From 92077d080710d762791c85954e9c667b24be3a80 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:31:38 +0200 Subject: [PATCH 001/315] LRC-SS-PLAYBACK: slideshow playback: fades, random order, repeat, previews prepared in advance, playback controls --- ROADMAP.md | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/slideshow.rs | 32 ++ crates/engine/src/lib.rs | 4 + crates/engine/src/library.rs | 4 + crates/engine/src/slideshow.rs | 622 +++++++++++++++++++++++++ crates/engine/src/tests_prefs.rs | 40 ++ crates/ui-egui/src/headless.rs | 5 + crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/lib.rs | 29 +- crates/ui-egui/src/menus.rs | 39 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 99 +++- crates/ui-egui/src/panels/slideshow.rs | 389 ++++++++++++++++ crates/ui-egui/src/render.rs | 28 +- crates/ui-egui/src/shortcuts.rs | 17 +- crates/ui-egui/src/state.rs | 5 +- crates/ui-egui/src/tests_masking.rs | 32 -- crates/ui-egui/src/tests_slideshow.rs | 242 ++++++++++ docs/parity.md | 12 +- docs/whats-new.md | 10 + 21 files changed, 1530 insertions(+), 93 deletions(-) create mode 100644 crates/engine/src/cmd/slideshow.rs create mode 100644 crates/engine/src/slideshow.rs create mode 100644 crates/ui-egui/src/panels/slideshow.rs create mode 100644 crates/ui-egui/src/tests_slideshow.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9bb4744b9..e7c8feb07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | -| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | +| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow playback (fades, random order, repeat, previews prepared ahead, playback controls; no Slideshow module view yet), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | | **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export | | **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7def13d3..1b3fcfd4b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -32,6 +32,7 @@ mod prefs; mod preset_files; pub mod previews; mod query; +mod slideshow; mod sync; mod xmp; @@ -144,6 +145,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xmp::specs()); v.extend(preset_files::specs()); v.extend(prefs::specs()); + v.extend(slideshow::specs()); v.extend(export::specs()); v.extend(before::specs()); v.extend(browse::specs()); diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs new file mode 100644 index 000000000..e6cd2d008 --- /dev/null +++ b/crates/engine/src/cmd/slideshow.rs @@ -0,0 +1,32 @@ +//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). A library preference, saved in the +//! library's `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. + +use serde_json::Value; + +use super::{CommandSpec, always, bad, cmd}; +use crate::{Result, Session}; + +fn playback(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?; + if next != s.slideshow { + s.slideshow = next; + s.save_prefs()?; + } + } + Ok(s.slideshow.to_json()) +} + +pub fn specs() -> Vec { + vec![cmd!( + "slideshow.playback", + "Slideshow Playback Settings", + [], + None, + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + always, + playback + )] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66acfe4a7..d10abd193 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -55,6 +55,7 @@ pub mod presets; pub mod rename; pub mod segment; pub mod sidecar; +pub mod slideshow; pub mod smart; pub mod sync; mod view; @@ -271,6 +272,8 @@ pub struct Session { pub import_defaults: import::ImportDefaults, /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). pub cache_mb: u32, + /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). + pub slideshow: slideshow::PlaybackSettings, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the @@ -353,6 +356,7 @@ impl Session { preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, + slideshow: slideshow::PlaybackSettings::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, } diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4c8bf28aa..4e843054e 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -158,6 +158,8 @@ struct PrefsFile { /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option, + /// Slideshow playback settings. + slideshow: crate::slideshow::PlaybackSettings, } fn presets_json(s: &Session) -> String { @@ -340,6 +342,7 @@ impl Session { self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; + self.slideshow = prefs.slideshow.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -576,6 +579,7 @@ impl Session { cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), + slideshow: self.slideshow.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs new file mode 100644 index 000000000..bbe146a65 --- /dev/null +++ b/crates/engine/src/slideshow.rs @@ -0,0 +1,622 @@ +//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that +//! plays the photos in view (durations, fades, random order, repeat, pause, stepping). +//! +//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any +//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline. +//! The frontend asks [`Playback::frame`] what to draw (the current slide, and while a transition +//! runs the previous one and its progress) and [`Playback::upcoming`] which slides to prepare; it +//! tells [`Playback::tick`] which slides are ready, so a transition never starts on a slide that +//! has not been rendered yet (the current one is held instead). +//! +//! The settings are a library preference (`prefs.json`, see [`crate::library`]), read and changed +//! with the `slideshow.playback` command. + +use lightcraft_catalog::PhotoId; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Shortest and longest time a slide stays up (seconds). +pub const SLIDE_MIN: f64 = 0.5; +pub const SLIDE_MAX: f64 = 120.0; +/// Longest fade (seconds); a fade is never longer than the slide duration. +pub const FADE_MAX: f64 = 10.0; + +/// How playback runs (Lightroom Classic's Playback panel, without music and pan & zoom). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlaybackSettings { + /// Seconds from the start of one slide's transition to the start of the next one's (the + /// fade in is part of it). + pub slide_duration: f64, + /// Seconds a transition takes (0 = cut). + pub fade_duration: f64, + /// Fade through this colour (out to it, then in from it) instead of crossfading. + pub fade_color: Option<[u8; 3]>, + /// Shuffle the photos (a new order for every pass when repeating). + pub random_order: bool, + /// Start over after the last slide; off: playback ends there. + pub repeat: bool, + /// Render the next slides before they are due. + pub prepare_previews: bool, +} + +impl Default for PlaybackSettings { + fn default() -> Self { + PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true } + } +} + +impl PlaybackSettings { + /// The settings with every value in range (a non-finite duration takes the default; the fade + /// is never longer than the slide). + pub fn sanitized(mut self) -> Self { + let d = PlaybackSettings::default(); + self.slide_duration = if self.slide_duration.is_finite() { self.slide_duration.clamp(SLIDE_MIN, SLIDE_MAX) } else { d.slide_duration }; + let fade = if self.fade_duration.is_finite() { self.fade_duration } else { d.fade_duration }; + self.fade_duration = fade.clamp(0.0, FADE_MAX).min(self.slide_duration); + self + } + + pub fn to_json(&self) -> Value { + json!({ + "slideDuration": self.slide_duration, + "fadeDuration": self.fade_duration, + "fadeColor": self.fade_color.map(hex), + "randomOrder": self.random_order, + "repeat": self.repeat, + "preparePreviews": self.prepare_previews, + }) + } + + /// These settings with the keys `p` names changed (the others kept), validated and clamped. + /// A wrong type, an unknown key or a non-finite number is an error; nothing changes then. + pub fn merged(&self, p: &Value) -> std::result::Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "slideDuration" => s.slide_duration = finite(k, v)?, + "fadeDuration" => s.fade_duration = finite(k, v)?, + "fadeColor" => s.fade_color = color(v)?, + "randomOrder" => s.random_order = boolean(k, v)?, + "repeat" => s.repeat = boolean(k, v)?, + "preparePreviews" => s.prepare_previews = boolean(k, v)?, + other => { + return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)")); + } + } + } + // a fade longer than a slide asked for together with a shorter slide: the slide wins + Ok(s.sanitized()) + } +} + +fn finite(k: &str, v: &Value) -> std::result::Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{k}` must be a finite number of seconds, not {v}")), + } +} + +fn boolean(k: &str, v: &Value) -> std::result::Result { + v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}")) +} + +fn hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +/// `null` / `""` / `"none"` = crossfade; `"#rrggbb"` or `[r, g, b]` (0–255) = fade through it. +fn color(v: &Value) -> std::result::Result, String> { + let bad = || format!("`fadeColor` must be \"#rrggbb\", [r, g, b] or null, not {v}"); + match v { + Value::Null => Ok(None), + Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None), + Value::String(s) => { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 || !h.is_ascii() { + return Err(bad()); + } + let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad); + Ok(Some([byte(0)?, byte(2)?, byte(4)?])) + } + Value::Array(a) if a.len() == 3 => { + let mut c = [0u8; 3]; + for (o, x) in c.iter_mut().zip(a) { + *o = x.as_u64().filter(|n| *n <= 255).ok_or_else(bad)? as u8; + } + Ok(Some(c)) + } + _ => Err(bad()), + } +} + +/// What a tick did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tick { + /// Nothing changed (the slide is up, paused, or a transition is running). + Idle, + /// The next slide is due but not ready: the current one is held. + Waiting(PhotoId), + /// A new slide came up (its transition starts now). + Advanced(PhotoId), + /// The last slide was shown and repeat is off. + Ended, +} + +/// What to draw at a moment of playback. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Frame { + pub current: PhotoId, + /// The slide being faded out, while a transition runs. + pub from: Option, + /// How far the transition is (0–1; 1 when there is none). + pub progress: f32, + pub fade_color: Option<[u8; 3]>, +} + +/// One run of a slideshow over a fixed list of photos. +#[derive(Clone, Debug)] +pub struct Playback { + settings: PlaybackSettings, + photos: Vec, + /// This pass's order (indices into `photos`). + order: Vec, + /// Index into `order` of the slide up now. + pos: usize, + pass: u64, + seed: u64, + /// When the current slide came up (its transition started). + started: f64, + /// The slide fading out (until the transition ends). + from: Option, + /// Paused at this time. + paused_at: Option, + ended: bool, + /// The next slide was due but not ready at the last tick. + held: Option, +} + +/// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed). +fn splitmix(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// Fisher–Yates shuffle of `v` with `seed`. +fn shuffle(v: &mut [usize], seed: u64) { + let mut st = seed; + for i in (1..v.len()).rev() { + let j = (splitmix(&mut st) % (i as u64 + 1)) as usize; + v.swap(i, j); + } +} + +impl Playback { + /// Start playing `photos` (the ones in view, in view order) at `start` (the selected photo; + /// the first one when absent or not among them), at time `now`. `seed` fixes the random order. + /// `None` when there is nothing to show. + pub fn new(photos: Vec, start: Option, settings: PlaybackSettings, seed: u64, now: f64) -> Option { + if photos.is_empty() { + return None; + } + let settings = settings.sanitized(); + let n = photos.len(); + let first = start.and_then(|s| photos.iter().position(|p| *p == s)).unwrap_or(0); + let order: Vec = if settings.random_order { + // the starting photo first, the others shuffled + let mut rest: Vec = (0..n).filter(|i| *i != first).collect(); + shuffle(&mut rest, seed); + std::iter::once(first).chain(rest).collect() + } else { + // from the starting photo to the end, then the ones before it: every photo once + (first..n).chain(0..first).collect() + }; + let now = if now.is_finite() { now } else { 0.0 }; + Some(Playback { settings, photos, order, pos: 0, pass: 0, seed, started: now, from: None, paused_at: None, ended: false, held: None }) + } + + pub fn settings(&self) -> &PlaybackSettings { + &self.settings + } + + /// The order of pass `pass` (after the first), given the order of the pass before it. + fn order_after(&self, pass: u64, prev: &[usize]) -> Vec { + let mut order = prev.to_vec(); + if self.settings.random_order { + order.sort_unstable(); + shuffle(&mut order, self.seed ^ pass.wrapping_mul(0xa076_1d64_78bd_642f)); + // never the same photo twice in a row across passes + if order.len() > 1 && order.first() == prev.last() { + order.swap(0, 1); + } + } + order + } + + fn photo_at(&self, order: &[usize], pos: usize) -> Option { + order.get(pos).and_then(|i| self.photos.get(*i)).copied() + } + + /// The slide up now. + pub fn current(&self) -> PhotoId { + self.photo_at(&self.order, self.pos).or_else(|| self.photos.first().copied()).unwrap_or(PhotoId(0)) + } + + /// (slide number from 1, slides per pass). + pub fn position(&self) -> (usize, usize) { + (self.pos + 1, self.order.len()) + } + + pub fn is_paused(&self) -> bool { + self.paused_at.is_some() + } + + pub fn is_ended(&self) -> bool { + self.ended + } + + /// The slide that was due but not ready at the last tick (the current one is held for it). + pub fn waiting_for(&self) -> Option { + self.held + } + + /// The clock as playback sees it: stopped while paused. + fn clock(&self, now: f64) -> f64 { + let now = if now.is_finite() { now } else { self.started }; + self.paused_at.map_or(now, |p| p.min(now)) + } + + /// When the next slide is due. + pub fn due(&self) -> f64 { + self.started + self.settings.slide_duration + } + + /// Seconds until the next slide is due (as of `now`; frozen while paused). + pub fn remaining(&self, now: f64) -> f64 { + (self.due() - self.clock(now)).max(0.0) + } + + /// The next `n` slides in playing order (into the next pass when repeating; never the one up + /// now twice). + pub fn upcoming(&self, n: usize) -> Vec { + let mut out = Vec::new(); + let mut order = self.order.clone(); + let (mut pos, mut pass) = (self.pos, self.pass); + while out.len() < n { + pos += 1; + if pos >= order.len() { + if !self.settings.repeat { + break; + } + pass += 1; + order = self.order_after(pass, &order); + pos = 0; + } + match self.photo_at(&order, pos) { + Some(p) if p != self.current() && !out.contains(&p) => out.push(p), + _ => break, + } + } + out + } + + /// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat. + fn advance(&mut self) -> Tick { + if self.pos + 1 < self.order.len() { + self.pos += 1; + } else if self.settings.repeat { + self.pass += 1; + self.order = self.order_after(self.pass, &self.order); + self.pos = 0; + } else { + self.ended = true; + return Tick::Ended; + } + Tick::Advanced(self.current()) + } + + /// Advance the timeline to `now`. `ready(photo)` says whether a slide can be shown: the + /// transition to the next slide waits until it is. + pub fn tick(&mut self, now: f64, ready: impl Fn(PhotoId) -> bool) -> Tick { + if self.ended { + return Tick::Ended; + } + let now = self.clock(now); + if self.from.is_some() && now >= self.started + self.settings.fade_duration { + self.from = None; + } + if self.paused_at.is_some() { + return Tick::Idle; + } + // a slide's time starts once it can be shown (the first one, one stepped to with ←/→) + if self.from.is_none() && !ready(self.current()) { + self.started = now; + return Tick::Idle; + } + if now < self.due() { + return Tick::Idle; + } + let Some(next) = self.upcoming(1).first().copied() else { + if self.settings.repeat { + // a single photo repeating: nothing to change to + self.started = now; + return Tick::Idle; + } + self.ended = true; + return Tick::Ended; + }; + if !ready(next) { + self.held = Some(next); + return Tick::Waiting(next); + } + self.held = None; + let old = self.current(); + let t = self.advance(); + if let Tick::Advanced(_) = t { + self.started = now; + self.from = (self.settings.fade_duration > 0.0).then_some(old); + } + t + } + + /// What to draw at `now`. + pub fn frame(&self, now: f64) -> Frame { + let now = self.clock(now); + let fade = self.settings.fade_duration; + let (from, progress) = match self.from { + Some(f) if fade > 0.0 && now < self.started + fade => (Some(f), ((now - self.started) / fade).clamp(0.0, 1.0) as f32), + _ => (None, 1.0), + }; + Frame { current: self.current(), from, progress, fade_color: self.settings.fade_color } + } + + /// Pause or resume at `now`; resuming keeps the time the slide had left. Returns whether it + /// is paused now. + pub fn toggle_pause(&mut self, now: f64) -> bool { + let now = if now.is_finite() { now } else { self.started }; + match self.paused_at.take() { + Some(p) => { + // the slide (and a transition in progress) carries on where it stopped + self.started += (now - p).max(0.0); + } + None => self.paused_at = Some(now), + } + self.paused_at.is_some() + } + + /// Step `delta` slides (←/→) at `now`: a cut, and the new slide gets its full time (paused + /// playback stays paused). Past the last slide without repeat playback ends; before the first + /// it stays there. + pub fn step(&mut self, delta: i32, now: f64) -> Tick { + if self.ended { + return Tick::Ended; + } + let now = if now.is_finite() { now } else { self.started }; + // (more steps than slides would only go round again) + let steps = delta.unsigned_abs().min(self.order.len().min(u32::MAX as usize) as u32); + let mut t = Tick::Idle; + if delta > 0 { + for _ in 0..steps { + t = self.advance(); + if t == Tick::Ended { + return t; + } + } + } else { + for _ in 0..steps { + if self.pos > 0 { + self.pos -= 1; + } else if self.settings.repeat { + self.pos = self.order.len().saturating_sub(1); + } + } + t = Tick::Advanced(self.current()); + } + self.from = None; + self.held = None; + self.started = now; + if self.paused_at.is_some() { + self.paused_at = Some(now); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec { + (1..=n).map(PhotoId).collect() + } + + fn settings(slide: f64, fade: f64) -> PlaybackSettings { + PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } + } + + fn all_ready(_: PhotoId) -> bool { + true + } + + #[test] + fn timeline_durations_and_fade_progress() { + let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); + assert_eq!(p.current(), PhotoId(2), "starts at the selected photo"); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(13.9, all_ready), Tick::Idle); + assert_eq!(p.frame(13.9).from, None); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(PhotoId(3))); + let f = p.frame(14.25); + assert_eq!((f.current, f.from), (PhotoId(3), Some(PhotoId(2)))); + assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); + assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); + assert_eq!(p.tick(15.0, all_ready), Tick::Idle); + assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); + // the slide period counts from its transition: next at 18 + assert_eq!(p.tick(17.99, all_ready), Tick::Idle); + assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(PhotoId(1)), "wraps to the photos before the start"); + assert_eq!(p.position(), (3, 3)); + } + + #[test] + fn fade_zero_is_a_cut_and_fade_color_is_reported() { + let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(2))); + assert_eq!(p.frame(2.0).from, None); + let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + p.tick(2.0, all_ready); + assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); + } + + #[test] + fn arrows_cut_immediately_and_restart_the_slide_time() { + let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); + p.tick(4.0, all_ready); + assert!(p.frame(4.5).from.is_some(), "fading"); + assert_eq!(p.step(1, 4.5), Tick::Advanced(PhotoId(3))); + assert_eq!(p.frame(4.5), Frame { current: PhotoId(3), from: None, progress: 1.0, fade_color: None }, "a cut"); + assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(2))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(1))); + // repeat on: before the first is the last + assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(4))); + assert_eq!(p.remaining(6.0), 4.0); + } + + #[test] + fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { + let photos = ids(20); + let run = |seed: u64| { + let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; + let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); + let mut seen = vec![p.current()]; + for k in 1..60 { + p.tick(k as f64, all_ready); + seen.push(p.current()); + } + seen + }; + let a = run(42); + assert_eq!(a, run(42), "same seed, same order"); + assert_ne!(a, run(43), "another seed, another order"); + assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); + assert_ne!(a[..20].to_vec(), photos, "shuffled"); + for pass in a.chunks(20).take(2) { + let mut sorted = pass.to_vec(); + sorted.sort(); + assert_eq!(sorted, photos, "every photo once per pass"); + } + assert_ne!(a[..20], a[20..40], "a new order for the next pass"); + assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); + } + + #[test] + fn repeat_off_stops_after_the_last_slide() { + let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(PhotoId(3))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(1))); + assert_eq!(p.upcoming(2), vec![], "nothing after the last"); + assert_eq!(p.tick(3.0, all_ready), Tick::Ended); + assert!(p.is_ended()); + // → past the last one ends it too + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + assert_eq!(p.step(1, 0.5), Tick::Advanced(PhotoId(2))); + assert_eq!(p.step(1, 0.6), Tick::Ended); + // with repeat: it goes on + let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); + for k in 1..10 { + assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); + } + } + + #[test] + fn pause_and_resume_keep_the_remaining_time() { + let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); + assert!(p.toggle_pause(3.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); + assert_eq!(p.remaining(100.0), 1.0); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(100.9, all_ready), Tick::Idle); + assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(PhotoId(2)), "1 s left after resuming"); + // pausing mid-fade freezes the fade + p.toggle_pause(101.25); + assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); + p.toggle_pause(150.0); + assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); + } + + #[test] + fn a_transition_waits_for_the_next_slide_to_be_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![PhotoId(2), PhotoId(3)], "what to prepare"); + let not_two = |id: PhotoId| id != PhotoId(2); + assert_eq!(p.tick(2.0, not_two), Tick::Waiting(PhotoId(2))); + assert_eq!(p.frame(3.0), Frame { current: PhotoId(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); + assert_eq!(p.waiting_for(), Some(PhotoId(2))); + assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(PhotoId(2))); + assert_eq!(p.frame(3.5).from, Some(PhotoId(1)), "the fade starts once it is ready"); + assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); + } + + #[test] + fn a_slide_s_time_starts_once_it_is_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); + let none = |_: PhotoId| false; + assert_eq!(p.tick(1.0, none), Tick::Idle); + assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 2.0); + assert_eq!(p.tick(6.0, all_ready), Tick::Idle); + assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(PhotoId(2)), "2 s from when it could be shown"); + } + + #[test] + fn hostile_settings_are_rejected_or_clamped() { + let d = PlaybackSettings::default(); + for bad in [ + json!({"slideDuration": "fast"}), + json!({"fadeDuration": null}), + json!({"repeat": 1}), + json!({"fadeColor": "#12345"}), + json!({"fadeColor": "#ééé"}), + json!({"fadeColor": [300, 0, 0]}), + json!({"fadeColor": [1, 2]}), + json!({"speed": 2}), + json!([1, 2]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); + let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); + let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); + assert_eq!(s.fade_color, Some([255, 128, 0])); + assert_eq!(s.to_json()["fadeColor"], "#ff8000"); + assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); + // NaN / inf (from a hand-edited file or a caller in-process) take the defaults + let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); + assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); + // and the timeline never panics on hostile times + let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); + p.tick(f64::NAN, all_ready); + p.tick(f64::INFINITY, all_ready); + p.toggle_pause(f64::NEG_INFINITY); + p.step(i32::MIN, f64::NAN); + let _ = p.frame(f64::NAN); + assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); + } + + #[test] + fn a_single_photo_repeats_without_changing() { + let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![]); + assert_eq!(p.tick(5.0, all_ready), Tick::Idle); + assert_eq!(p.frame(5.0).from, None); + } +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3534064f2..cc9f5f74d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -317,3 +317,43 @@ fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_cl drop(s); let _ = std::fs::remove_dir_all(&lib); } + +#[test] +fn slideshow_playback_settings_persist_with_the_library() { + let lib = temp_dir("slideshow"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.playback", &json!({})).unwrap(); + assert_eq!(r["slideDuration"], 4.0, "defaults: {r}"); + let r = s + .execute( + "slideshow.playback", + &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}), + ) + .unwrap(); + assert_eq!(r["fadeColor"], "#000000"); + // a hostile value changes nothing + assert!(s.execute("slideshow.playback", &json!({"slideDuration": "soon", "repeat": true})).is_err()); + assert!(!s.slideshow.repeat); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.playback", &json!({})).unwrap(); + assert_eq!( + r, + json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true}) + ); + drop(s); + // out-of-range values written by hand are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideshow": {"slideDuration": 1e9, "fadeDuration": -3}}"#).unwrap(); + let s = { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s + }; + assert_eq!((s.slideshow.slide_duration, s.slideshow.fade_duration), (120.0, 0.0)); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7de..0d4e59e78 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -269,6 +269,11 @@ impl Headless { self.frames } + /// The simulated clock (seconds) the next frame runs at. + pub fn time(&self) -> f64 { + self.time + } + /// Every piece of text the last frame painted ([`HeadlessView::painted_text`]). pub fn painted_text(&self) -> Vec { self.view.painted_text() diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9a4718895..9f7622ef7 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,10 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Play (a triangle pointing right). + Play, + /// Pause (two bars). + Pause, } struct Pen<'a> { @@ -423,6 +427,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 18.0, 6.5, 200.0, 340.0); pen.rect(2.5, 2.5, 17.5, 17.5, 2.0); } + Play => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], color), + Pause => { + pen.rect_fill(5.0, 4.0, 8.5, 16.0, 0.5); + pen.rect_fill(11.5, 4.0, 15.0, 16.0, 0.5); + } FaceBox => { pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]); pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..1a4bc9d83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,8 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_slideshow; +#[cfg(test)] mod tests_switch_library; #[cfg(test)] mod tests_sync; @@ -453,28 +455,9 @@ impl LightcraftApp { } /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). - /// Advance a running slideshow (wrapping around at the end). + /// Advance a running slideshow. fn slideshow_tick(&mut self, ctx: &egui::Context) { - let Some((interval, due, paused)) = self.ui.slideshow else { return }; - if !self.ui.fullscreen { - self.ui.slideshow = None; - return; - } - if paused { - return; - } - let now = ctx.input(|i| i.time); - if now >= due { - let vis = self.session.visible_cloned(); - if let Some(cur) = self.session.active() - && !vis.is_empty() - { - let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len()); - let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]})); - } - self.ui.slideshow = Some((interval, now + interval, false)); - } - ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval))); + panels::slideshow::tick(self, ctx); } /// Announce the end of a Build Previews run. @@ -1022,7 +1005,9 @@ impl LightcraftApp { self.ai_mask_detail(&ctx); if self.ui.fullscreen { // full-screen preview: the photo alone on black - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c9b397f60..21029efea 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -138,6 +138,12 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.smartAlbum", "New Smart Album…", None, "File"), ("view.photoCounts", "Show Photo Counts", None, "View"), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.slideshowOptions", "Slideshow Options…", None, "View"), + // the slideshow's playback controls (Space, ←/→ and Esc while it plays) + ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), + ("slideshow.next", "Next Slide", None, ""), + ("slideshow.previous", "Previous Slide", None, ""), + ("slideshow.end", "End Slideshow", None, ""), ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), @@ -418,25 +424,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // {interval?: seconds (4)}: the photos in view, full screen, one after another - let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0); - if app.session.active().is_none() { - let first = app.session.visible_cloned().first().copied(); - match first { - Some(f) => { - let _ = app.run("library.select", json!({"ids": [f.0]})); - } - None => return Some(Err("no photos to show".into())), - } - } + // the photos in view, full screen, with the playback settings (see panels::slideshow) let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, false)); - app.ui.fullscreen = true; - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); - Ok(json!({"interval": interval})) + let r = crate::panels::slideshow::start(app, p, now); + if r.is_ok() { + app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + } + r + } + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { + let now = ctx.input(|i| i.time); + crate::panels::slideshow::command(app, id, now) + } + "view.slideshowOptions" => { + app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); + Ok(Value::Null) } "view.fullScreenPreview" => { app.ui.fullscreen = !app.ui.fullscreen; @@ -1615,6 +1617,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(), s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..0652414c1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod right; pub mod rules_editor; pub mod second; pub mod settings; +pub mod slideshow; pub mod strip; pub mod topbar; pub mod unnamed; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7036e10f1..7569f4a3c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -1,12 +1,14 @@ -//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Faces, AI Denoise. +//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces, +//! AI Denoise. //! //! Changes apply immediately (no OK/Cancel). Where they are stored: //! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, //! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in //! the UI state, //! saved by the host in its config folder (`ui.json`); -//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the -//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`, +//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow +//! playback) go through the `library.preferences` / `library.xmpPreferences` / +//! `slideshow.playback` commands into the library's `prefs.json`, //! so they travel with the library. use egui::RichText; @@ -24,6 +26,7 @@ pub const TABS: &[(&str, &str)] = &[ ("performance", "Performance"), ("display", "Display"), ("interface", "Interface"), + ("slideshow", "Slideshow"), ("faces", "Faces"), ("denoise", "AI Denoise"), ]; @@ -53,6 +56,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { "performance" => performance_tab(app, ui, &t), "display" => display_tab(app, ui, &t), "interface" => interface_tab(app, ui, &t), + "slideshow" => slideshow_tab(app, ui, &t), "faces" => super::faces::settings_tab(app, ui, &t), "denoise" => super::denoise::settings_tab(app, ui, &t), _ => general_tab(app, ui, &t), @@ -620,6 +624,95 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +// ----------------------------------------------------------------------------------- Slideshow + +/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory +/// (the slideshow reads it); letting go saves it through `slideshow.playback`. +fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive) { + let mut v = value; + let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + if r.changed() + && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) + { + app.session.slideshow = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.playback", json!({ key: v })); + } +} + +fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; + heading(ui, t, crate::i18n::tr("Playback")); + let s = app.session.slideshow.clone(); + row(ui, t, crate::i18n::tr("Slide duration"), |ui| { + duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) + }); + row(ui, t, crate::i18n::tr("Fade duration"), |ui| { + duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) + }); + hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); + row(ui, t, crate::i18n::tr("Fade"), |ui| { + #[derive(Clone, Copy, PartialEq)] + enum Fade { + Cross, + Black, + White, + Color, + } + let mut f = match s.fade_color { + None => Fade::Cross, + Some([0, 0, 0]) => Fade::Black, + Some([255, 255, 255]) => Fade::White, + Some(_) => Fade::Color, + }; + let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; + if choices(ui, "settingsSlideshowFade", &opts, &mut f) { + let c = match f { + Fade::Cross => Value::Null, + Fade::Black => json!("#000000"), + Fade::White => json!("#ffffff"), + Fade::Color => json!([64, 64, 64]), + }; + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + if f == Fade::Color + && let Some(mut c) = s.fade_color + { + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), "button:slideshowFadeColor", r.rect); + if r.changed() { + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + } + }); + let mut v = s.random_order; + if check(ui, "settings.slideshowRandom", &mut v, "Random order") { + let _ = app.run("slideshow.playback", json!({"randomOrder": v})); + } + let mut v = s.repeat; + if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { + let _ = app.run("slideshow.playback", json!({"repeat": v})); + } + let mut v = s.prepare_previews; + if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { + let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); + } + hint( + ui, + t, + crate::i18n::tr( + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", + ), + ); + hint( + ui, + t, + crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), + ); +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs new file mode 100644 index 000000000..9e1416a67 --- /dev/null +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -0,0 +1,389 @@ +//! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the +//! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). +//! +//! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this +//! module renders the slides, draws the frame it asks for and the playback controls. +//! +//! Smooth transitions: every slide is rendered into its own texture ([`Slot::Slide`]) off the UI +//! thread. With "prepare previews" on, the next [`AHEAD`] slides are requested as soon as a slide +//! comes up, so they are ready long before they are due; a fade then only draws two textures +//! that already exist (no render, no upload inside the fade). The timeline does not start a +//! transition until the next slide's texture exists ([`ready`]): the current slide is held +//! instead of flashing a stand-in. With it off, a slide is rendered when it is due. +//! +//! Which picture a slide shows comes from one place, [`slide_job`], so a later slide composer +//! (layout, overlays, backdrop) replaces that function and the playback stays as it is. + +use egui::{Align2, Color32, Pos2, Rect, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::slideshow::{Playback, Tick}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::Icon; +use crate::render::Slot; +use crate::widgets::{icon_button, register}; + +/// Slides prepared ahead of the one up. +pub const AHEAD: usize = 2; +/// Seconds the playback controls stay after the pointer last moved. +pub const CONTROLS_SECS: f64 = 2.5; + +/// A running slideshow. +#[derive(Clone, Debug)] +pub struct Run { + pub play: Playback, + /// The playback controls show until this time (egui time). + pub controls_until: f64, + /// Where the pointer was last frame (the controls appear when it moves). + pub pointer: Option, + /// Slides no render can be made for (gone from the catalog, nothing to read): shown with + /// whatever stand-in there is, never waited for. + pub unrenderable: Vec, +} + +/// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?, +/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's +/// playback settings; the keys given change them for this run only (`interval` = slideDuration, +/// `seed` fixes the random order). +pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result { + let mut overrides = p.as_object().cloned().unwrap_or_default(); + if let Some(i) = overrides.remove("interval") { + overrides.insert("slideDuration".into(), i); + } + let seed = match overrides.remove("seed") { + None | Some(Value::Null) => (now.abs() * 1000.0) as u64 ^ 0x5eed_5eed, + Some(v) => v.as_u64().ok_or_else(|| format!("view.slideshow: `seed` must be a whole number, not {v}"))?, + }; + let settings = app.session.slideshow.merged(&Value::Object(overrides)).map_err(|e| format!("view.slideshow: {e}"))?; + let photos = app.session.visible_cloned(); + let Some(play) = Playback::new(photos, app.session.active(), settings, seed, now) else { + return Err("no photos to show".into()); + }; + let first = play.current(); + if app.session.active() != Some(first) { + let _ = app.run("library.select", json!({"ids": [first.0]})); + } + let (_, slides) = play.position(); + let out = json!({"slides": slides, "seed": seed, "settings": play.settings().to_json()}); + app.ui.slideshow = Some(Box::new(Run { play, controls_until: 0.0, pointer: None, unrenderable: Vec::new() })); + app.ui.fullscreen = true; + app.ui.zoom = crate::state::Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + Ok(out) +} + +/// End the slideshow (back to the view it started from, on the last photo shown). +pub fn end(app: &mut LightcraftApp) { + app.ui.slideshow = None; + app.ui.fullscreen = false; + app.renderer.release_slides(&[]); +} + +/// The slideshow's own commands (the playback controls and their keys). +pub fn command(app: &mut LightcraftApp, id: &str, now: f64) -> Result { + let Some(run) = app.ui.slideshow.as_mut() else { return Err("no slideshow is playing".into()) }; + let t = match id { + "slideshow.togglePause" => { + run.play.toggle_pause(now); + return Ok(state(&run.play, now)); + } + "slideshow.next" => run.play.step(1, now), + "slideshow.previous" => run.play.step(-1, now), + _ => { + end(app); + return Ok(Value::Null); + } + }; + after(app, t); + Ok(app.ui.slideshow.as_ref().map(|r| state(&r.play, now)).unwrap_or(json!({"ended": true}))) +} + +/// What the slideshow is doing (for `slideshow.*` replies and tests). +pub fn state(play: &Playback, now: f64) -> Value { + let (n, of) = play.position(); + let f = play.frame(now); + json!({ + "current": play.current().0, + "slide": n, + "of": of, + "paused": play.is_paused(), + "fading": f.from.is_some(), + "progress": f.progress, + "waitingFor": play.waiting_for().map(|p| p.0), + "remaining": play.remaining(now), + }) +} + +/// Act on what the timeline did: follow the slide with the selection, or end. +fn after(app: &mut LightcraftApp, t: Tick) { + match t { + Tick::Advanced(id) if app.session.active() != Some(id) => { + let _ = app.run("library.select", json!({"ids": [id.0]})); + } + Tick::Ended => end(app), + _ => {} + } +} + +/// Can `id` be shown as a slide now: its texture exists (or it can't be rendered at all, and its +/// stand-in is all there will be: playback goes on rather than waiting forever). +pub fn ready(app: &LightcraftApp, id: PhotoId) -> bool { + app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id) + || app.renderer.failure(Slot::Slide(id)).is_some() + || app.session.catalog.photo(id).is_none() + || app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&id)) +} + +/// Advance a running slideshow (every frame, before drawing). +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(run) = app.ui.slideshow.as_ref() else { + app.renderer.release_slides(&[]); + return; + }; + if !app.ui.fullscreen { + end(app); + return; + } + let now = ctx.input(|i| i.time); + let mut play = run.play.clone(); + let t = play.tick(now, |id| ready(app, id)); + if let Some(run) = app.ui.slideshow.as_mut() { + run.play = play; + } + after(app, t); + let Some(run) = app.ui.slideshow.as_ref() else { return }; + let f = run.play.frame(now); + if f.from.is_some() { + ctx.request_repaint(); // a fade draws every frame + } else if !run.play.is_paused() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0))); + } + if run.controls_until > now { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((run.controls_until - now).clamp(0.016, CONTROLS_SECS))); + } +} + +/// The render that shows slide `id` in `area` (logical points at `ppp`): the photo as developed, +/// fitted to the screen. The one place that decides what a slide's picture is. +fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option { + let photo = app.session.catalog.photo(id)?.clone(); + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); + let aspect = frame.aspect() as f32; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 }; + let fit = (area.width() / aspect).min(area.height()); + let drawn_long = fit.max(fit * aspect) * ppp; + let native = super::detail::output_px(&frame); + let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side); + let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) }; + app.session.loupe_job(id, w.max(8), h.max(8), true) +} + +/// The slides to keep rendered now, most wanted first, with their render priority. +fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> { + let f = play.frame(now); + let mut v = vec![(f.current, 100)]; + if let Some(from) = f.from { + v.push((from, 99)); + } + if let Some(next) = play.waiting_for() { + v.push((next, 98)); + } + if play.settings().prepare_previews { + for (k, id) in play.upcoming(AHEAD).into_iter().enumerate() { + v.push((id, 97u32.saturating_sub(k as u32))); + } + } + let mut seen = Vec::new(); + v.retain(|(id, _)| { + let fresh = !seen.contains(id); + seen.push(*id); + fresh + }); + v +} + +/// The texture to draw for slide `id`: its own render, else the loupe's or a thumbnail (a slide +/// stepped to with ←/→ shows at once, sharp when its render lands). +fn texture(app: &LightcraftApp, id: PhotoId) -> Option<&crate::render::Tex> { + app.renderer + .textures + .get(&Slot::Slide(id)) + .filter(|t| t.photo == id) + .or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)) + .or_else(|| app.renderer.thumb(id)) +} + +fn full_uv() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) +} + +/// The parts of `outer` outside `inner` (up to four bands). +fn outside(outer: Rect, inner: Rect) -> Vec { + let i = inner.intersect(outer); + if !i.is_positive() { + return vec![outer]; + } + let mut v = Vec::new(); + if i.top() > outer.top() { + v.push(Rect::from_min_max(outer.min, pos2(outer.right(), i.top()))); + } + if i.bottom() < outer.bottom() { + v.push(Rect::from_min_max(pos2(outer.left(), i.bottom()), outer.max)); + } + if i.left() > outer.left() { + v.push(Rect::from_min_max(pos2(outer.left(), i.top()), pos2(i.left(), i.bottom()))); + } + if i.right() < outer.right() { + v.push(Rect::from_min_max(pos2(i.right(), i.top()), pos2(outer.right(), i.bottom()))); + } + v +} + +/// Draw the slideshow (full screen) and its controls. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = ui.max_rect(); + app.canvas_rect = Some(canvas); + // (`ui.pointer` positions are relative to it) + app.image_rect = Some(canvas); + let Some(run) = app.ui.slideshow.as_ref() else { return }; + let now = ui.input(|i| i.time); + let ppp = ui.ctx().pixels_per_point(); + let side = super::detail::texture_side(ui.ctx()); + let want = wanted(&run.play, now); + let frame = run.play.frame(now); + let mut unrenderable = Vec::new(); + for (id, priority) in &want { + match slide_job(app, *id, canvas, ppp, side) { + Some(job) => app.renderer.request(Slot::Slide(*id), job, *priority), + None => unrenderable.push(*id), + } + } + if let Some(run) = app.ui.slideshow.as_mut() { + for id in unrenderable { + if !run.unrenderable.contains(&id) { + run.unrenderable.push(id); + } + } + } + let keep: Vec = want.iter().map(|(id, _)| *id).collect(); + app.renderer.release_slides(&keep); + let p = ui.painter_at(canvas); + p.rect_filled(canvas, 0.0, Color32::BLACK); + let rect_of = |t: &crate::render::Tex| super::detail::fit_texture_rect(canvas, t.size); + let draw = |id: PhotoId, alpha: f32| -> Option { + let t = texture(app, id)?; + let r = rect_of(t); + p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(alpha.clamp(0.0, 1.0))); + Some(r) + }; + let k = frame.progress.clamp(0.0, 1.0); + match (frame.from, frame.fade_color) { + // fade through a colour: out to it, then in from it + (Some(from), Some(c)) => { + let veil = if k < 0.5 { + draw(from, 1.0); + k * 2.0 + } else { + draw(frame.current, 1.0); + 2.0 - k * 2.0 + }; + p.rect_filled(canvas, 0.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(veil.clamp(0.0, 1.0))); + } + // crossfade: where the slides overlap the new one goes over the old at `k`; where only + // the old one is, it fades to the backdrop + (Some(from), None) => { + let new_rect = texture(app, frame.current).map(rect_of); + if let Some(t) = texture(app, from) { + let r = rect_of(t); + match new_rect { + Some(n) => { + p.with_clip_rect(n.intersect(canvas)).image(t.tex.id(), r, full_uv(), Color32::WHITE); + for band in outside(r, n) { + p.with_clip_rect(band).image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k)); + } + } + None => { + p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k)); + } + } + } + draw(frame.current, k); + } + _ => { + draw(frame.current, 1.0); + } + } + register(ui.ctx(), "slideshow:canvas", canvas); + controls(app, ui, canvas, now); +} + +/// The playback controls: previous, play / pause, next, slide n of N, end. They appear when the +/// pointer moves and fade out [`CONTROLS_SECS`] after it stops (or stay while it is over them). +fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64) { + let t = crate::theme::Tokens::get(ui.ctx()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let pressed = ui.input(|i| i.pointer.any_pressed()); + let bar = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 56.0), vec2(300.0, 44.0)); + let Some(run) = app.ui.slideshow.as_mut() else { return }; + let moved = pos.is_some() && run.pointer.is_some() && pos != run.pointer; + let over = pos.is_some_and(|p| bar.expand(8.0).contains(p)); + if moved || pressed || (over && run.controls_until > now) { + run.controls_until = now + CONTROLS_SECS; + } + run.pointer = pos.or(run.pointer); + let left = run.controls_until - now; + if left <= 0.0 { + return; + } + let paused = run.play.is_paused(); + let (n, of) = run.play.position(); + // fade out over the last half second + let alpha = (left / 0.5).clamp(0.0, 1.0) as f32; + let p = ui.painter_at(canvas); + p.rect_filled(bar, 10.0, Color32::from_black_alpha((200.0 * alpha) as u8)); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.set_opacity(alpha); + child.spacing_mut().item_spacing.x = 4.0; + let size = vec2(36.0, 36.0); + let mut fire = None; + if icon_button(&mut child, "slideshowPrevious", Icon::ChevronLeft, size, false, true, "Previous slide (←)").clicked() { + fire = Some("slideshow.previous"); + } + let (icon, tip) = if paused { (Icon::Play, "Play (Space)") } else { (Icon::Pause, "Pause (Space)") }; + if icon_button(&mut child, "slideshowPlayPause", icon, size, false, true, tip).clicked() { + fire = Some("slideshow.togglePause"); + } + if icon_button(&mut child, "slideshowNext", Icon::ChevronRight, size, false, true, "Next slide (→)").clicked() { + fire = Some("slideshow.next"); + } + let (r, _) = child.allocate_exact_size(vec2(118.0, 36.0), egui::Sense::hover()); + let label = format!("{n} / {of}"); + child.painter().text(r.center(), Align2::CENTER_CENTER, label, t.font(13.0), Color32::from_gray(230).gamma_multiply(alpha)); + if icon_button(&mut child, "slideshowEnd", Icon::Close, size, false, true, "End slideshow (Esc)").clicked() { + fire = Some("slideshow.end"); + } + if let Some(id) = fire { + let _ = app.run(id, json!({})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crossfade_bands_cover_the_old_slide_outside_the_new_one() { + let outer = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 60.0)); + let inner = Rect::from_min_max(pos2(30.0, 0.0), pos2(70.0, 60.0)); + let bands = outside(outer, inner); + let area: f32 = bands.iter().map(|b| b.area()).sum(); + assert_eq!(area, 100.0 * 60.0 - 40.0 * 60.0); + assert_eq!(outside(outer, Rect::from_min_max(pos2(200.0, 0.0), pos2(300.0, 10.0))), vec![outer], "no overlap: all of it"); + assert!(outside(outer, outer.expand(5.0)).is_empty(), "covered"); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 6b2293f65..8346650c7 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -52,6 +52,9 @@ pub enum Slot { Import(u32), /// The second window's view of the active photo. Second, + /// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see + /// [`crate::panels::slideshow`]). + Slide(PhotoId), } pub struct Tex { @@ -206,6 +209,8 @@ pub struct Renderer { variant_want: usize, /// Frames polled so far. frame: u64, + /// Slideshow slides may be held ([`Self::release_slides`] has something to do). + slides_live: bool, /// Stage caches cleared to stay in budget (see [`stage_trim_order`]). stages_trimmed: u64, /// A budget other than the memory budget's share (tests). @@ -267,6 +272,7 @@ impl Renderer { variant_used: HashMap::new(), variant_want: 0, frame: 0, + slides_live: false, stages_trimmed: 0, #[cfg(test)] stage_budget_override: None, @@ -310,7 +316,7 @@ impl Renderer { /// Views that render straight into the display profile's primaries (wide gamut); everything /// else is rendered for sRGB and converted ([`lightcraft_engine::display::present`]). fn renders_for_display(slot: Slot) -> bool { - matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second) + matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second | Slot::Slide(_)) } /// `job` as [`Self::request`] would run it for `slot` (its key included): views render for @@ -384,6 +390,9 @@ impl Renderer { /// Request a render for `slot` (no-op if already current or pending at the same priority). pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) { + if matches!(slot, Slot::Slide(_)) { + self.slides_live = true; + } let mut job = self.job_for(slot, job); if !self.request_needed(slot, job.key, priority) { return; @@ -442,6 +451,23 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); } + /// Drop the slideshow slides other than `keep`: their textures and their requests that have + /// not started (a slide that is no longer near is not worth rendering). + pub fn release_slides(&mut self, keep: &[PhotoId]) { + if !self.slides_live { + return; + } + let gone = |s: &Slot| matches!(s, Slot::Slide(id) if !keep.contains(id)); + self.textures.retain(|s, _| !gone(s)); + let _ = self.pool.reprioritize(|s, p| if gone(s) { None } else { Some(p) }); + self.queue.retain(|q| !gone(&q.slot)); + // (a render already running finishes unclaimed: its result is dropped) + self.pending.retain(|s, _| !gone(s)); + self.request_ids.retain(|s, _| !gone(s)); + self.failed.retain(|s, _| !gone(s)); + self.slides_live = !keep.is_empty(); + } + /// Is a request for `slot` queued or running? /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). pub fn forget_all(&mut self) { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e3af3c66a..3ebc9d2ab 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -366,13 +366,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } }; for (id, params) in aliased { - // Space pauses / resumes a slideshow - if id == "view.zoomToggle" - && let Some((interval, _, paused)) = app.ui.slideshow - { - let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, !paused)); - app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" }); + // a slideshow: Space pauses / resumes + if id == "view.zoomToggle" && app.ui.slideshow.is_some() { + if let Ok(v) = app.run("slideshow.togglePause", json!({})) { + app.toast(ctx, if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" }); + } continue; } // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, @@ -392,6 +390,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } for f in fire { + // a slideshow: ←/→ step (a cut) + if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { + let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); + continue; + } if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b178a167c..002d6386a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -476,10 +476,9 @@ pub struct UiState { /// A keyword being dragged in the Keyword List (onto another to nest it). #[serde(skip)] pub dragging_keyword: Option, - /// A running slideshow (full screen): seconds per photo, when the next one is due (egui - /// time), paused. + /// A running slideshow (full screen; see [`crate::panels::slideshow`]). #[serde(skip)] - pub slideshow: Option<(f64, f64, bool)>, + pub slideshow: Option>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, /// Navigator mini map in the loupe while zoomed in. diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index c748ecf14..d075dda3a 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -812,38 +812,6 @@ fn local_folder_tree_expands_and_browses() { h.settle(SETTLE); } -#[test] -fn slideshow_advances_pauses_and_ends() { - let mut h = detail("panel.edit"); - let first = h.app.session.active(); - exec(&mut h, "view.slideshow", json!({"interval": 0.5})); - assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); - // simulated time runs with the frames - let mut moved = false; - for _ in 0..200 { - h.step(); - if h.app.session.active() != first { - moved = true; - break; - } - } - assert!(moved, "the next photo comes up"); - // Space pauses: nothing moves - let r = h.request("ui.key", json!({"key": "space"}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.ui.slideshow.is_some_and(|s| s.2), "paused"); - let held = h.app.session.active(); - for _ in 0..120 { - h.step(); - } - assert_eq!(h.app.session.active(), held); - // Esc ends it - let r = h.request("ui.key", json!({"key": "escape"}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); - h.settle(SETTLE); -} - #[test] fn geometry_slider_drag_marks_the_grid() { let mut h = detail("panel.crop"); diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs new file mode 100644 index 000000000..06e402bef --- /dev/null +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -0,0 +1,242 @@ +//! The slideshow in the app (View ▸ Slideshow): playback with the library's settings, slides +//! prepared ahead off the UI thread, transitions only between ready slides, keys and controls. +//! Simulated time: every headless frame is 1/60 s, so nothing here sleeps. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::headless::Headless; +use crate::render::Slot; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn grid() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1000.0, 700.0], 1.0); + h.step(); + h +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn play(h: &Headless) -> &lightcraft_engine::slideshow::Playback { + &h.app.ui.slideshow.as_ref().expect("a slideshow is playing").play +} + +fn has_slide(h: &Headless, id: PhotoId) -> bool { + h.app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id) +} + +fn slides_held(h: &Headless) -> usize { + h.app.renderer.textures.keys().filter(|s| matches!(s, Slot::Slide(_))).count() +} + +#[test] +fn slideshow_advances_pauses_and_ends() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); + assert_eq!(h.app.session.active(), Some(first), "starts at the first photo in view"); + assert!(h.step_until(SETTLE, |h| h.app.session.active() != Some(first)), "the next photo comes up and is selected"); + // Space pauses: nothing moves + let r = h.request("ui.key", json!({"key": "space"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(play(&h).is_paused(), "paused"); + let held = h.app.session.active(); + for _ in 0..120 { + h.step(); + } + assert_eq!(h.app.session.active(), held); + // Esc ends it, and the slides are let go + let r = h.request("ui.key", json!({"key": "escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); + assert_eq!(slides_held(&h), 0, "slide textures freed"); + h.settle(SETTLE); +} + +#[test] +fn next_slides_are_prepared_before_they_are_due() { + let mut h = grid(); + // a long slide: anything rendered for the next ones is rendered ahead of time + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7})); + let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD); + assert_eq!(ahead.len(), 2); + let start = h.time(); + assert!(h.step_until(SETTLE, |h| ahead.iter().all(|id| has_slide(h, *id))), "the next two slides are rendered"); + let current = play(&h).current(); + assert!(has_slide(&h, current)); + assert!(h.time() - start < 60.0, "before the first is due"); + assert_eq!(play(&h).current(), current, "still on the first slide"); + h.settle(SETTLE); + + // off: only the slide up is rendered until the next one is due + let mut h = grid(); + run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false})); + let next = play(&h).upcoming(1)[0]; + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current))); + for _ in 0..30 { + h.step(); + } + assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "not prepared"); + h.settle(SETTLE); +} + +#[test] +fn a_fade_only_runs_between_rendered_slides() { + let mut h = grid(); + // without previews prepared, each slide renders when it is due: the transition must wait + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.4, "preparePreviews": false})); + let mut fades = 0; + let t0 = std::time::Instant::now(); + while fades < 20 && t0.elapsed() < SETTLE { + h.step(); + let f = play(&h).frame(h.time()); + if let Some(from) = f.from { + fades += 1; + assert!(has_slide(&h, f.current), "fading in a slide that isn't rendered"); + assert!(has_slide(&h, from), "fading out a slide that was let go"); + } + } + assert!(fades >= 20, "fades ran ({fades})"); + h.settle(SETTLE); +} + +#[test] +fn arrows_cut_and_repeat_off_ends_after_the_last_slide() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 2, "repeat": false})); + let r = h.request("ui.key", json!({"key": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(play(&h).current(), photos[1]); + assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows"); + assert!(play(&h).frame(h.time()).from.is_none(), "a cut, not a fade"); + let r = h.request("ui.key", json!({"key": "left"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(play(&h).current(), photos[0]); + for _ in 1..photos.len() { + run(&mut h, "slideshow.next", json!({})); + } + assert_eq!(play(&h).position(), (photos.len(), photos.len())); + run(&mut h, "slideshow.next", json!({})); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "no repeat: past the last one it ends"); + h.settle(SETTLE); +} + +#[test] +fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() { + let order = |seed: u64| { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "library.select", json!({"ids": [photos[3].0]})); + run(&mut h, "view.slideshow", json!({"randomOrder": true, "seed": seed, "interval": 60})); + let mut seen = vec![play(&h).current()]; + for _ in 1..photos.len() { + run(&mut h, "slideshow.next", json!({})); + seen.push(play(&h).current()); + } + h.settle(SETTLE); + (photos, seen) + }; + let (photos, a) = order(11); + assert_eq!(a[0], photos[3]); + let in_view_order: Vec = photos.iter().copied().filter(|p| *p != photos[3]).collect(); + assert_ne!(a[1..].to_vec(), in_view_order, "shuffled"); + let mut sorted = a.clone(); + sorted.sort(); + let mut all = photos.clone(); + all.sort(); + assert_eq!(sorted, all, "every photo once"); + assert_eq!(order(11).1, a, "same seed, same order"); +} + +#[test] +fn playback_controls_show_on_pointer_movement_and_hide() { + let mut h = grid(); + run(&mut h, "view.slideshow", json!({"interval": 60})); + h.step(); + let shown = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "icon:slideshowPlayPause"); + assert!(!shown(&h), "hidden until the pointer moves"); + for fx in [0.3, 0.6] { + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": fx, "fy": 0.3}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); + assert!(shown(&h), "the pointer moved: the controls show"); + assert!(h.painted_text().iter().any(|t| t.contains(&format!("1 / {}", h.app.session.visible_cloned().len()))), "slide n of N"); + let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowPlayPause"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(play(&h).is_paused(), "the play / pause button pauses"); + // the pointer rests away from the bar: gone after a few seconds + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.5, "fy": 0.2}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..(60.0 * (crate::panels::slideshow::CONTROLS_SECS + 0.5)) as usize { + h.step(); + } + assert!(!shown(&h), "hidden again"); + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.55, "fy": 0.25}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowEnd"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "the end button ends it"); + h.settle(SETTLE); +} + +#[test] +fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { + let mut h = grid(); + let r = run(&mut h, "slideshow.playback", json!({"slideDuration": 9, "fadeDuration": 0.5, "repeat": false})); + assert_eq!(r["slideDuration"], 9.0); + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"slideDuration": "slow"}}), T); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_none()); + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"seed": -1}}), T); + assert_ne!(r["ok"], true, "{r}"); + let r = run(&mut h, "view.slideshow", json!({})); + assert_eq!(r["settings"]["slideDuration"], 9.0); + assert_eq!(r["settings"]["repeat"], false); + // `interval` (the older parameter) is this run's slide duration; the saved one stays + run(&mut h, "slideshow.end", json!({})); + let r = run(&mut h, "view.slideshow", json!({"interval": 2})); + assert_eq!(r["settings"]["slideDuration"], 2.0); + assert_eq!(h.app.session.slideshow.slide_duration, 9.0); + // the options open in Settings + run(&mut h, "slideshow.end", json!({})); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + assert!(h.app.widgets.iter().any(|(w, _)| w == "check:settings.slideshowRandom")); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback"); + h.settle(SETTLE); +} + +#[test] +fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "preparePreviews": false})); + // the next slide goes away before it is due + let next = play(&h).upcoming(1)[0]; + run(&mut h, "photo.delete", json!({"ids": [next.0]})); + run(&mut h, "photo.deletePermanently", json!({"ids": [next.0]})); + assert!(h.app.session.catalog.photo(next).is_none()); + assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.position().0 >= 3)), "it plays on"); + assert!(photos.len() > 3); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..bbce64327 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | +| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 16 | 34 | 9 | — | 23/25 (92%) | +| **Total** | 397 | 50 | 72 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.5% of 163. ## Top gaps @@ -200,7 +200,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | -| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | +| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | @@ -800,7 +800,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | | LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, slides composed with layout/overlays/backdrop (LRC-SS-LAYOUT…) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..02dea32ac 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,16 @@ ## October 2026 +### Slideshow playback +- View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade + between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per + pass, a new order each time round), repeat (off: the slideshow ends after the last photo) and preparing previews in + advance (the next two slides render while the current one is up, so a fade never waits or flashes). +- Change them in Settings ▸ Slideshow (View ▸ Slideshow Options…); they are saved with the library. Agents use + `slideshow.playback`; `view.slideshow` takes any of them for one run (`interval` still sets the slide duration). +- While it plays, moving the pointer shows a small control bar: previous, play / pause, next, the slide number and + end. Space pauses, ← / → step (a cut), Esc ends. A slide's time starts once it is shown sharp. + ### 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, From ae033aaf324802a1fb34b94eefdaba016e825a98 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:47:42 +0200 Subject: [PATCH 002/315] LRC-SS-LAYOUT: slideshow layout, options and backdrop: slides composed with margins, stroke, shadow and backdrop - New crate lightcraft-slideshow (L1, no UI): SlideSettings (margins linked or independent, guides, aspect preview, zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; lengths relative to the slide's shorter side; validated and clamped) and a CPU compose() used by playback, the Settings preview and slideshow.preview. - Engine: slideshow.layout (partial merge, saved in prefs.json) and slideshow.preview {id?, width?, height?, path?} (PNG to path or base64); RenderJob::with_slide composes on the render worker; the background photo is rendered once and shared. - Playback: with a non-default layout each slide is the composed screen-sized slide, prepared ahead like before; the default layout plays as before. - Settings > Slideshow: Layout, Options and Backdrop sections and a debounced live preview of the active photo's slide (with margin guides). - Tests: composer geometry/pixels/hostile input, command get/set/persist/preview, headless playback of a composed slide and of the default layout. --- Cargo.lock | 11 + Cargo.toml | 1 + apps/lightcraft-web/src/workers.rs | 10 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/slideshow.rs | 144 +++++- crates/engine/src/import.rs | 1 + crates/engine/src/lib.rs | 10 + crates/engine/src/library.rs | 4 + crates/engine/src/media.rs | 31 +- crates/engine/src/slide.rs | 140 ++++++ crates/engine/src/tests_slide.rs | 139 ++++++ crates/slideshow/Cargo.toml | 14 + crates/slideshow/src/compose.rs | 513 ++++++++++++++++++++++ crates/slideshow/src/lib.rs | 23 + crates/slideshow/src/settings.rs | 401 +++++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/locales/de.json | 28 +- crates/ui-egui/locales/es.json | 28 +- crates/ui-egui/locales/fr.json | 28 +- crates/ui-egui/locales/ja.json | 28 +- crates/ui-egui/locales/pt-br.json | 28 +- crates/ui-egui/locales/ru.json | 28 +- crates/ui-egui/locales/uk.json | 28 +- crates/ui-egui/locales/zh-hans.json | 28 +- crates/ui-egui/locales/zh-hant.json | 28 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 27 +- crates/ui-egui/src/panels/slide_layout.rs | 228 ++++++++++ crates/ui-egui/src/panels/slideshow.rs | 38 +- crates/ui-egui/src/render.rs | 3 + crates/ui-egui/src/tests_slideshow.rs | 104 +++++ docs/control-protocol.md | 2 + docs/mcp.md | 6 + docs/parity-checklist.md | 14 +- docs/whats-new.md | 12 + xtask/src/layers.rs | 1 + 36 files changed, 2095 insertions(+), 37 deletions(-) create mode 100644 crates/engine/src/slide.rs create mode 100644 crates/engine/src/tests_slide.rs create mode 100644 crates/slideshow/Cargo.toml create mode 100644 crates/slideshow/src/compose.rs create mode 100644 crates/slideshow/src/lib.rs create mode 100644 crates/slideshow/src/settings.rs create mode 100644 crates/ui-egui/src/panels/slide_layout.rs diff --git a/Cargo.lock b/Cargo.lock index 0ad4ab843..8f2dbb46b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3130,6 +3130,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-slideshow", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3326,6 +3327,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "lightcraft-slideshow" +version = "0.5.0" +dependencies = [ + "lightcraft-raster", + "serde", + "serde_json", +] + [[package]] name = "lightcraft-sysmem" version = "0.5.0" @@ -3357,6 +3367,7 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-slideshow", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 58e5383b7..4a796e757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ lightcraft-mcp = { path = "crates/mcp" } lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } +lightcraft-slideshow = { path = "crates/slideshow" } # foundation thiserror = "2" diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index f321bdb9b..86d54fc66 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -165,6 +165,8 @@ struct Busy { level: SourceLevel, key: u64, cache: Option<(Arc, Hash128)>, + /// A slideshow slide: the worker renders the photo, it is composed here when it comes back. + slide: Option>, } struct W { @@ -369,6 +371,13 @@ impl Workers { }); } g.remote_done += 1; + let rendered = match (rendered, &b.slide) { + (Ok(mut r), Some(slide)) => { + r.image = slide.compose(&r.image); + Ok(r) + } + (r, _) => r, + }; g.done.push(( b.slot, RenderResult { @@ -490,6 +499,7 @@ impl RenderOffload for Workers { level: job.level, key: job.key, cache: job.cache, + slide: job.slide, }); None } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4036a5c94..a17e0885a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -27,6 +27,7 @@ lightcraft-meta = { workspace = true } lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } +lightcraft-slideshow = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index e6cd2d008..17bd861f1 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -1,12 +1,19 @@ //! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour, -//! random order, repeat, preparing previews in advance). A library preference, saved in the -//! library's `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. +//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks +//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop +//! colour, colour wash, background image). Both are library preferences, saved in the library's +//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.preview` +//! composes one slide as a PNG. -use serde_json::Value; +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, cmd}; +use super::{CommandSpec, always, bad, cmd, str_param}; use crate::{Result, Session}; +/// Largest side `slideshow.preview` renders. +pub const PREVIEW_MAX: usize = 4096; + fn playback(s: &mut Session, p: &Value) -> Result { let changes = p.as_object().is_some_and(|o| !o.is_empty()); if changes { @@ -19,14 +26,123 @@ fn playback(s: &mut Session, p: &Value) -> Result { Ok(s.slideshow.to_json()) } -pub fn specs() -> Vec { - vec![cmd!( - "slideshow.playback", - "Slideshow Playback Settings", - [], - None, - "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", - always, - playback - )] +fn layout(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?; + if next != s.slide_layout { + s.slide_layout = next; + s.save_prefs()?; + } + } + Ok(s.slide_layout.to_json()) +} + +/// A slide size from the request: both sides given, one (the other from the aspect preview), or +/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. +fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { + const C: &str = "slideshow.preview"; + let side = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)), + _ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))), + }, + } + }; + let ratio = s.slide_layout.aspect.ratio(f64::NAN); + let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX); + Ok(match (side("width")?, side("height")?) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, clamp(w as f64 / ratio)), + (None, Some(h)) => (clamp(h as f64 * ratio), h), + (None, None) => (1280, clamp(1280.0 / ratio)), + }) +} + +fn preview(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.preview"; + let id = match p.get("id") { + None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?, + Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?), + }; + if s.catalog.photo(id).is_none() { + return Err(bad(C, format!("no photo {}", id.0))); + } + let (w, h) = preview_size(s, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let settings = s.slide_layout.clone(); + let img = s.slide_now(&settings, id, w, h).map_err(crate::EngineError::Other)?; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))?; + let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height); + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "id": id.0, + "width": img.width, + "height": img.height, + "cell": rect(l.cell), + "photo": rect(l.visible), + }); + match path { + Some(path) => { + crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?; + out["path"] = json!(path); + } + None => out["png"] = json!(base64(&png)), + } + Ok(out) +} + +/// Standard padded base64 (for an image returned in the reply). +fn base64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char; + out.push(at(18)); + out.push(at(12)); + out.push(if chunk.len() > 1 { at(6) } else { '=' }); + out.push(if chunk.len() > 2 { at(0) } else { '=' }); + } + out +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "slideshow.playback", + "Slideshow Playback Settings", + [], + None, + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + always, + playback + ), + cmd!( + "slideshow.layout", + "Slideshow Layout Settings", + [], + None, + "{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings", + always, + layout + ), + cmd!( + query "slideshow.preview", + "Slideshow Slide Preview", + [], + None, + "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}", + always, + preview + ), + ] } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 29ea013e3..bf685c4d0 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1386,6 +1386,7 @@ impl Session { stages: None, view_cache: None, display: None, + slide: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d10abd193..9d1bd5db8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -55,6 +55,7 @@ pub mod presets; pub mod rename; pub mod segment; pub mod sidecar; +pub mod slide; pub mod slideshow; pub mod smart; pub mod sync; @@ -274,6 +275,11 @@ pub struct Session { pub cache_mb: u32, /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). pub slideshow: slideshow::PlaybackSettings, + /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in + /// prefs.json). + pub slide_layout: slide::SlideSettings, + /// The background image the slides last rendered (shared by slide jobs: rendered once). + pub slide_backdrop: std::sync::Arc, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the @@ -357,6 +363,8 @@ impl Session { import_defaults: import::ImportDefaults::default(), cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), + slide_layout: slide::SlideSettings::default(), + slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, } @@ -910,6 +918,8 @@ mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] +mod tests_slide; +#[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4e843054e..4f5695a0f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -160,6 +160,8 @@ struct PrefsFile { forget_local_days: Option, /// Slideshow playback settings. slideshow: crate::slideshow::PlaybackSettings, + /// Slideshow slide layout (margins, options, backdrop). + slide_layout: crate::slide::SlideSettings, } fn presets_json(s: &Session) -> String { @@ -343,6 +345,7 @@ impl Session { self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; self.slideshow = prefs.slideshow.sanitized(); + self.slide_layout = prefs.slide_layout.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -580,6 +583,7 @@ impl Session { smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), slideshow: self.slideshow.clone(), + slide_layout: self.slide_layout.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..8cafcf082 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -676,6 +676,8 @@ pub struct RenderJob { pub view_cache: Option<(Arc, Hash128)>, /// Render for this monitor profile ([`Self::with_display`]). pub display: Option>, + /// Compose the render into a slideshow slide ([`Self::with_slide`]). + pub slide: Option>, } pub struct RenderResult { @@ -744,7 +746,8 @@ impl RenderJob { /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: /// [`crate::display::present`] converts their sRGB results. pub fn with_display(mut self, display: Option>) -> Self { - if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) + if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { return self; } let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); @@ -754,7 +757,30 @@ impl RenderJob { self } + /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the + /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the + /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). + pub fn with_slide(mut self, slide: Arc) -> Self { + let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); + self.key ^= k(&self.slide); + self.key ^= k(&Some(slide.clone())); + self.slide = Some(slide); + self + } + + /// [`Self::run_photo`], composed into its slide when this is a slide job. pub fn run(self) -> RenderResult { + let slide = self.slide.clone(); + let mut r = self.run_photo(); + if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { + rendered.image = slide.compose(&rendered.image); + rendered.deep = None; + } + r + } + + /// The photo's render (without slide composition). + fn run_photo(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { @@ -1020,6 +1046,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, }) } @@ -1071,6 +1098,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, }) } @@ -1117,6 +1145,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, }) } diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs new file mode 100644 index 000000000..cd487d7d8 --- /dev/null +++ b/crates/engine/src/slide.rs @@ -0,0 +1,140 @@ +//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom +//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs +//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). +//! +//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings +//! preview and `slideshow.preview` all compose through it, so they draw the same pixels. + +use std::sync::{Arc, Mutex, PoisonError}; + +use lightcraft_catalog::PhotoId; +use lightcraft_raster::Rgba8; +pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings}; + +use crate::media::RenderJob; + +/// The last rendered background image: (its render key, the image). Shared by the slide jobs of +/// a session, so the backdrop photo is rendered once, not once per slide. +pub type BackdropCache = Mutex)>>; + +/// The background image of a slide: a library photo's render job and the shared cache it goes to. +#[derive(Clone)] +pub struct Backdrop { + pub photo: PhotoId, + job: Box, + cache: Arc, +} + +impl Backdrop { + /// The rendered background image (from the cache when it holds this render; otherwise + /// rendered now, on the calling thread). `None` when it can't be rendered (the slide then + /// shows the backdrop colour alone). + pub fn image(&self) -> Option> { + let key = self.job.key; + if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref() + && *k == key + { + return Some(img.clone()); + } + let img = Arc::new((*self.job).clone().run().rendered.ok()?.image); + *self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone())); + Some(img) + } +} + +/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the +/// background image, if any. +#[derive(Clone)] +pub struct SlideCompose { + pub settings: SlideSettings, + pub width: usize, + pub height: usize, + pub backdrop: Option, +} + +impl SlideCompose { + /// What makes the slide's pixels differ (for render keys). + pub fn key(&self) -> u64 { + self.settings.pixel_key() + ^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f) + ^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29)) + } + + /// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size). + pub fn compose(&self, photo: &Rgba8) -> Rgba8 { + let bg = self.backdrop.as_ref().and_then(Backdrop::image); + lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height) + } +} + +impl crate::Session { + /// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected. + pub fn slide_photo_aspect(&self, id: PhotoId) -> Option { + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &p.develop, + true, + p.embedded_lens.as_ref(), + ); + let a = frame.aspect(); + Some(if a.is_finite() && a > 0.0 { a } else { 1.5 }) + } + + /// The composition of `settings` at `width × height`, with its background image's render job + /// (the photo `settings.background_image` names, when it is in the catalog). + pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc { + let settings = settings.clone().sanitized(); + let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { + let id = PhotoId(bid); + let aspect = self.slide_photo_aspect(id)?; + let fill = + SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() }; + let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height); + let job = self.render_job(id, w, h, false, true)?; + Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) + }); + Arc::new(SlideCompose { settings, width, height, backdrop }) + } + + /// A render job whose result is photo `id`'s slide at `width × height` with the library's + /// slide layout: the photo is rendered at the size its cell needs (its long edge at most + /// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its + /// view preview, like the loupe's. + pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option) -> Option { + let settings = self.slide_layout.clone(); + self.slide_job_with(&settings, id, width, height, photo_edge, true) + } + + /// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the + /// photo's render as its view preview. + pub fn slide_job_with( + &mut self, + settings: &SlideSettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option, + view: bool, + ) -> Option { + let aspect = self.slide_photo_aspect(id)?; + let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height); + if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) { + let k = edge as f64 / w.max(h) as f64; + (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); + } + let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; + let compose = self.slide_compose(settings, width, height); + Some(job.with_slide(compose)) + } + + /// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread. + pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result { + let job = self.slide_job_with(settings, id, width, height, None, false).ok_or("no such photo")?; + let r = job.run(); + self.accept(&r); + r.rendered.map(|r| r.image) + } +} diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests_slide.rs new file mode 100644 index 000000000..84a8b5274 --- /dev/null +++ b/crates/engine/src/tests_slide.rs @@ -0,0 +1,139 @@ +//! Slideshow slides: `slideshow.layout` (validated, saved with the library) and +//! `slideshow.preview` (a slide composed with the layout, as a PNG). + +use std::path::PathBuf; + +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-slide-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png_size(bytes: &[u8]) -> (u32, u32) { + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "a PNG"); + let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]); + (be(16), be(20)) +} + +fn base64_decode(text: &str) -> Vec { + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32); + for c in text.bytes().take_while(|c| *c != b'=') { + acc = (acc << 6) | A.iter().position(|a| *a == c).unwrap() as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + out +} + +#[test] +fn slide_layout_is_validated_and_persists_with_the_library() { + let lib = temp_dir("layout"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.layout", &json!({})).unwrap(); + assert_eq!(r["marginLeft"], 0.0, "defaults: {r}"); + assert_eq!(r["backdropColor"], "#000000"); + let r = s + .execute( + "slideshow.layout", + &json!({"marginTop": 0.1, "strokeBorder": true, "strokeColor": [255, 255, 255], "castShadow": true, "backdropColor": "#203040", "aspect": "4:3"}), + ) + .unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["marginBottom"].as_f64()), (Some(0.1), Some(0.1)), "linked margins: {r}"); + assert_eq!(r["strokeColor"], "#ffffff"); + // a bad value changes nothing (the good keys beside it included) + for bad in [json!({"marginLeft": "wide", "zoomToFill": true}), json!({"backdropColor": "red"}), json!({"glitter": 1})] { + assert!(s.execute("slideshow.layout", &bad).is_err(), "{bad}"); + } + assert!(!s.slide_layout.zoom_to_fill); + // out of range: clamped + let r = s.execute("slideshow.layout", &json!({"linkMargins": false, "marginRight": 9, "shadowOpacity": -1})).unwrap(); + assert_eq!((r["marginRight"].as_f64(), r["marginLeft"].as_f64(), r["shadowOpacity"].as_f64()), (Some(0.45), Some(0.1), Some(0.0))); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.layout", &json!({})).unwrap(); + assert_eq!((r["marginRight"].as_f64(), r["aspect"].as_str(), r["backdropColor"].as_str()), (Some(0.45), Some("4:3"), Some("#203040"))); + assert_eq!(r["linkMargins"], false); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideLayout": {"marginTop": 1e9, "shadowAngle": 720, "strokeWidth": -3}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!((s.slide_layout.margin_top, s.slide_layout.shadow_angle, s.slide_layout.stroke_width), (0.45, 0.0, 0.0)); + // and the playback settings beside them are untouched by a layout change + let before = s.slideshow.clone(); + s.execute("slideshow.layout", &json!({"zoomToFill": true})).unwrap(); + assert_eq!(s.slideshow, before); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_returns_the_composed_slide_at_the_asked_size() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap(); + s.execute("slideshow.layout", &json!({"margins": 0.1, "backdropColor": "#ff0000"})).unwrap(); + let r = s.execute("slideshow.preview", &json!({"width": 320, "height": 200})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["id"].as_u64()), (Some(320), Some(200), Some(photos[0].0))); + assert_eq!(r["cell"], json!([20.0, 20.0, 300.0, 180.0]), "10 % of the 200 px short side"); + let png = base64_decode(r["png"].as_str().unwrap()); + assert_eq!(png_size(&png), (320, 200)); + // written to a file instead + let dir = temp_dir("preview"); + let path = dir.join("slide.png"); + let r = s.execute("slideshow.preview", &json!({"id": photos[1].0, "width": 240, "path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], path.to_string_lossy().as_ref()); + assert!(r.get("png").is_none()); + assert_eq!(png_size(&std::fs::read(&path).unwrap()), (240, 150), "the height from the aspect preview (screen: 16:10)"); + s.execute("slideshow.layout", &json!({"aspect": "16:9"})).unwrap(); + let r = s.execute("slideshow.preview", &json!({"height": 90})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90))); + // the pixels: red margins, the photo inside + let img = s.slide_now(&s.slide_layout.clone(), photos[0], 320, 200).unwrap(); + assert_eq!((img.width, img.height), (320, 200)); + assert_eq!(img.get(3, 3), [255, 0, 0, 255]); + assert_eq!(img.get(160, 196), [255, 0, 0, 255]); + assert_ne!(img.get(160, 100), [255, 0, 0, 255], "the photo in the cell"); + // refused: bad sizes, a photo that isn't there + for bad in [json!({"width": 4}), json!({"height": 100000}), json!({"width": "big"}), json!({"id": 999_999}), json!({"id": "one"})] { + assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_background_image_and_the_slide_key() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + let plain = s.slide_layout.clone(); + let job = s.slide_job(photos[0], 320, 200, None).unwrap(); + let loupe = s.loupe_job(photos[0], 320, 200, true).unwrap(); + assert_ne!(job.key, loupe.key, "a slide is not the photo's render"); + // a background image fills the slide around the photo + let bg = + s.slide_layout.merged(&json!({"margins": 0.2, "backdropColor": "#ff0000", "backgroundImage": photos[2].0, "backgroundOpacity": 1})).unwrap(); + let img = s.slide_now(&bg, photos[0], 320, 200).unwrap(); + assert_ne!(img.get(3, 3), [255, 0, 0, 255], "the background photo, not the colour"); + // it is rendered once and reused + assert!(s.slide_backdrop.lock().unwrap().is_some()); + let other = s.slide_job_with(&bg, photos[1], 320, 200, None, false).unwrap(); + assert_ne!(other.key, s.slide_job_with(&plain, photos[1], 320, 200, None, false).unwrap().key); + // a background photo that isn't in the catalog: the colour alone + let gone = bg.merged(&json!({"backgroundImage": 987_654})).unwrap(); + let img = s.slide_now(&gone, photos[0], 320, 200).unwrap(); + assert_eq!(img.get(3, 3), [255, 0, 0, 255]); +} diff --git a/crates/slideshow/Cargo.toml b/crates/slideshow/Cargo.toml new file mode 100644 index 000000000..15cda43c1 --- /dev/null +++ b/crates/slideshow/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lightcraft-slideshow" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft slideshow slides: layout, options and backdrop settings and the CPU slide composer (L1)" + +[lints] +workspace = true + +[dependencies] +lightcraft-raster = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/slideshow/src/compose.rs b/crates/slideshow/src/compose.rs new file mode 100644 index 000000000..998e591c9 --- /dev/null +++ b/crates/slideshow/src/compose.rs @@ -0,0 +1,513 @@ +//! The slide composer: [`layout`] (where the image cell, the photo, its frame and shadow go) and +//! [`compose`] (the slide's pixels, on the CPU). +//! +//! Drawing order: backdrop colour → background image (at its opacity, filling the slide) → colour +//! wash → cast shadow → stroke border → photo. The stroke is drawn around the outside of the +//! photo (the photo keeps its size); the shadow falls from the photo with its stroke. + +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Image, Plane, Rgba8, par_rows}; + +use crate::settings::{MAX_SIDE, SlideSettings}; + +/// A rectangle in slide pixels (`x0 ≤ x1`, `y0 ≤ y1`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +impl Rect { + pub fn width(&self) -> f64 { + self.x1 - self.x0 + } + pub fn height(&self) -> f64 { + self.y1 - self.y0 + } + fn intersect(&self, o: &Rect) -> Rect { + let (x0, y0) = (self.x0.max(o.x0), self.y0.max(o.y0)); + Rect { x0, y0, x1: self.x1.min(o.x1).max(x0), y1: self.y1.min(o.y1).max(y0) } + } + fn expand(&self, d: f64) -> Rect { + Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d } + } + fn shift(&self, dx: f64, dy: f64) -> Rect { + Rect { x0: self.x0 + dx, y0: self.y0 + dy, x1: self.x1 + dx, y1: self.y1 + dy } + } + /// Whole pixel bounds clipped to `w × h`: (x0, y0, x1, y1). + fn pixels(&self, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(self.x0, w), c(self.y0, h)); + (x0, y0, c(self.x1, w).max(x0), c(self.y1, h).max(y0)) + } +} + +/// Where a slide's parts go, in slide pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Layout { + /// The slide. + pub slide: Rect, + /// The image cell: the slide inside its margins. + pub cell: Rect, + /// The whole photo, scaled (with zoom to fill it extends past the cell). + pub photo: Rect, + /// The part of the photo that shows (the photo inside the cell). + pub visible: Rect, + /// The visible photo with its stroke border (the visible photo when there is none). + pub frame: Rect, + /// The rectangle that casts the shadow (the frame, offset), and the blur's sigma in pixels. + pub shadow: Option<(Rect, f64)>, +} + +/// A usable aspect: a non-finite, zero or absurd one takes `fallback`. +fn sane_aspect(a: f64, fallback: f64) -> f64 { + if a.is_finite() && a > 1e-3 && a < 1e3 { a } else { fallback } +} + +/// The slide size `out_w × out_h` limited to [`MAX_SIDE`] (proportionally) and at least 1 × 1. +fn slide_size(out_w: usize, out_h: usize) -> (usize, usize) { + let (w, h) = (out_w.max(1), out_h.max(1)); + let long = w.max(h); + if long <= MAX_SIDE { + return (w, h); + } + let k = MAX_SIDE as f64 / long as f64; + (((w as f64 * k).round() as usize).clamp(1, MAX_SIDE), ((h as f64 * k).round() as usize).clamp(1, MAX_SIDE)) +} + +/// The layout of a `out_w × out_h` slide showing a photo of aspect `photo_aspect` (width / +/// height; a bad one fills the cell). +pub fn layout(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> Layout { + let s = s.clone().sanitized(); + let (w, h) = slide_size(out_w, out_h); + let (wf, hf) = (w as f64, h as f64); + let short = wf.min(hf); + // margins in pixels; when they leave no room (a tiny slide), they shrink to leave one pixel + let fit = |a: f64, b: f64, room: f64| { + let (a, b) = ((a * short).round(), (b * short).round()); + if a + b <= room - 1.0 { + (a, b) + } else { + let k = ((room - 1.0).max(0.0)) / (a + b).max(1e-9); + ((a * k).floor(), (b * k).floor()) + } + }; + let (ml, mr) = fit(s.margin_left, s.margin_right, wf); + let (mt, mb) = fit(s.margin_top, s.margin_bottom, hf); + let slide = Rect { x0: 0.0, y0: 0.0, x1: wf, y1: hf }; + let cell = Rect { x0: ml, y0: mt, x1: wf - mr, y1: hf - mb }; + let (cw, ch) = (cell.width().max(1.0), cell.height().max(1.0)); + let a = sane_aspect(photo_aspect, cw / ch); + let wider = a > cw / ch; + // fit: the photo's longer side (relative to the cell) meets the cell; fill: the shorter + let (dw, dh) = if wider != s.zoom_to_fill { (cw, cw / a) } else { (ch * a, ch) }; + let (dw, dh) = (dw.round().max(1.0), dh.round().max(1.0)); + let x0 = (cell.x0 + (cw - dw) / 2.0).round(); + let y0 = (cell.y0 + (ch - dh) / 2.0).round(); + let photo = Rect { x0, y0, x1: x0 + dw, y1: y0 + dh }; + let visible = photo.intersect(&cell); + let stroke = if s.stroke_border && s.stroke_width > 0.0 { (s.stroke_width * short).round().max(1.0) } else { 0.0 }; + let frame = visible.expand(stroke).intersect(&slide); + let shadow = (s.cast_shadow && s.shadow_opacity > 0.0).then(|| { + let (d, t) = (s.shadow_offset * short, s.shadow_angle.to_radians()); + // counter-clockwise from the right, y down on screen + (frame.shift(d * t.cos(), -d * t.sin()), s.shadow_radius * short / 2.0) + }); + Layout { slide, cell, photo, visible, frame, shadow } +} + +/// The size to render a photo of aspect `photo_aspect` at for a `out_w × out_h` slide: the +/// photo's whole size in the slide (with zoom to fill, larger than its cell), at most 4× the +/// slide's long side. +pub fn photo_size(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> (usize, usize) { + let l = layout(s, photo_aspect, out_w, out_h); + let (w, h) = (l.photo.width().max(1.0), l.photo.height().max(1.0)); + let cap = 4.0 * l.slide.width().max(l.slide.height()); + let k = (cap / w.max(h)).min(1.0); + (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) +} + +fn mix(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8 +} + +fn mix_px(dst: &mut [u8; 4], src: [u8; 3], t: f32) { + for (d, s) in dst.iter_mut().zip(src) { + *d = mix(*d, s, t); + } + dst[3] = 255; +} + +/// Draw `src` scaled to `dest` (slide pixels), only where it meets `clip`, at `opacity`. +/// Only the part of `src` that shows is resampled (a zoomed-to-fill panorama stays cheap). +fn draw_image(out: &mut Rgba8, src: &Rgba8, dest: &Rect, clip: &Rect, opacity: f32) { + if src.width == 0 || src.height == 0 || src.data.len() != src.width * src.height || opacity <= 0.0 { + return; + } + let (w, h) = (out.width, out.height); + let (vx0, vy0, vx1, vy1) = dest.intersect(clip).pixels(w, h); + let (vw, vh) = (vx1 - vx0, vy1 - vy0); + if vw == 0 || vh == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 { + return; + } + // the source pixels behind the visible part + let (sx, sy) = (src.width as f64 / dest.width(), src.height as f64 / dest.height()); + let to_src = |v: f64, origin: f64, k: f64, max: usize| ((v - origin) * k).clamp(0.0, max as f64); + let (s0x, s1x) = (to_src(vx0 as f64, dest.x0, sx, src.width).floor(), to_src(vx1 as f64, dest.x0, sx, src.width).ceil()); + let (s0y, s1y) = (to_src(vy0 as f64, dest.y0, sy, src.height).floor(), to_src(vy1 as f64, dest.y0, sy, src.height).ceil()); + let (cx, cy) = (s0x as usize, s0y as usize); + let (cw, ch) = (((s1x - s0x) as usize).max(1), ((s1y - s0y) as usize).max(1)); + let part = if (cx, cy, cw, ch) == (0, 0, src.width, src.height) { None } else { Some(src.crop(cx, cy, cw, ch)) }; + let part = part.as_ref().unwrap_or(src); + let scaled: Rgba8 = if (part.width, part.height) == (vw, vh) { + part.clone() + } else { + let f = part.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]); + let filter = if vw < part.width { Filter::Lanczos3 } else { Filter::Mitchell }; + resize(&f, vw, vh, filter).map(|p| [0, 1, 2, 3].map(|i| p[i].round().clamp(0.0, 255.0) as u8)) + }; + for (row, y) in (vy0..vy1).enumerate() { + let (Some(dst), Some(s)) = (out.data.get_mut(y * w + vx0..y * w + vx1), scaled.data.get(row * vw..(row + 1) * vw)) else { + continue; + }; + for (d, p) in dst.iter_mut().zip(s) { + let a = opacity * p[3] as f32 / 255.0; + mix_px(d, [p[0], p[1], p[2]], a); + } + } +} + +/// Fill `r` with `c`. +fn fill(out: &mut Rgba8, r: &Rect, c: [u8; 3]) { + let w = out.width; + let (x0, y0, x1, y1) = r.pixels(out.width, out.height); + for y in y0..y1 { + if let Some(row) = out.data.get_mut(y * w + x0..y * w + x1) { + row.fill([c[0], c[1], c[2], 255]); + } + } +} + +/// A 1-D blurred step: 1 inside `[a, b)`, blurred with `sigma` (pixels), over `0..n`. +fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec { + let inside = Plane::from_fn(n, 1, |x, _| { + let c = x as f64 + 0.5; + if c >= a && c < b { 1.0 } else { 0.0 } + }); + gaussian(&inside, sigma as f32).data +} + +/// The slide: `photo` (the developed photo, any size: it is scaled into its cell) and the +/// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels +/// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone. +pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 { + let s = s.clone().sanitized(); + let (w, h) = slide_size(out_w, out_h); + let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height; + let aspect = if has_photo { photo.width as f64 / photo.height as f64 } else { f64::NAN }; + let l = layout(&s, aspect, w, h); + let c = s.backdrop_color; + let mut out = Image::filled(w, h, [c[0], c[1], c[2], 255]); + // background image: the photo filling the slide, at its opacity + if let Some(bg) = backdrop_image.filter(|_| s.background_image.is_some() && s.background_opacity > 0.0) + && bg.width > 0 + && bg.height > 0 + { + let a = bg.width as f64 / bg.height as f64; + let fill_all = SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..s.clone() }; + let bl = layout(&fill_all, a, w, h); + draw_image(&mut out, bg, &bl.photo, &bl.slide, s.background_opacity as f32); + } + // colour wash: full wash colour at the side the angle points to, none at the opposite side + if s.color_wash && s.wash_opacity > 0.0 { + let t = s.wash_angle.to_radians(); + let (dx, dy) = (t.cos(), -t.sin()); + let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); + let extent = (cx * dx.abs() + cy * dy.abs()).max(1e-9); + let (wc, op) = (s.wash_color, s.wash_opacity); + par_rows(&mut out.data, w, |y, row| { + let py = y as f64 + 0.5 - cy; + for (x, p) in row.iter_mut().enumerate() { + let proj = (x as f64 + 0.5 - cx) * dx + py * dy; + let k = ((proj / extent + 1.0) / 2.0).clamp(0.0, 1.0); + mix_px(p, wc, (k * op) as f32); + } + }); + } + // cast shadow: a blurred rectangle (separable: the product of two blurred steps) + if let Some((r, sigma)) = l.shadow { + let op = s.shadow_opacity as f32; + let fx = blurred_step(w, r.x0, r.x1, sigma); + let fy = blurred_step(h, r.y0, r.y1, sigma); + par_rows(&mut out.data, w, |y, row| { + let ky = fy.get(y).copied().unwrap_or(0.0); + if ky <= 1e-4 { + return; + } + for (p, kx) in row.iter_mut().zip(&fx) { + let a = op * ky * kx; + if a > 1e-4 { + mix_px(p, [0, 0, 0], a); + } + } + }); + } + if l.frame != l.visible { + fill(&mut out, &l.frame, s.stroke_color); + } + if has_photo { + draw_image(&mut out, photo, &l.photo, &l.cell, 1.0); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; + use serde_json::json; + + fn solid(w: usize, h: usize, c: [u8; 3]) -> Rgba8 { + Image::filled(w, h, [c[0], c[1], c[2], 255]) + } + + fn with(p: serde_json::Value) -> SlideSettings { + SlideSettings::default().merged(&p).unwrap() + } + + fn px(img: &Rgba8, x: usize, y: usize) -> [u8; 3] { + let p = img.get(x, y); + [p[0], p[1], p[2]] + } + + const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 200, 0]; + + #[test] + fn margins_make_the_cell_and_the_photo_fits_inside_it() { + // 1000 × 500 slide, 10 % margins (of the 500 px short side = 50 px) + let s = with(json!({"margins": 0.1})); + let l = layout(&s, 1.0, 1000, 500); + assert_eq!(l.cell, Rect { x0: 50.0, y0: 50.0, x1: 950.0, y1: 450.0 }); + // a square photo fits the 900 × 400 cell's height, centred + assert_eq!(l.photo, Rect { x0: 300.0, y0: 50.0, x1: 700.0, y1: 450.0 }); + assert_eq!(l.visible, l.photo); + // independent margins + let s = with(json!({"linkMargins": false, "marginLeft": 0.2, "marginTop": 0.0, "marginRight": 0.0, "marginBottom": 0.4})); + let l = layout(&s, 3.0, 1000, 500); + assert_eq!(l.cell, Rect { x0: 100.0, y0: 0.0, x1: 1000.0, y1: 300.0 }); + assert_eq!((l.photo.width(), l.photo.height()), (900.0, 300.0), "3:1 fits 900 × 300 exactly"); + + let img = compose(&solid(64, 64, GREEN), None, &with(json!({"margins": 0.1, "backdropColor": "#ff0000"})), 1000, 500); + assert_eq!((img.width, img.height), (1000, 500)); + assert_eq!(px(&img, 10, 10), RED, "margin: backdrop"); + assert_eq!(px(&img, 500, 250), GREEN, "cell centre: photo"); + assert_eq!(px(&img, 290, 250), RED, "beside a fitted square photo: backdrop"); + assert_eq!(px(&img, 310, 60), GREEN); + } + + #[test] + fn zoom_to_fill_covers_the_cell_and_crops() { + let s = with(json!({"margins": 0.1, "zoomToFill": true})); + let l = layout(&s, 1.0, 1000, 500); + assert_eq!((l.photo.width(), l.photo.height()), (900.0, 900.0), "the square photo covers the 900 × 400 cell"); + assert_eq!(l.visible, l.cell, "and shows only inside it"); + assert_eq!(photo_size(&s, 1.0, 1000, 500), (900, 900), "rendered whole at the covering size"); + // a gradient photo: zoomed to fill, its middle rows show, its top and bottom are cut + let photo = Rgba8::from_fn(100, 100, |_, y| [(y * 2) as u8, 0, 0, 255]); + let img = compose(&photo, None, &s, 1000, 500); + assert_eq!(px(&img, 500, 10), [0, 0, 0], "margin stays the backdrop"); + assert_eq!(px(&img, 60, 250), px(&img, 940, 250), "covers the cell edge to edge"); + let top = img.get(500, 51)[0]; + assert!(top > 40, "the photo's top rows are cropped away ({top})"); + // fit draws the whole photo (its first rows near black) + let img = compose(&photo, None, &with(json!({"margins": 0.1})), 1000, 500); + assert!(img.get(500, 51)[0] < 6); + } + + #[test] + fn aspect_preview_ratios() { + use crate::settings::Aspect; + let s = with(json!({"aspect": "4:3"})); + let r = s.aspect.ratio(2.0); + assert!((r - 4.0 / 3.0).abs() < 1e-12); + assert_eq!(with(json!({"aspect": "screen"})).aspect.ratio(2.0), 2.0); + assert_eq!(Aspect::parse("16:9"), Some(Aspect::Wide)); + // a 4:3 slide of a 3:2 photo letterboxes top and bottom + let l = layout(&s, 1.5, 800, 600); + assert_eq!((l.photo.width(), l.photo.height(), l.photo.y0), (800.0, 533.0, 34.0)); + } + + #[test] + fn stroke_surrounds_the_photo_and_shadow_falls_at_its_angle() { + let s = with(json!({"margins": 0.2, "strokeBorder": true, "strokeWidth": 0.02, "strokeColor": "#ffffff"})); + let l = layout(&s, 1.0, 1000, 1000); + assert_eq!(l.visible, Rect { x0: 200.0, y0: 200.0, x1: 800.0, y1: 800.0 }); + assert_eq!(l.frame, Rect { x0: 180.0, y0: 180.0, x1: 820.0, y1: 820.0 }, "20 px outside the photo"); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert_eq!(px(&img, 190, 500), [255, 255, 255], "stroke left"); + assert_eq!(px(&img, 810, 810), [255, 255, 255], "stroke corner"); + assert_eq!(px(&img, 175, 500), [0, 0, 0], "outside the stroke"); + assert_eq!(px(&img, 205, 500), GREEN, "the photo keeps its size"); + + // shadow at −45° (down-right) by 5 %, hard edged (radius 0), opaque + let s = with( + json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": -45, "shadowRadius": 0, "shadowOpacity": 1, "backdropColor": "#ffffff"}), + ); + let l = layout(&s, 1.0, 1000, 1000); + let (r, _) = l.shadow.unwrap(); + let d = 50.0 / 2f64.sqrt(); + assert!((r.x0 - (200.0 + d)).abs() < 1e-9 && (r.y0 - (200.0 + d)).abs() < 1e-9, "{r:?}"); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert_eq!(px(&img, 820, 820), [0, 0, 0], "shadow below right"); + assert_eq!(px(&img, 180, 180), [255, 255, 255], "no shadow above left"); + assert_eq!(px(&img, 850, 500), [255, 255, 255], "shadow ends at its offset"); + // at 90° the shadow goes straight up; a soft radius spreads it but fades out + let s = with( + json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": 90, "shadowRadius": 0.04, "shadowOpacity": 0.8, "backdropColor": "#ffffff"}), + ); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert!(img.get(500, 170)[0] < 200, "above: in shadow"); + assert_eq!(px(&img, 500, 950), [255, 255, 255], "below: none"); + assert_eq!(px(&img, 500, 50), [255, 255, 255], "beyond the blur: none"); + let mid = img.get(500, 151)[0]; + assert!(mid > 60 && mid < 250, "a soft edge ({mid})"); + } + + #[test] + fn colour_wash_follows_its_angle() { + let wash = |angle: f64| { + compose( + &Rgba8::new(0, 0), + None, + &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 1, "washAngle": angle})), + 200, + 100, + ) + }; + // 0°: white at the right, black at the left + let img = wash(0.0); + assert!(img.get(199, 50)[0] > 250 && img.get(0, 50)[0] < 5); + assert!((img.get(100, 50)[0] as i32 - 128).abs() < 3, "half way in the middle"); + assert_eq!(img.get(150, 0), img.get(150, 99), "constant along the other axis"); + // 90°: white at the top + let img = wash(90.0); + assert!(img.get(100, 0)[0] > 245 && img.get(100, 99)[0] < 10); + // 180°: white at the left + assert!(wash(180.0).get(0, 50)[0] > 250); + // −45°: bottom-right is the brightest corner + let img = wash(-45.0); + assert!(img.get(199, 99)[0] > 250 && img.get(0, 0)[0] < 5); + // opacity scales it + let img = + compose(&Rgba8::new(0, 0), None, &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 0.5, "washAngle": 0})), 200, 100); + assert!((img.get(199, 50)[0] as i32 - 127).abs() < 3); + } + + #[test] + fn background_image_fills_the_slide_at_its_opacity() { + let bg = solid(30, 10, [200, 100, 0]); + let s = with(json!({"backgroundImage": 5, "backgroundOpacity": 0.5, "backdropColor": "#000000", "margins": 0.25})); + let img = compose(&solid(4, 4, GREEN), Some(&bg), &s, 400, 200); + assert_eq!(px(&img, 2, 2), [100, 50, 0], "half the image over black"); + assert_eq!(px(&img, 398, 198), [100, 50, 0], "fills the whole slide"); + assert_eq!(px(&img, 200, 100), GREEN, "the photo on top"); + // off, or no image given: the backdrop colour + let off = SlideSettings { background_image: None, ..s.clone() }; + assert_eq!(px(&compose(&solid(4, 4, GREEN), Some(&bg), &off, 400, 200), 2, 2), [0, 0, 0]); + assert_eq!(px(&compose(&solid(4, 4, GREEN), None, &s, 400, 200), 2, 2), [0, 0, 0]); + } + + #[test] + fn layouts_are_resolution_independent() { + let s = with( + json!({"linkMargins": false, "marginLeft": 0.1, "marginTop": 0.05, "marginRight": 0.2, "marginBottom": 0.08, "strokeBorder": true, "strokeWidth": 0.01, "castShadow": true, "shadowOffset": 0.02}), + ); + let a = layout(&s, 1.5, 800, 450); + let b = layout(&s, 1.5, 1600, 900); + for (ra, rb) in [(a.cell, b.cell), (a.photo, b.photo), (a.frame, b.frame), (a.shadow.unwrap().0, b.shadow.unwrap().0)] { + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 2.0, "{ra:?} vs {rb:?}"); + } + } + assert!((a.shadow.unwrap().1 * 2.0 - b.shadow.unwrap().1).abs() < 1e-9); + // and the pixels agree: the big slide, halved, is the small one (away from edges) + let photo = Rgba8::from_fn(60, 40, |x, y| [(x * 4) as u8, (y * 6) as u8, 90, 255]); + let small = compose(&photo, None, &s, 800, 450); + let big = compose(&photo, None, &s, 1600, 900); + for (x, y) in [(10, 10), (400, 225), (300, 100), (700, 400)] { + let (p, q) = (small.get(x, y), big.get(x * 2, y * 2)); + assert!((0..3).all(|i| (p[i] as i32 - q[i] as i32).abs() < 24), "({x}, {y}): {p:?} vs {q:?}"); + } + } + + #[test] + fn the_default_slide_is_the_photo_fitted_on_black() { + let photo = Rgba8::from_fn(300, 200, |x, y| [x as u8, y as u8, 7, 255]); + let s = SlideSettings::default(); + assert!(s.is_plain()); + // a 3:2 photo on a 16:10 screen: the photo at its own pixels, black bars beside it + assert_eq!(photo_size(&s, 1.5, 320, 200), (300, 200)); + let img = compose(&photo, None, &s, 320, 200); + assert_eq!(px(&img, 5, 100), [0, 0, 0]); + assert_eq!(px(&img, 314, 100), [0, 0, 0]); + for (x, y) in [(0, 0), (150, 100), (299, 199)] { + assert_eq!(img.get(x + 10, y), photo.get(x, y), "unchanged pixels at ({x}, {y})"); + } + } + + #[test] + fn hostile_input_never_panics() { + let wild = SlideSettings { + margin_left: f64::NAN, + margin_right: 1e300, + margin_top: -5.0, + margin_bottom: f64::INFINITY, + stroke_border: true, + stroke_width: 1e9, + cast_shadow: true, + shadow_opacity: f64::NAN, + shadow_offset: -1.0, + shadow_radius: f64::INFINITY, + shadow_angle: f64::NAN, + color_wash: true, + wash_opacity: 9.0, + wash_angle: f64::INFINITY, + background_image: Some(u64::MAX), + background_opacity: f64::NEG_INFINITY, + zoom_to_fill: true, + ..SlideSettings::default() + }; + let photos = [Rgba8::new(0, 0), Rgba8::new(1, 1), Rgba8::new(5000, 1), Rgba8::new(1, 3000), Rgba8 { width: 4, height: 4, data: vec![] }]; + for photo in &photos { + for (w, h) in [(0, 0), (1, 1), (3, 2), (1, 400), (100_000, 2), (64, 48)] { + let img = compose(photo, Some(&Rgba8::new(0, 5)), &wild, w, h); + assert!(img.width >= 1 && img.height >= 1 && img.width <= MAX_SIDE && img.height <= MAX_SIDE); + assert_eq!(img.data.len(), img.width * img.height); + let (pw, ph) = photo_size(&wild, photo.width as f64 / photo.height.max(1) as f64, w, h); + assert!(pw >= 1 && ph >= 1 && pw <= 4 * MAX_SIDE && ph <= 4 * MAX_SIDE); + } + } + for a in [f64::NAN, 0.0, -1.0, f64::INFINITY, 1e-300, 1e300] { + let l = layout(&wild, a, 640, 480); + assert!(l.photo.width() >= 1.0 && l.photo.width().is_finite()); + } + // the clamps: biggest margins still leave a cell, biggest stroke and shadow stay bounded + let s = SlideSettings { + margin_left: MARGIN_MAX, + margin_right: MARGIN_MAX, + stroke_border: true, + stroke_width: STROKE_MAX, + cast_shadow: true, + shadow_offset: SHADOW_OFFSET_MAX, + shadow_radius: SHADOW_RADIUS_MAX, + ..SlideSettings::default() + }; + let l = layout(&s, 1.0, 100, 100); + assert!(l.cell.width() >= 1.0, "{l:?}"); + assert!(l.frame.x0 >= 0.0 && l.frame.x1 <= 100.0, "the stroke stays on the slide"); + } +} diff --git a/crates/slideshow/src/lib.rs b/crates/slideshow/src/lib.rs new file mode 100644 index 000000000..4d61716a5 --- /dev/null +++ b/crates/slideshow/src/lib.rs @@ -0,0 +1,23 @@ +//! Slideshow slides for LightCraft: the settings of Lightroom Classic's Slideshow Layout, Options +//! and Backdrop panels ([`SlideSettings`]) and the CPU composer that draws one slide from a +//! rendered photo ([`compose`]). +//! +//! No UI and no catalog here: playback, the settings preview, the `slideshow.preview` command and +//! (later) slideshow export all call [`compose`], so every one of them draws the same pixels. +//! +//! **Resolution independence:** every length is relative to the slide: margins, the stroke width +//! and the shadow's offset and radius are fractions of the slide's *shorter side* (so linked +//! margins are equal in pixels, and a slide composed at two sizes has the same proportions). +//! Angles are degrees counter-clockwise from the right (−45° points down and to the right). +//! +//! **Never crash:** settings are validated by [`SlideSettings::merged`] (a wrong type is an error) +//! and clamped by [`SlideSettings::sanitized`] (NaN, ±∞, negative and huge values land in range); +//! [`compose`] accepts any size and any photo (an empty one draws the backdrop alone). +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod compose; +mod settings; + +pub use compose::{Layout, Rect, compose, layout, photo_size}; +pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color}; diff --git a/crates/slideshow/src/settings.rs b/crates/slideshow/src/settings.rs new file mode 100644 index 000000000..6fb004bf6 --- /dev/null +++ b/crates/slideshow/src/settings.rs @@ -0,0 +1,401 @@ +//! [`SlideSettings`]: Layout (margins, guides, aspect preview), Options (zoom to fill, stroke +//! border, cast shadow) and Backdrop (colour, colour wash, background image). + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Largest margin (fraction of the slide's shorter side). +pub const MARGIN_MAX: f64 = 0.45; +/// Widest stroke border (fraction of the slide's shorter side). +pub const STROKE_MAX: f64 = 0.05; +/// Longest shadow offset (fraction of the slide's shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.1; +/// Largest shadow radius (fraction of the slide's shorter side). +pub const SHADOW_RADIUS_MAX: f64 = 0.1; +/// Largest slide side [`crate::compose`] draws (bigger requests are scaled down to it). +pub const MAX_SIDE: usize = 8192; + +/// The aspect the slide is previewed at (Lightroom Classic's Aspect Preview). Playback always +/// fills the screen; the settings preview and `slideshow.preview` use this. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Aspect { + /// The screen's own aspect. + #[default] + #[serde(rename = "screen")] + Screen, + #[serde(rename = "16:9")] + Wide, + #[serde(rename = "4:3")] + Standard, +} + +impl Aspect { + pub const ALL: [Aspect; 3] = [Aspect::Screen, Aspect::Wide, Aspect::Standard]; + + pub fn id(self) -> &'static str { + match self { + Aspect::Screen => "screen", + Aspect::Wide => "16:9", + Aspect::Standard => "4:3", + } + } + + pub fn parse(s: &str) -> Option { + Aspect::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) + } + + /// Width / height, given the screen's (used for [`Aspect::Screen`]; a bad one gives 16:10). + pub fn ratio(self, screen: f64) -> f64 { + match self { + Aspect::Screen if screen.is_finite() && screen > 0.05 && screen < 20.0 => screen, + Aspect::Screen => 1.6, + Aspect::Wide => 16.0 / 9.0, + Aspect::Standard => 4.0 / 3.0, + } + } +} + +/// How a slide looks (everything but the photo). Lengths are fractions of the slide's shorter +/// side, angles degrees counter-clockwise from the right, opacities 0–1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SlideSettings { + // Layout + pub margin_left: f64, + pub margin_right: f64, + pub margin_top: f64, + pub margin_bottom: f64, + /// Changing one margin changes all four. + pub link_margins: bool, + /// Show the margin guides in the slide editor (never in playback or export). + pub show_guides: bool, + pub aspect: Aspect, + // Options + /// Crop the photo to fill its cell (off: the whole photo fits inside it). + pub zoom_to_fill: bool, + pub stroke_border: bool, + pub stroke_color: [u8; 3], + pub stroke_width: f64, + pub cast_shadow: bool, + pub shadow_opacity: f64, + pub shadow_offset: f64, + pub shadow_radius: f64, + pub shadow_angle: f64, + // Backdrop + pub backdrop_color: [u8; 3], + /// A gradient from `wash_color` (full at the side `wash_angle` points to) to the backdrop. + pub color_wash: bool, + pub wash_color: [u8; 3], + pub wash_opacity: f64, + pub wash_angle: f64, + /// A library photo (its id) drawn over the backdrop colour, filling the slide. + pub background_image: Option, + pub background_opacity: f64, +} + +impl Default for SlideSettings { + fn default() -> Self { + SlideSettings { + margin_left: 0.0, + margin_right: 0.0, + margin_top: 0.0, + margin_bottom: 0.0, + link_margins: true, + show_guides: true, + aspect: Aspect::Screen, + zoom_to_fill: false, + stroke_border: false, + stroke_color: [255, 255, 255], + stroke_width: 0.004, + cast_shadow: false, + shadow_opacity: 0.4, + shadow_offset: 0.012, + shadow_radius: 0.02, + shadow_angle: -45.0, + backdrop_color: [0, 0, 0], + color_wash: false, + wash_color: [96, 96, 96], + wash_opacity: 0.6, + wash_angle: 90.0, + background_image: None, + background_opacity: 0.5, + } + } +} + +/// The keys [`SlideSettings::merged`] takes (for error messages and docs). +const KEYS: &str = "margins, marginLeft, marginRight, marginTop, marginBottom, linkMargins, showGuides, aspect, zoomToFill, strokeBorder, strokeColor, strokeWidth, castShadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle, backdropColor, colorWash, washColor, washOpacity, washAngle, backgroundImage, backgroundOpacity"; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +/// An angle in degrees into (−180, 180]; non-finite → `fallback`. +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +impl SlideSettings { + /// The settings with every value in range: lengths and opacities clamped, angles wrapped, + /// non-finite numbers replaced by the defaults. + pub fn sanitized(mut self) -> Self { + let d = SlideSettings::default(); + for m in [&mut self.margin_left, &mut self.margin_right, &mut self.margin_top, &mut self.margin_bottom] { + *m = clamp_or(*m, 0.0, MARGIN_MAX, 0.0); + } + self.stroke_width = clamp_or(self.stroke_width, 0.0, STROKE_MAX, d.stroke_width); + self.shadow_opacity = clamp_or(self.shadow_opacity, 0.0, 1.0, d.shadow_opacity); + self.shadow_offset = clamp_or(self.shadow_offset, 0.0, SHADOW_OFFSET_MAX, d.shadow_offset); + self.shadow_radius = clamp_or(self.shadow_radius, 0.0, SHADOW_RADIUS_MAX, d.shadow_radius); + self.shadow_angle = wrap_angle(self.shadow_angle, d.shadow_angle); + self.wash_opacity = clamp_or(self.wash_opacity, 0.0, 1.0, d.wash_opacity); + self.wash_angle = wrap_angle(self.wash_angle, d.wash_angle); + self.background_opacity = clamp_or(self.background_opacity, 0.0, 1.0, d.background_opacity); + self + } + + /// Does a slide with these settings show nothing but the photo fitted on black (the plain + /// slideshow)? Settings that are switched on but draw nothing (a 0-wide stroke, a shadow at + /// 0 % opacity) count as plain. + pub fn is_plain(&self) -> bool { + let s = self.clone().sanitized(); + s.margin_left == 0.0 + && s.margin_right == 0.0 + && s.margin_top == 0.0 + && s.margin_bottom == 0.0 + && !s.zoom_to_fill + && !(s.stroke_border && s.stroke_width > 0.0) + && !(s.cast_shadow && s.shadow_opacity > 0.0) + && s.backdrop_color == [0, 0, 0] + && !(s.color_wash && s.wash_opacity > 0.0) + && !(s.background_image.is_some() && s.background_opacity > 0.0) + } + + /// A hash of what changes the slide's pixels (guides and the aspect preview don't), for + /// render keys. Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut s = self.clone().sanitized(); + s.show_guides = false; + s.aspect = Aspect::Screen; + s.link_margins = false; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.to_json().to_string().hash(&mut h); + h.finish() + } + + pub fn to_json(&self) -> Value { + json!({ + "marginLeft": self.margin_left, + "marginRight": self.margin_right, + "marginTop": self.margin_top, + "marginBottom": self.margin_bottom, + "linkMargins": self.link_margins, + "showGuides": self.show_guides, + "aspect": self.aspect.id(), + "zoomToFill": self.zoom_to_fill, + "strokeBorder": self.stroke_border, + "strokeColor": hex(self.stroke_color), + "strokeWidth": self.stroke_width, + "castShadow": self.cast_shadow, + "shadowOpacity": self.shadow_opacity, + "shadowOffset": self.shadow_offset, + "shadowRadius": self.shadow_radius, + "shadowAngle": self.shadow_angle, + "backdropColor": hex(self.backdrop_color), + "colorWash": self.color_wash, + "washColor": hex(self.wash_color), + "washOpacity": self.wash_opacity, + "washAngle": self.wash_angle, + "backgroundImage": self.background_image, + "backgroundOpacity": self.background_opacity, + }) + } + + /// These settings with the keys `p` names changed (the others kept), validated and clamped. + /// A wrong type, an unknown key or a non-finite number is an error, and nothing changes. + /// With `linkMargins` on (already, or set in `p`), a margin given sets all four; `margins` + /// sets all four in any case. + pub fn merged(&self, p: &Value) -> Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + let mut margin: Option = None; + for (k, v) in obj { + match k.as_str() { + "margins" => { + let m = number(k, v)?; + (s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m); + } + "marginLeft" => s.margin_left = *margin.insert(number(k, v)?), + "marginRight" => s.margin_right = *margin.insert(number(k, v)?), + "marginTop" => s.margin_top = *margin.insert(number(k, v)?), + "marginBottom" => s.margin_bottom = *margin.insert(number(k, v)?), + "linkMargins" => s.link_margins = boolean(k, v)?, + "showGuides" => s.show_guides = boolean(k, v)?, + "aspect" => { + s.aspect = + v.as_str().and_then(Aspect::parse).ok_or_else(|| format!("`aspect` must be \"screen\", \"16:9\" or \"4:3\", not {v}"))? + } + "zoomToFill" => s.zoom_to_fill = boolean(k, v)?, + "strokeBorder" => s.stroke_border = boolean(k, v)?, + "strokeColor" => s.stroke_color = color(k, v)?, + "strokeWidth" => s.stroke_width = number(k, v)?, + "castShadow" => s.cast_shadow = boolean(k, v)?, + "shadowOpacity" => s.shadow_opacity = number(k, v)?, + "shadowOffset" => s.shadow_offset = number(k, v)?, + "shadowRadius" => s.shadow_radius = number(k, v)?, + "shadowAngle" => s.shadow_angle = number(k, v)?, + "backdropColor" => s.backdrop_color = color(k, v)?, + "colorWash" => s.color_wash = boolean(k, v)?, + "washColor" => s.wash_color = color(k, v)?, + "washOpacity" => s.wash_opacity = number(k, v)?, + "washAngle" => s.wash_angle = number(k, v)?, + "backgroundImage" => { + s.background_image = match v { + Value::Null => None, + _ => Some(v.as_u64().ok_or_else(|| format!("`backgroundImage` must be a photo id or null, not {v}"))?), + } + } + "backgroundOpacity" => s.background_opacity = number(k, v)?, + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + if s.link_margins + && let Some(m) = margin + { + (s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m); + } + Ok(s.sanitized()) + } +} + +fn number(k: &str, v: &Value) -> Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{k}` must be a finite number, not {v}")), + } +} + +fn boolean(k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}")) +} + +fn color(k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +/// `"#rrggbb"` (the `#` optional) or `[r, g, b]` with 0–255 components. +pub fn parse_color(v: &Value) -> Option<[u8; 3]> { + match v { + Value::String(s) => { + let h = s.trim().strip_prefix('#').unwrap_or(s.trim()); + if h.len() != 6 || !h.is_ascii() { + return None; + } + let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); + Some([byte(0)?, byte(2)?, byte(4)?]) + } + Value::Array(a) if a.len() == 3 => { + let mut c = [0u8; 3]; + for (o, x) in c.iter_mut().zip(a) { + *o = u8::try_from(x.as_u64()?).ok()?; + } + Some(c) + } + _ => None, + } +} + +/// `#rrggbb`. +pub fn hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_validates_links_margins_and_round_trips() { + let d = SlideSettings::default(); + assert!(d.is_plain(), "the default slide is the plain slideshow"); + let s = d.merged(&json!({"marginLeft": 0.1})).unwrap(); + assert_eq!((s.margin_left, s.margin_right, s.margin_top, s.margin_bottom), (0.1, 0.1, 0.1, 0.1), "linked: all four"); + let s = d.merged(&json!({"linkMargins": false, "marginTop": 0.2})).unwrap(); + assert_eq!((s.margin_left, s.margin_top), (0.0, 0.2), "unlinked: one"); + let s = s.merged(&json!({"margins": 0.05})).unwrap(); + assert_eq!((s.margin_left, s.margin_bottom), (0.05, 0.05)); + let s = d + .merged(&json!({"strokeBorder": true, "strokeColor": "#FF8000", "washColor": [1, 2, 3], "aspect": "16:9", "backgroundImage": 7})) + .unwrap(); + assert_eq!(s.stroke_color, [255, 128, 0]); + assert_eq!(s.wash_color, [1, 2, 3]); + assert_eq!(s.aspect, Aspect::Wide); + assert_eq!(s.background_image, Some(7)); + assert!(!s.is_plain()); + // the JSON form reads back as the same settings + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + // serde (prefs.json) round trip, and missing keys take the defaults + let back: SlideSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + let partial: SlideSettings = serde_json::from_str(r#"{"marginTop": 0.3}"#).unwrap(); + assert_eq!(partial, SlideSettings { margin_top: 0.3, ..d.clone() }); + assert_eq!(d.merged(&json!({"backgroundImage": null})).unwrap().background_image, None); + } + + #[test] + fn hostile_settings_are_rejected_or_clamped() { + let d = SlideSettings::default(); + for bad in [ + json!({"marginLeft": "wide"}), + json!({"margins": null}), + json!({"zoomToFill": 1}), + json!({"strokeColor": "#12345"}), + json!({"strokeColor": "#ééééé"}), + json!({"washColor": [256, 0, 0]}), + json!({"washColor": [-1, 0, 0]}), + json!({"backdropColor": [1, 2]}), + json!({"aspect": "21:9"}), + json!({"backgroundImage": -3}), + json!({"backgroundImage": "photo"}), + json!({"sparkle": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"margins": 1e300, "strokeWidth": -2, "shadowOpacity": 7, "shadowOffset": 1e9, "shadowRadius": -1, "shadowAngle": 540, "washAngle": -190, "washOpacity": -0.5})) + .unwrap(); + assert_eq!((s.margin_left, s.margin_top), (MARGIN_MAX, MARGIN_MAX)); + assert_eq!((s.stroke_width, s.shadow_opacity, s.shadow_offset, s.shadow_radius), (0.0, 1.0, SHADOW_OFFSET_MAX, 0.0)); + assert_eq!((s.shadow_angle, s.wash_angle, s.wash_opacity), (180.0, 170.0, 0.0)); + // NaN / ±∞ (a hand-edited prefs file, an in-process caller) take the defaults + let s = SlideSettings { + margin_left: f64::NAN, + stroke_width: f64::INFINITY, + shadow_angle: f64::NEG_INFINITY, + background_opacity: f64::NAN, + ..d.clone() + } + .sanitized(); + assert_eq!( + (s.margin_left, s.stroke_width, s.shadow_angle, s.background_opacity), + (0.0, d.stroke_width, d.shadow_angle, d.background_opacity) + ); + assert_eq!(Aspect::Screen.ratio(f64::NAN), 1.6); + assert_eq!(Aspect::Screen.ratio(1.5), 1.5); + assert!((Aspect::Wide.ratio(1.0) - 16.0 / 9.0).abs() < 1e-12); + } + + #[test] + fn pixel_key_ignores_what_does_not_change_pixels() { + let d = SlideSettings::default(); + let guides = SlideSettings { show_guides: false, aspect: Aspect::Standard, ..d.clone() }; + assert_eq!(d.pixel_key(), guides.pixel_key()); + assert_ne!(d.pixel_key(), SlideSettings { backdrop_color: [9, 9, 9], ..d.clone() }.pixel_key()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f7dbc65ab..f68aad329 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ lightcraft-pipeline = { workspace = true } lightcraft-catalog = { workspace = true } web-time = { workspace = true } lightcraft-engine = { workspace = true } +lightcraft-slideshow = { workspace = true } lightcraft-preview = { workspace = true } egui = { workspace = true } serde = { workspace = true } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f10..fa4855fe3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2031,5 +2031,31 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "Playback": "Wiedergabe", + "Layout": "Layout", + "Link all margins": "Alle Ränder verknüpfen", + "Margins": "Ränder", + "Left": "Links", + "Right": "Rechts", + "Top": "Oben", + "Bottom": "Unten", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.", + "Show guides": "Hilfslinien anzeigen", + "Aspect preview": "Seitenverhältnis-Vorschau", + "Zoom to fill frame": "Zoomen, um Rahmen zu füllen", + "Stroke border": "Konturlinie", + "Colour": "Farbe", + "Cast shadow": "Schlagschatten", + "Offset": "Versatz", + "Backdrop": "Hintergrund", + "Background colour": "Hintergrundfarbe", + "Colour wash": "Farbverlauf", + "Background image": "Hintergrundbild", + "(not in the library)": "(nicht in der Bibliothek)", + "Use selected photo": "Ausgewähltes Foto verwenden", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.", + "Reset layout": "Layout zurücksetzen", + "Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e8357..4ab5578fe 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1876,5 +1876,31 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "Playback": "Reproducción", + "Layout": "Diseño", + "Link all margins": "Vincular todos los márgenes", + "Margins": "Márgenes", + "Left": "Izquierda", + "Right": "Derecha", + "Top": "Arriba", + "Bottom": "Abajo", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Los márgenes son una parte del lado más corto de la diapositiva, así que las diapositivas se ven igual en cualquier pantalla.", + "Show guides": "Mostrar guías", + "Aspect preview": "Vista previa de proporción", + "Zoom to fill frame": "Ampliar para llenar el marco", + "Stroke border": "Borde de trazo", + "Colour": "Color", + "Cast shadow": "Proyectar sombra", + "Offset": "Desplazamiento", + "Backdrop": "Fondo", + "Background colour": "Color de fondo", + "Colour wash": "Degradado de color", + "Background image": "Imagen de fondo", + "(not in the library)": "(no está en la biblioteca)", + "Use selected photo": "Usar la foto seleccionada", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.", + "Reset layout": "Restablecer diseño", + "Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0a..e456530b5 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1876,5 +1876,31 @@ "Downloading model": "Téléchargement du modèle", "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", - "Looking for changes": "Recherche des modifications" + "Looking for changes": "Recherche des modifications", + "Playback": "Lecture", + "Layout": "Disposition", + "Link all margins": "Lier toutes les marges", + "Margins": "Marges", + "Left": "Gauche", + "Right": "Droite", + "Top": "Haut", + "Bottom": "Bas", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Les marges sont une part du petit côté de la diapositive : les diapositives sont identiques sur tous les écrans.", + "Show guides": "Afficher les repères", + "Aspect preview": "Aperçu des proportions", + "Zoom to fill frame": "Zoom pour remplir le cadre", + "Stroke border": "Contour", + "Colour": "Couleur", + "Cast shadow": "Ombre portée", + "Offset": "Décalage", + "Backdrop": "Toile de fond", + "Background colour": "Couleur d’arrière-plan", + "Colour wash": "Dégradé de couleur", + "Background image": "Image d’arrière-plan", + "(not in the library)": "(absente de la bibliothèque)", + "Use selected photo": "Utiliser la photo sélectionnée", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.", + "Reset layout": "Réinitialiser la disposition", + "Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32ef..a8da442e4 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1878,5 +1878,31 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "Playback": "再生", + "Layout": "レイアウト", + "Link all margins": "すべての余白をリンク", + "Margins": "余白", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "余白はスライドの短辺に対する割合なので、どの画面でもスライドは同じように見えます。", + "Show guides": "ガイドを表示", + "Aspect preview": "縦横比プレビュー", + "Zoom to fill frame": "ズームして枠を埋める", + "Stroke border": "境界線", + "Colour": "カラー", + "Cast shadow": "ドロップシャドウ", + "Offset": "オフセット", + "Backdrop": "背景", + "Background colour": "背景色", + "Colour wash": "カラーウォッシュ", + "Background image": "背景画像", + "(not in the library)": "(ライブラリにありません)", + "Use selected photo": "選択した写真を使用", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。", + "Reset layout": "レイアウトをリセット", + "Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d78..e811a1be1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1876,5 +1876,31 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "Playback": "Reprodução", + "Layout": "Layout", + "Link all margins": "Vincular todas as margens", + "Margins": "Margens", + "Left": "Esquerda", + "Right": "Direita", + "Top": "Superior", + "Bottom": "Inferior", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "As margens são uma parte do lado menor do slide, então os slides ficam iguais em qualquer tela.", + "Show guides": "Mostrar guias", + "Aspect preview": "Visualização da proporção", + "Zoom to fill frame": "Zoom para preencher o quadro", + "Stroke border": "Borda", + "Colour": "Cor", + "Cast shadow": "Projetar sombra", + "Offset": "Deslocamento", + "Backdrop": "Fundo", + "Background colour": "Cor de fundo", + "Colour wash": "Degradê de cor", + "Background image": "Imagem de fundo", + "(not in the library)": "(não está na biblioteca)", + "Use selected photo": "Usar a foto selecionada", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.", + "Reset layout": "Redefinir layout", + "Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf9974740..2685f8609 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1590,5 +1590,31 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "Playback": "Воспроизведение", + "Layout": "Макет", + "Link all margins": "Связать все поля", + "Margins": "Поля", + "Left": "Слева", + "Right": "Справа", + "Top": "Сверху", + "Bottom": "Снизу", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаются долей короткой стороны слайда, поэтому слайды выглядят одинаково на любом экране.", + "Show guides": "Показывать направляющие", + "Aspect preview": "Предпросмотр пропорций", + "Zoom to fill frame": "Масштабировать по рамке", + "Stroke border": "Обводка", + "Colour": "Цвет", + "Cast shadow": "Отбрасывать тень", + "Offset": "Смещение", + "Backdrop": "Фон", + "Background colour": "Цвет фона", + "Colour wash": "Цветовой градиент", + "Background image": "Фоновое изображение", + "(not in the library)": "(нет в библиотеке)", + "Use selected photo": "Использовать выбранное фото", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.", + "Reset layout": "Сбросить макет", + "Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b93..e3ddc10ae 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2070,5 +2070,31 @@ "Downloading model": "Завантаження моделі", "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", - "Looking for changes": "Пошук змін" + "Looking for changes": "Пошук змін", + "Playback": "Відтворення", + "Layout": "Макет", + "Link all margins": "Зв’язати всі поля", + "Margins": "Поля", + "Left": "Ліворуч", + "Right": "Праворуч", + "Top": "Угорі", + "Bottom": "Унизу", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаються часткою короткої сторони слайда, тож слайди виглядають однаково на будь-якому екрані.", + "Show guides": "Показувати напрямні", + "Aspect preview": "Попередній перегляд пропорцій", + "Zoom to fill frame": "Масштабувати за рамкою", + "Stroke border": "Обведення", + "Colour": "Колір", + "Cast shadow": "Відкидати тінь", + "Offset": "Зсув", + "Backdrop": "Тло", + "Background colour": "Колір тла", + "Colour wash": "Кольоровий градієнт", + "Background image": "Фонове зображення", + "(not in the library)": "(немає в бібліотеці)", + "Use selected photo": "Використати вибране фото", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.", + "Reset layout": "Скинути макет", + "Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e9..065494810 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1876,5 +1876,31 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "Playback": "播放", + "Layout": "布局", + "Link all margins": "链接所有边距", + "Margins": "边距", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "边距按幻灯片短边的比例设置,因此幻灯片在任何屏幕上看起来都一样。", + "Show guides": "显示参考线", + "Aspect preview": "长宽比预览", + "Zoom to fill frame": "缩放以填充框架", + "Stroke border": "描边", + "Colour": "颜色", + "Cast shadow": "投影", + "Offset": "偏移", + "Backdrop": "背景", + "Background colour": "背景颜色", + "Colour wash": "颜色渐变", + "Background image": "背景图像", + "(not in the library)": "(不在图库中)", + "Use selected photo": "使用所选照片", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。", + "Reset layout": "重置布局", + "Select a photo to preview its slide": "选择一张照片以预览其幻灯片", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c2..27802e68e 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1876,5 +1876,31 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "Playback": "播放", + "Layout": "版面", + "Link all margins": "連結所有邊界", + "Margins": "邊界", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "邊界以投影片短邊的比例設定,因此投影片在任何螢幕上看起來都一樣。", + "Show guides": "顯示參考線", + "Aspect preview": "長寬比預覽", + "Zoom to fill frame": "縮放以填滿框架", + "Stroke border": "描邊", + "Colour": "顏色", + "Cast shadow": "投射陰影", + "Offset": "偏移", + "Backdrop": "背景", + "Background colour": "背景顏色", + "Colour wash": "顏色漸層", + "Background image": "背景影像", + "(not in the library)": "(不在圖庫中)", + "Use selected photo": "使用所選照片", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", + "Reset layout": "重設版面", + "Select a photo to preview its slide": "選取一張照片以預覽其投影片", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。" } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0652414c1..3d40f9cc1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -27,6 +27,7 @@ pub mod right; pub mod rules_editor; pub mod second; pub mod settings; +pub mod slide_layout; pub mod slideshow; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7569f4a3c..2be60a4a5 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -68,7 +68,7 @@ pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); } -fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { +pub(super) fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); @@ -642,7 +642,32 @@ fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &s } } +/// Width of the Slideshow tab's settings column (the slide preview is beside it). +const SLIDESHOW_COLUMN_W: f32 = 480.0; + fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // the settings scroll on the left, the composed slide stays in view on the right + let top_down = egui::Layout::top_down(egui::Align::Min); + // as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt) + let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| { + ui.set_width(SLIDESHOW_COLUMN_W); + egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| { + playback_section(app, ui, t); + ui.add_space(6.0); + super::slide_layout::sections(app, ui, t); + }); + }); + ui.add_space(12.0); + ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| { + ui.set_width(super::slide_layout::PREVIEW_W); + super::slide_layout::preview(app, ui, t) + }); + }); +} + +fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; heading(ui, t, crate::i18n::tr("Playback")); let s = app.session.slideshow.clone(); diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs new file mode 100644 index 000000000..c0e03844c --- /dev/null +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -0,0 +1,228 @@ +//! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's +//! Slideshow panels of the same names) and a small live preview of the composed slide. +//! +//! Every control goes through `slideshow.layout` (validated there, saved with the library). +//! Sliders change the layout in memory while dragged (the preview follows) and save it when let +//! go. The preview is the active photo's slide rendered off the UI thread ([`Slot::SlidePreview`]) +//! by the same composer playback uses; it is requested once the layout has been still for +//! [`DEBOUNCE`] seconds, so a drag doesn't queue a render per frame. + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use lightcraft_engine::slide::{Aspect, SlideSettings}; +use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::render::Slot; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Seconds the layout must stay unchanged before the preview is rendered again. +pub const DEBOUNCE: f64 = 0.15; +/// The preview's width in points. +pub const PREVIEW_W: f32 = 300.0; + +#[derive(Clone, Copy)] +enum Unit { + /// A fraction shown as a percentage (of the slide's shorter side, or an opacity). + Percent, + Degrees, +} + +fn set(app: &mut LightcraftApp, p: Value) { + let _ = app.run("slideshow.layout", p); +} + +/// A layout slider (`slider:{id}`): `key`'s value, 0–`max` (percent) or −180–180° (degrees). +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, max: f64, unit: Unit) { + let (mut v, range, suffix) = match unit { + Unit::Percent => (value * 100.0, 0.0..=max * 100.0, " %"), + Unit::Degrees => (value, -180.0..=180.0, "°"), + }; + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let value = match unit { + Unit::Percent => v / 100.0, + Unit::Degrees => v, + }; + if r.changed() + && let Ok(next) = app.session.slide_layout.merged(&json!({ key: value })) + { + app.session.slide_layout = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + set(app, json!({ key: value })); + } +} + +/// A colour button (`button:{id}`) for `key`. +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: [u8; 3]) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, json!({ key: c })); + } +} + +/// A checkbox (`check:settings.{id}`) for `key`; true when it is on. +fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: bool, label: &str) -> bool { + let mut v = value; + if check(ui, &format!("settings.{id}"), &mut v, label) { + set(app, json!({ key: v })); + } + v +} + +/// The Layout, Options and Backdrop sections. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s = app.session.slide_layout.clone(); + + heading(ui, t, "Layout"); + let linked = toggle(app, ui, "slideshowLinkMargins", "linkMargins", s.link_margins, "Link all margins"); + if linked { + row(ui, t, "Margins", |ui| slider(app, ui, "slideshowMargins", "marginLeft", s.margin_left, MARGIN_MAX, Unit::Percent)); + } else { + for (id, key, label, v) in [ + ("slideshowMarginLeft", "marginLeft", "Left", s.margin_left), + ("slideshowMarginRight", "marginRight", "Right", s.margin_right), + ("slideshowMarginTop", "marginTop", "Top", s.margin_top), + ("slideshowMarginBottom", "marginBottom", "Bottom", s.margin_bottom), + ] { + row(ui, t, label, |ui| slider(app, ui, id, key, v, MARGIN_MAX, Unit::Percent)); + } + } + hint(ui, t, "Margins are a share of the slide's shorter side, so slides look the same on any screen."); + toggle(app, ui, "slideshowGuides", "showGuides", s.show_guides, "Show guides"); + row(ui, t, "Aspect preview", |ui| { + let mut a = s.aspect; + let opts = [(Aspect::Screen, "Screen"), (Aspect::Wide, "16:9"), (Aspect::Standard, "4:3")]; + if choices(ui, "settingsSlideshowAspect", &opts, &mut a) { + set(app, json!({"aspect": a.id()})); + } + }); + + ui.add_space(6.0); + heading(ui, t, "Options"); + toggle(app, ui, "slideshowZoomToFill", "zoomToFill", s.zoom_to_fill, "Zoom to fill frame"); + if toggle(app, ui, "slideshowStroke", "strokeBorder", s.stroke_border, "Stroke border") { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStrokeColor", "strokeColor", s.stroke_color)); + row(ui, t, "Width", |ui| slider(app, ui, "slideshowStrokeWidth", "strokeWidth", s.stroke_width, STROKE_MAX, Unit::Percent)); + } + if toggle(app, ui, "slideshowShadow", "castShadow", s.cast_shadow, "Cast shadow") { + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowShadowOpacity", "shadowOpacity", s.shadow_opacity, 1.0, Unit::Percent)); + row(ui, t, "Offset", |ui| slider(app, ui, "slideshowShadowOffset", "shadowOffset", s.shadow_offset, SHADOW_OFFSET_MAX, Unit::Percent)); + row(ui, t, "Radius", |ui| slider(app, ui, "slideshowShadowRadius", "shadowRadius", s.shadow_radius, SHADOW_RADIUS_MAX, Unit::Percent)); + row(ui, t, "Angle", |ui| slider(app, ui, "slideshowShadowAngle", "shadowAngle", s.shadow_angle, 180.0, Unit::Degrees)); + } + + ui.add_space(6.0); + heading(ui, t, "Backdrop"); + row(ui, t, "Background colour", |ui| colour(app, ui, "slideshowBackdropColor", "backdropColor", s.backdrop_color)); + if toggle(app, ui, "slideshowWash", "colorWash", s.color_wash, "Colour wash") { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowWashColor", "washColor", s.wash_color)); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowWashOpacity", "washOpacity", s.wash_opacity, 1.0, Unit::Percent)); + row(ui, t, "Angle", |ui| slider(app, ui, "slideshowWashAngle", "washAngle", s.wash_angle, 180.0, Unit::Degrees)); + } + let active = app.session.active(); + let mut on = s.background_image.is_some(); + let r = ui.add_enabled(on || active.is_some(), egui::Checkbox::new(&mut on, crate::i18n::tr("Background image"))); + register(ui.ctx(), "check:settings.slideshowBackgroundImage", r.rect); + if r.changed() { + set(app, json!({"backgroundImage": if on { active.map(|a| a.0) } else { None }})); + } + if let Some(bid) = s.background_image { + let name = app.session.catalog.photo(lightcraft_catalog::PhotoId(bid)).map(|p| p.file_name.clone()); + row(ui, t, "Photo", |ui| { + let text = name.unwrap_or_else(|| crate::i18n::tr("(not in the library)").to_string()); + ui.label(egui::RichText::new(text).color(t.text)); + if let Some(a) = active.filter(|a| a.0 != bid) + && crate::widgets::text_button(ui, "slideshowUseSelected", "Use selected photo", false).clicked() + { + set(app, json!({"backgroundImage": a.0})); + } + }); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowBackgroundOpacity", "backgroundOpacity", s.background_opacity, 1.0, Unit::Percent)); + } else { + hint(ui, t, "Select a photo, then turn this on to fill the slide behind every photo with it."); + } + ui.add_space(4.0); + if crate::widgets::text_button(ui, "slideshowResetLayout", "Reset layout", false).clicked() { + reset(app); + } +} + +/// The aspect of the screen a slideshow plays on (the monitor, else the window). +fn screen_aspect(ctx: &egui::Context) -> f64 { + let monitor = ctx.input(|i| i.viewport().monitor_size).filter(|m| m.x > 0.0 && m.y > 0.0); + let r = monitor.unwrap_or_else(|| ctx.content_rect().size()); + if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 } +} + +/// The small composed slide of the active photo, with the margin guides when they are on. +pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "Preview"); + let s = app.session.slide_layout.clone(); + let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32; + let h = (PREVIEW_W / ratio).clamp(80.0, 320.0); + let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover()); + register(ui.ctx(), "slideshow:preview", rect); + let p = ui.painter_at(rect.expand(1.0)); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); + let Some(id) = app.session.active() else { + p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a photo to preview its slide"), t.font(11.5), Color32::from_gray(200)); + return; + }; + let ppp = ui.ctx().pixels_per_point(); + let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); + let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64); + let now = ui.input(|i| i.time); + let mem = egui::Id::new("settingsSlideshowPreviewKey"); + let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); + let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id); + if last != key { + ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now))); + } + let still = if last == key { now - since } else { 0.0 }; + // the first preview (or another photo's) at once, a changing layout once it has settled + if current.is_none() || still >= DEBOUNCE { + let side = super::detail::texture_side(ui.ctx()); + let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side); + if let Some(job) = app.session.slide_job_with(&s, id, pw, ph, Some(edge), false) { + app.renderer.request(Slot::SlidePreview, job, 90); + } + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE))); + } + let Some(tex) = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id) else { + p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); + return; + }; + let drawn = super::detail::fit_texture_rect(rect, tex.size); + p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if s.show_guides { + // the margins, as lines across the slide (where the image cell's edges are) + let aspect = app.session.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&s, aspect, tex.size[0], tex.size[1]); + let (kx, ky) = (drawn.width() / tex.size[0].max(1) as f32, drawn.height() / tex.size[1].max(1) as f32); + let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 170, 255, 200)); + let x = |v: f64| drawn.left() + v as f32 * kx; + let y = |v: f64| drawn.top() + v as f32 * ky; + for gx in [x(l.cell.x0), x(l.cell.x1)] { + p.line_segment([pos2(gx, drawn.top()), pos2(gx, drawn.bottom())], stroke); + } + for gy in [y(l.cell.y0), y(l.cell.y1)] { + p.line_segment([pos2(drawn.left(), gy), pos2(drawn.right(), gy)], stroke); + } + } + hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview."); +} + +/// The default layout (for the Reset button). +pub fn reset(app: &mut LightcraftApp) { + let d = SlideSettings::default().to_json(); + set(app, d); +} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index 9e1416a67..13b8f0c5a 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -12,8 +12,10 @@ //! transition until the next slide's texture exists ([`ready`]): the current slide is held //! instead of flashing a stand-in. With it off, a slide is rendered when it is due. //! -//! Which picture a slide shows comes from one place, [`slide_job`], so a later slide composer -//! (layout, overlays, backdrop) replaces that function and the playback stays as it is. +//! Which picture a slide shows comes from one place, [`slide_job`]: the photo itself with the +//! plain layout, else the slide composed with the library's layout (`slideshow.layout`: margins, +//! zoom to fill, stroke, shadow, backdrop) by `lightcraft-slideshow` on the render worker. A +//! composed slide is a screen-sized texture; the playback (fades, controls) is the same. use egui::{Align2, Color32, Pos2, Rect, pos2, vec2}; use lightcraft_catalog::PhotoId; @@ -167,21 +169,47 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { } } -/// The render that shows slide `id` in `area` (logical points at `ppp`): the photo as developed, -/// fitted to the screen. The one place that decides what a slide's picture is. +/// The render that shows slide `id` in `area` (logical points at `ppp`). The one place that +/// decides what a slide's picture is: +/// - with the plain layout (no margins, border, shadow or backdrop: [`SlideSettings::is_plain`]) +/// the photo as developed, fitted to the screen (drawn over black); +/// - otherwise the slide composed with the library's layout (`slideshow.layout`) at the screen's +/// pixels: the photo rendered at its image cell's size, then composed on the render worker, so +/// a slide is ready (and fades) like any other texture. +/// +/// [`SlideSettings::is_plain`]: lightcraft_engine::slide::SlideSettings::is_plain fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option { let photo = app.session.catalog.photo(id)?.clone(); let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); + let native = super::detail::output_px(&frame); + if !app.session.slide_layout.is_plain() { + let (w, h) = slide_px(area, ppp, texture_side); + let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side); + return app.session.slide_job(id, w, h, Some(edge)); + } let aspect = frame.aspect() as f32; let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 }; let fit = (area.width() / aspect).min(area.height()); let drawn_long = fit.max(fit * aspect) * ppp; - let native = super::detail::output_px(&frame); let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side); let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) }; app.session.loupe_job(id, w.max(8), h.max(8), true) } +/// A composed slide's size in pixels: `area` at `ppp`, its long side at most the texture side +/// (and the composer's limit), at least 16 × 16. +pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) { + let px = |v: f32| if v.is_finite() { (v * ppp).round().max(16.0) as usize } else { 16 }; + let (w, h) = (px(area.width()), px(area.height())); + let cap = texture_side.clamp(16, lightcraft_slideshow::MAX_SIDE); + let long = w.max(h); + if long <= cap { + return (w, h); + } + let k = cap as f64 / long as f64; + (((w as f64 * k).round() as usize).max(16), ((h as f64 * k).round() as usize).max(16)) +} + /// The slides to keep rendered now, most wanted first, with their render priority. fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> { let f = play.frame(now); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 8346650c7..1a7d1f4fc 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -55,6 +55,9 @@ pub enum Slot { /// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see /// [`crate::panels::slideshow`]). Slide(PhotoId), + /// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being + /// edited). + SlidePreview, } pub struct Tex { diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 06e402bef..36558267b 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -240,3 +240,107 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { assert!(photos.len() > 3); h.settle(SETTLE); } + +fn painted(img: &egui::ColorImage, x: usize, y: usize) -> [u8; 3] { + let c = img.pixels[y * img.size[0] + x]; + [c.r(), c.g(), c.b()] +} + +#[test] +fn a_composed_slide_shows_during_playback() { + let mut h = grid(); + let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"})); + assert_eq!(r["marginTop"], 0.1); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); + h.step(); + let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap(); + assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen"); + let img = h.paint(); + assert_eq!(img.size, [1000, 700]); + // 10 % of the 700 px short side = 70 px of red backdrop around the image cell + for (x, y) in [(5, 5), (995, 5), (500, 30), (500, 690), (30, 350), (985, 350)] { + assert_eq!(painted(&img, x, y), [255, 0, 0], "margin at ({x}, {y})"); + } + let mid = painted(&img, 500, 350); + assert_ne!(mid, [255, 0, 0], "the photo in the cell"); + // the next slide is composed too, and stepping shows it + let next = play(&h).upcoming(1)[0]; + assert!(h.step_until(SETTLE, |h| has_slide(h, next))); + run(&mut h, "slideshow.next", json!({})); + h.step(); + assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]); + h.settle(SETTLE); +} + +#[test] +fn the_default_layout_plays_the_photo_as_before() { + let mut h = grid(); + assert!(h.app.session.slide_layout.is_plain()); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current))); + h.step(); + // the photo's own render (its aspect, not the screen's), fitted on black, as before + let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap(); + let aspect = h.app.session.slide_photo_aspect(current).unwrap(); + let tex_aspect = tex.size[0] as f64 / tex.size[1] as f64; + assert!((tex_aspect - aspect).abs() < 0.02, "{tex_aspect} vs {aspect}"); + assert_ne!(tex.size, [1000, 700]); + let img = h.paint(); + let r = crate::panels::detail::fit_texture_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)), tex.size); + if r.left() > 2.0 { + assert_eq!(painted(&img, 1, 350), [0, 0, 0], "black beside the photo"); + } + if r.top() > 2.0 { + assert_eq!(painted(&img, 500, 1), [0, 0, 0], "black above the photo"); + } + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_slide_layout_and_a_live_preview() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + for w in [ + "slider:slideshowMargins", + "check:settings.slideshowZoomToFill", + "check:settings.slideshowStroke", + "check:settings.slideshowShadow", + "button:slideshowBackdropColor", + "check:settings.slideshowWash", + "slideshow:preview", + ] { + assert!(has(&h, w), "{w}"); + } + // the preview is the composed slide, rendered off the UI thread + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + // a checkbox goes through slideshow.layout, and its controls appear (scrolled into view) + let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.scroll", json!({"dy": -600}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..30 { + h.step(); + } + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_layout.cast_shadow); + assert!(has(&h, "slider:slideshowShadowAngle")); + // a changed layout renders the preview again (once it has settled) + let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key); + run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); + h.step(); + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); + h.settle(SETTLE); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 552f76952..2d0f88ab1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,6 +49,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | +| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | +| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index 81cbc4bd8..845d054fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,6 +196,12 @@ JSON params (documented in each tool's description and by `list_commands`). Pass leave these per-command tools out when a client struggles with a large tool list; `run_command` still reaches every command. +Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide layout (margins, +zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with +the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it — +written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout +before playing it. `cmd_slideshow_playback` sets the timing and fades. + ## Resources `resources/list` / `resources/read` serve JSON snapshots: diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f9bc830d3..6995f6f16 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 33 | 16 | 34 | 9 | — | 23/25 (92%) | -| **Total** | 397 | 50 | 72 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.5% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163. ## Top gaps and shortcut notes @@ -728,13 +728,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | | | LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | | | LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | -| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | | -| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | | +| LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | +| LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | | LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | -| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | +| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, slides composed with layout/overlays/backdrop (LRC-SS-LAYOUT…) | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index b8a83b591..a59175377 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,18 @@ ## 2026-10-10 +### Slideshow layout, options and backdrop +- Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four + linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width), + a cast shadow (opacity, offset, radius, angle), and a backdrop colour with an optional colour wash (a gradient from a + second colour, at an angle and opacity) and a background image (a photo from the library, at an opacity). +- Set them in Settings ▸ Slideshow, which now shows the selected photo as a slide as you change them (with the margin + guides, at the screen's aspect or 16:9 / 4:3). Sizes are a share of the slide, so a slide looks the same on any + screen. They are saved with the library. +- View ▸ Slideshow plays the composed slides, prepared ahead off the UI thread like before, so fades and the playback + controls work as they did. With the default layout (no margins, border, shadow or backdrop) it plays the photos as + before. Agents use `slideshow.layout`, and `slideshow.preview` returns one composed slide as a PNG. + ### Slideshow playback - View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 051b3aedf..1b4429214 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -48,6 +48,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("develop", Class::Layer(1)), ("faces", Class::Layer(1)), ("scenes", Class::Layer(1)), + ("slideshow", Class::Layer(1)), ("pipeline", Class::Layer(2)), ("gpu", Class::Layer(3)), ("catalog", Class::Layer(3)), From 12dc533396fd7b6e8d000c2a4b5b855257a40849 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:54:40 +0200 Subject: [PATCH 003/315] LRC-SS-OVERLAYS: slideshow overlays: identity plate, rating stars, text with tokens, watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine: slide_overlay (OverlaySettings in prefs.json, the new slideshow.overlays command): identity plate (text, or a PNG/JPEG file with its transparency; size, colour, opacity; render behind image), rating stars (own vector stars), up to 16 text overlays with tokens ({caption}, {exposure}, {file}, {date}, {focal}… plus every rename token; unknown tags show as typed and are reported) and a drop shadow, each at one of nine anchors of the slide or photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide's shorter side; validated and clamped. - Drawn by the engine as a second pass over lightcraft_slideshow::compose_with (new behind hook for "render behind image"); the watermark's text renderer moved to engine text.rs (TextLayout, Inter + craft-fonts CJK) and is shared; glyphs off the image are skipped so huge text stays cheap. - rename::expand_tokens_with lets overlays add tokens without changing rename. - Playback composes slides when overlays are on; slideshow.preview returns each overlay's box. Settings > Slideshow > Overlays section with live preview. - Docs: parity row (partial), whats-new, control/MCP docs, nine locale catalogs. - Tests: tokens, nine anchors, star count, plate image opacity and missing file, text shadow, resolution independence, hostile input, big-slide cost, CJK with craft-fonts, prefs persistence, preview and headless playback pixel checks. --- crates/engine/src/cmd/slideshow.rs | 33 +- crates/engine/src/export.rs | 190 +-- crates/engine/src/lib.rs | 5 + crates/engine/src/library.rs | 4 + crates/engine/src/rename.rs | 63 +- crates/engine/src/slide.rs | 35 +- crates/engine/src/slide_overlay.rs | 1322 +++++++++++++++++++ crates/engine/src/tests_slide.rs | 90 ++ crates/engine/src/text.rs | 207 +++ crates/slideshow/src/compose.rs | 30 +- crates/slideshow/src/lib.rs | 2 +- crates/ui-egui/locales/de.json | 14 +- crates/ui-egui/locales/es.json | 14 +- crates/ui-egui/locales/fr.json | 14 +- crates/ui-egui/locales/ja.json | 14 +- crates/ui-egui/locales/pt-br.json | 14 +- crates/ui-egui/locales/ru.json | 15 +- crates/ui-egui/locales/uk.json | 14 +- crates/ui-egui/locales/zh-hans.json | 14 +- crates/ui-egui/locales/zh-hant.json | 14 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 2 + crates/ui-egui/src/panels/slide_layout.rs | 14 +- crates/ui-egui/src/panels/slide_overlays.rs | 265 ++++ crates/ui-egui/src/panels/slideshow.rs | 2 +- crates/ui-egui/src/tests_slideshow.rs | 83 ++ docs/control-protocol.md | 3 +- docs/mcp.md | 5 +- docs/parity-checklist.md | 10 +- docs/whats-new.md | 13 + 30 files changed, 2290 insertions(+), 216 deletions(-) create mode 100644 crates/engine/src/slide_overlay.rs create mode 100644 crates/engine/src/text.rs create mode 100644 crates/ui-egui/src/panels/slide_overlays.rs diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 17bd861f1..1ac391055 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -2,8 +2,9 @@ //! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks //! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop //! colour, colour wash, background image). Both are library preferences, saved in the library's -//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.preview` -//! composes one slide as a PNG. +//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`: +//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every +//! slide (also a library preference). `slideshow.preview` composes one slide as a PNG. use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; @@ -38,6 +39,20 @@ fn layout(s: &mut Session, p: &Value) -> Result { Ok(s.slide_layout.to_json()) } +fn overlays(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?; + if next != s.slide_overlays { + s.slide_overlays = next; + s.save_prefs()?; + } + } + let mut out = s.slide_overlays.to_json(); + out["warnings"] = json!(s.slide_overlays.warnings()); + Ok(out) +} + /// A slide size from the request: both sides given, one (the other from the aspect preview), or /// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { @@ -89,6 +104,9 @@ fn preview(s: &mut Session, p: &Value) -> Result { "cell": rect(l.cell), "photo": rect(l.visible), }); + let photo = s.catalog.photo(id).cloned(); + let o = crate::slide_overlay::SlideOverlays::resolve(&s.slide_overlays, photo.as_deref()); + out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect(); match path { Some(path) => { crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?; @@ -135,12 +153,21 @@ pub fn specs() -> Vec { always, layout ), + cmd!( + "slideshow.overlays", + "Slideshow Overlay Settings", + [], + None, + "{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)", + always, + overlays + ), cmd!( query "slideshow.preview", "Slideshow Slide Preview", [], None, - "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), path | png (base64 PNG)}", + "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}", always, preview ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a9e9147c..4cd4dd50d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -242,58 +242,8 @@ impl Default for Watermark { } } -/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). -static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); - -/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is -/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's -/// missing-glyph box. -fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec> { - let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); - cjk.sort_by_key(|f| !f.is_mincho()); - std::iter::once(WATERMARK_FONT) - .chain(cjk.into_iter().map(|f| f.bytes)) - .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) - .collect() -} - -/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. -/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. -fn watermark_cell_glyphs( - font: &ab_glyph::FontRef<'_>, - data: &harfrust::ShaperData, - text: &str, - vertical: bool, - px: f32, - left: f32, - top: f32, -) -> Option> { - use ab_glyph::{Font, ScaleFont}; - let face = harfrust::FontRef::new(font.font_data()).ok()?; - let mut buf = harfrust::UnicodeBuffer::new(); - buf.push_str(text); - buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); - buf.guess_segment_properties(); - let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); - if shaped.glyph_infos().is_empty() { - return None; - } - let sf = font.as_scaled(px); - let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); - let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); - let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); - let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; - let mut glyphs = Vec::new(); - for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; - let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); - glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); - x += pos.x_advance as f32 * sx; - y -= pos.y_advance as f32 * sy; - } - Some(glyphs) -} +#[cfg(test)] +use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { @@ -306,6 +256,23 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { }); } +/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and +/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. +pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { + let (width, height) = (img.width, img.height); + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, |x, y, k, col| { + let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); + if px >= width || py >= height { + return; + } + if let Some(p) = img.data.get_mut(py * width + px) { + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + } + }); +} + /// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float /// images are linear and blend in linear light). pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { @@ -338,7 +305,6 @@ fn watermark_coverage( craft: &'static [crate::fonts::CraftFont], mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), ) { - use ab_glyph::{Font, PxScale, ScaleFont, point}; if !wm.image.trim().is_empty() { logo_coverage(width, height, wm, blend_px); return; @@ -347,97 +313,10 @@ fn watermark_coverage( if text.is_empty() || width == 0 || height == 0 { return; } - let fonts = watermark_fonts(craft); - let Some(latin) = fonts.first() else { return }; - let vertical_data: Vec<_> = if wm.vertical { - fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() - } else { - Vec::new() - }; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); - let mut glyphs = Vec::new(); - let mut x = 0.0f32; - let mut y = 0.0f32; - let mut tw = 0.0f32; - let mut prev = None; - let columns = text.split('\n').count(); - let mut column = 0usize; - use unicode_segmentation::UnicodeSegmentation; - // Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes. - let cells: Box> = - if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; - for cell in cells { - let Some(original) = cell.chars().next() else { continue }; - if cell == "\n" || (wm.vertical && cell == "\r\n") { - if wm.vertical { - column = column.saturating_add(1); - tw = tw.max(y); - y = 0.0; - } else { - tw = tw.max(x); - x = 0.0; - y += px; - } - prev = None; - continue; - } - let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); - if wm.vertical && (cjk || cell.chars().count() > 1) { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { - let data = data.as_ref()?; - let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?; - let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); - if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') { - return None; - } - Some((face, cluster)) - }); - if let Some((face, cluster)) = shaped { - glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); - y += px; - continue; - } - } - let vertical_form = match original { - '、' => '︑', - '。' => '︒', - '(' => '︵', - ')' => '︶', - '「' => '﹁', - '」' => '﹂', - '『' => '﹃', - '』' => '﹄', - '【' => '︻', - '】' => '︼', - '…' => '︙', - other => other, - }; - let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0; - let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; - // The first face with the glyph; Inter (its missing-glyph box) when none has it. - let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); - let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px)); - let id = sf.glyph_id(ch); - if wm.vertical { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); - let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); - glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); - y += px; - } else { - if let Some((previous_face, previous)) = prev - && previous_face == face - { - x += sf.kern(previous, id); - } - glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); - x += sf.h_advance(id); - prev = Some((face, id)); - } - } - let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; + let (tw, th) = (layout.width, layout.height); let inset = wm.inset.clamp(0.0, 0.4) * short; let (w, h) = (width as f32, height as f32); use Anchor::*; @@ -452,33 +331,20 @@ fn watermark_coverage( BottomLeft | Bottom | BottomRight => h - inset - th, }; let alpha = wm.opacity.clamp(0.0, 1.0); - let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { - if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { - return; - } - blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col); - }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - for (face, g, rotation) in &glyphs { - if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { - let b = o.px_bounds(); - o.draw(|gx, gy, cov| { - let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); - let (sx, sy) = match rotation { - Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), - None => (sx, sy), - }; - blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha); - }); + layout.draw_in(ox + off, oy + off, width as f32, height as f32, |gx, gy, cov| { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { + return; } - } + blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); + }); } } /// A watermark graphic, decoded once per file (and again when the file changes). -fn watermark_logo(path: &str) -> Option> { +pub(crate) fn watermark_logo(path: &str) -> Option> { use std::collections::HashMap; use std::sync::{Arc, Mutex}; static CACHE: Mutex)>>> = Mutex::new(None); @@ -833,7 +699,7 @@ impl ExportOptions { } /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). - fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let obj = match v { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d1bd5db8..fab21a5d9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,9 +56,11 @@ pub mod rename; pub mod segment; pub mod sidecar; pub mod slide; +pub mod slide_overlay; pub mod slideshow; pub mod smart; pub mod sync; +pub mod text; mod view; pub mod walk; @@ -278,6 +280,8 @@ pub struct Session { /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in /// prefs.json). pub slide_layout: slide::SlideSettings, + /// Slideshow overlays (identity plate, rating stars, text overlays, watermark). + pub slide_overlays: slide_overlay::OverlaySettings, /// The background image the slides last rendered (shared by slide jobs: rendered once). pub slide_backdrop: std::sync::Arc, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). @@ -364,6 +368,7 @@ impl Session { cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), slide_layout: slide::SlideSettings::default(), + slide_overlays: slide_overlay::OverlaySettings::default(), slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 4f5695a0f..6c7bafaef 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -162,6 +162,8 @@ struct PrefsFile { slideshow: crate::slideshow::PlaybackSettings, /// Slideshow slide layout (margins, options, backdrop). slide_layout: crate::slide::SlideSettings, + /// Slideshow overlays. + slide_overlays: crate::slide_overlay::OverlaySettings, } fn presets_json(s: &Session) -> String { @@ -346,6 +348,7 @@ impl Session { self.cache_mb = prefs.cache_mb; self.slideshow = prefs.slideshow.sanitized(); self.slide_layout = prefs.slide_layout.sanitized(); + self.slide_overlays = prefs.slide_overlays.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -584,6 +587,7 @@ impl Session { forget_local_days: Some(self.forget_local_days), slideshow: self.slideshow.clone(), slide_layout: self.slide_layout.clone(), + slide_overlays: self.slide_overlays.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 259d51fcd..31a2696e0 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String { /// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` /// is zero-padded to `seq_width` digits. pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option, +) -> String { let (stem, ext) = split_ext(&p.file_name); let mut out = String::new(); let mut rest = template; @@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> }; let tok = &rest[i + 1..i + j]; let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let v = match name.trim().to_ascii_lowercase().as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() } - Source::Demo { .. } => String::new(), + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), }; out.push_str(&v); rest = &rest[i + j + 1..]; diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs index cd487d7d8..ab252cf6d 100644 --- a/crates/engine/src/slide.rs +++ b/crates/engine/src/slide.rs @@ -1,6 +1,7 @@ //! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom -//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command) and render jobs -//! that compose a photo into its slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). +//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command), its overlays +//! ([`crate::slide_overlay`], `slideshow.overlays`) and render jobs that compose a photo into its +//! slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). //! //! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings //! preview and `slideshow.preview` all compose through it, so they draw the same pixels. @@ -50,6 +51,8 @@ pub struct SlideCompose { pub width: usize, pub height: usize, pub backdrop: Option, + /// The overlays resolved for the slide's photo (`None`: none draw). + pub overlays: Option>, } impl SlideCompose { @@ -59,12 +62,19 @@ impl SlideCompose { ^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f) ^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29)) + ^ self.overlays.as_ref().map_or(0, |o| o.key().rotate_left(43)) } /// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size). pub fn compose(&self, photo: &Rgba8) -> Rgba8 { let bg = self.backdrop.as_ref().and_then(Backdrop::image); - lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height) + let Some(o) = &self.overlays else { + return lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height); + }; + let (mut img, l) = + lightcraft_slideshow::compose_with(photo, bg.as_deref(), &self.settings, self.width, self.height, &mut |img, l| o.draw_behind(img, l)); + o.draw_front(&mut img, &l); + img } } @@ -83,9 +93,16 @@ impl crate::Session { Some(if a.is_finite() && a > 0.0 { a } else { 1.5 }) } + /// Does a slide show nothing but the photo fitted on black (the plain layout and no + /// overlays)? Playback then shows the photo's own render. + pub fn slides_plain(&self) -> bool { + self.slide_layout.is_plain() && !self.slide_overlays.any() + } + /// The composition of `settings` at `width × height`, with its background image's render job - /// (the photo `settings.background_image` names, when it is in the catalog). - pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize) -> Arc { + /// (the photo `settings.background_image` names, when it is in the catalog) and the library's + /// overlays resolved for `photo`. + pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option) -> Arc { let settings = settings.clone().sanitized(); let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { let id = PhotoId(bid); @@ -96,7 +113,11 @@ impl crate::Session { let job = self.render_job(id, w, h, false, true)?; Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) }); - Arc::new(SlideCompose { settings, width, height, backdrop }) + let overlays = self.slide_overlays.any().then(|| { + let p = photo.and_then(|id| self.catalog.photo(id)); + Arc::new(crate::slide_overlay::SlideOverlays::resolve(&self.slide_overlays, p.map(|p| &**p))) + }); + Arc::new(SlideCompose { settings, width, height, backdrop, overlays }) } /// A render job whose result is photo `id`'s slide at `width × height` with the library's @@ -126,7 +147,7 @@ impl crate::Session { (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); } let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; - let compose = self.slide_compose(settings, width, height); + let compose = self.slide_compose(settings, width, height, Some(id)); Some(job.with_slide(compose)) } diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs new file mode 100644 index 000000000..8dc450c8b --- /dev/null +++ b/crates/engine/src/slide_overlay.rs @@ -0,0 +1,1322 @@ +//! Slideshow overlays (Lightroom Classic's Slideshow ▸ Overlays panel): the identity plate (text +//! or an image file), the photo's rating as stars, text overlays with tokens (file name, title, +//! caption, capture date, exposure, camera, lens, rating…) and the export watermark. +//! [`OverlaySettings`] is a library preference (`prefs.json`), edited with the +//! `slideshow.overlays` command; [`SlideOverlays`] is those settings resolved for one photo (its +//! texts expanded, its rating) and drawn onto the composed slide. +//! +//! **Where the drawing lives:** text needs the fonts (Inter plus the craft-fonts CJK fallbacks, +//! [`crate::text`]) and tokens need the catalog's photo, both in the engine, so overlays are a +//! second pass in the engine over `lightcraft_slideshow::compose_with`'s slide (its `behind` hook +//! draws an identity plate set to "render behind image" before the photo). The composer stays a +//! layout-and-pixels crate with no fonts and no catalog. +//! +//! **Resolution independence:** sizes and offsets are fractions of the slide's shorter side (also +//! for overlays anchored to the photo), so a slide at any size has the same proportions. An +//! overlay is placed at one of nine anchors of the slide or of the photo, moved by its offset +//! (x right, y down) and kept on the slide. +//! +//! **Never crash:** settings are validated by [`OverlaySettings::merged`] and clamped by +//! [`OverlaySettings::sanitized`]; text is cut at [`TEXT_MAX_CHARS`]; an unreadable identity +//! plate image draws nothing (and `slideshow.overlays` reports it); unknown tokens stay as typed +//! (as in rename and export file names) and are reported. + +use std::sync::Arc; + +use lightcraft_catalog::Photo; +use lightcraft_raster::{Image, Plane, Rgba8}; +use lightcraft_slideshow::{Layout, Rect, hex, parse_color}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::export::{Anchor, Watermark}; + +/// Smallest text, plate and star size (fraction of the slide's shorter side). +pub const SIZE_MIN: f64 = 0.005; +/// Largest text line height. +pub const TEXT_SIZE_MAX: f64 = 0.3; +/// Largest identity plate height. +pub const PLATE_SIZE_MAX: f64 = 0.5; +/// Largest star. +pub const STAR_SIZE_MAX: f64 = 0.15; +/// Largest offset either way (fraction of the shorter side). +pub const OFFSET_MAX: f64 = 1.0; +/// Longest shadow offset and radius (fractions of the shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.05; +pub const SHADOW_RADIUS_MAX: f64 = 0.05; +/// Most text overlays. +pub const MAX_TEXTS: usize = 16; +/// Longest overlay text or template (characters; longer is cut). +pub const TEXT_MAX_CHARS: usize = 1000; + +/// What an overlay is anchored to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RelativeTo { + #[default] + Slide, + /// The visible photo. + Photo, +} + +impl RelativeTo { + fn id(self) -> &'static str { + match self { + RelativeTo::Slide => "slide", + RelativeTo::Photo => "photo", + } + } +} + +/// The nine anchors, in reading order (for pickers and tests). +pub const ANCHORS: [Anchor; 9] = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, +]; + +/// An anchor's id (`topLeft`…). +pub fn anchor_id(a: Anchor) -> &'static str { + match a { + Anchor::TopLeft => "topLeft", + Anchor::Top => "top", + Anchor::TopRight => "topRight", + Anchor::Left => "left", + Anchor::Center => "center", + Anchor::Right => "right", + Anchor::BottomLeft => "bottomLeft", + Anchor::Bottom => "bottom", + Anchor::BottomRight => "bottomRight", + } +} + +/// Where an overlay goes: an anchor of the slide or the photo, and an offset from it. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placement { + pub anchor: Anchor, + /// Fractions of the slide's shorter side, x to the right, y down. + pub offset_x: f64, + pub offset_y: f64, + pub relative_to: RelativeTo, +} + +impl Default for Placement { + fn default() -> Self { + Placement { anchor: Anchor::BottomRight, offset_x: -0.03, offset_y: -0.03, relative_to: RelativeTo::Slide } + } +} + +impl Placement { + fn sanitized(mut self) -> Self { + self.offset_x = clamp_or(self.offset_x, -OFFSET_MAX, OFFSET_MAX, 0.0); + self.offset_y = clamp_or(self.offset_y, -OFFSET_MAX, OFFSET_MAX, 0.0); + self + } + + fn json(&self, o: &mut Map) { + o.insert("anchor".into(), json!(anchor_id(self.anchor))); + o.insert("offsetX".into(), json!(self.offset_x)); + o.insert("offsetY".into(), json!(self.offset_y)); + o.insert("relativeTo".into(), json!(self.relative_to.id())); + } + + /// Apply a placement key; `false` when `k` isn't one. + fn merge(&mut self, ctx: &str, k: &str, v: &Value) -> Result { + match k { + "anchor" => { + self.anchor = serde_json::from_value(v.clone()).map_err(|_| { + format!("`{ctx}.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, not {v}") + })? + } + "offsetX" => self.offset_x = number(ctx, k, v)?, + "offsetY" => self.offset_y = number(ctx, k, v)?, + "relativeTo" => { + self.relative_to = match v.as_str() { + Some("slide") => RelativeTo::Slide, + Some("photo") => RelativeTo::Photo, + _ => return Err(format!("`{ctx}.relativeTo` must be \"slide\" or \"photo\", not {v}")), + } + } + _ => return Ok(false), + } + Ok(true) + } + + /// The box of a `w × h` overlay on the slide `l`: at the anchor of the slide or the visible + /// photo, moved by the offset, then kept on the slide (when it fits). + pub fn place(&self, l: &Layout, w: f64, h: f64) -> Rect { + let c = match self.relative_to { + RelativeTo::Slide => l.slide, + RelativeTo::Photo => l.visible, + }; + let short = l.slide.width().min(l.slide.height()); + use Anchor::*; + let x = match self.anchor { + TopLeft | Left | BottomLeft => c.x0, + Top | Center | Bottom => (c.x0 + c.x1 - w) / 2.0, + TopRight | Right | BottomRight => c.x1 - w, + } + self.offset_x * short; + let y = match self.anchor { + TopLeft | Top | TopRight => c.y0, + Left | Center | Right => (c.y0 + c.y1 - h) / 2.0, + BottomLeft | Bottom | BottomRight => c.y1 - h, + } + self.offset_y * short; + let s = l.slide; + let fit = |v: f64, lo: f64, hi: f64, len: f64| if v.is_finite() { v.min(hi - len).max(lo) } else { lo }; + let (x, y) = (fit(x, s.x0, s.x1, w), fit(y, s.y0, s.y1, h)); + Rect { x0: x, y0: y, x1: x + w, y1: y + h } + } +} + +/// A soft drop shadow under text. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextShadow { + pub on: bool, + pub opacity: f64, + /// Fraction of the slide's shorter side. + pub offset: f64, + /// Blur radius, fraction of the slide's shorter side. + pub radius: f64, + /// Degrees counter-clockwise from the right (−45 = down right). + pub angle: f64, +} + +impl Default for TextShadow { + fn default() -> Self { + TextShadow { on: false, opacity: 0.6, offset: 0.004, radius: 0.004, angle: -45.0 } + } +} + +/// The identity plate: a line of text, or an image file (PNG / JPEG…, its transparency kept). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct IdentityPlate { + pub enabled: bool, + pub text: String, + /// An image file drawn instead of the text (empty = the text). + pub image: String, + /// Height: the text's line height or the image's height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + /// Draw it before the photo, so the photo covers it where they meet. + pub behind: bool, +} + +impl Default for IdentityPlate { + fn default() -> Self { + IdentityPlate { + enabled: false, + text: "LightCraft".into(), + image: String::new(), + size: 0.05, + color: [255, 255, 255], + opacity: 0.8, + place: Placement { anchor: Anchor::TopLeft, offset_x: 0.03, offset_y: 0.03, relative_to: RelativeTo::Slide }, + behind: false, + } + } +} + +/// The photo's star rating, drawn as stars. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RatingStars { + pub enabled: bool, + pub color: [u8; 3], + /// A star's height, fraction of the shorter side. + pub size: f64, + pub place: Placement, +} + +impl Default for RatingStars { + fn default() -> Self { + RatingStars { + enabled: false, + color: [235, 235, 235], + size: 0.03, + place: Placement { anchor: Anchor::TopRight, offset_x: -0.015, offset_y: 0.015, relative_to: RelativeTo::Photo }, + } + } +} + +/// A text overlay: a template with tokens (`{title}`, `{caption}`, `{exposure}`…; see [`TOKENS`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextOverlay { + pub text: String, + /// Line height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + pub shadow: TextShadow, +} + +impl Default for TextOverlay { + fn default() -> Self { + TextOverlay { + text: "{file}".into(), + size: 0.03, + color: [255, 255, 255], + opacity: 0.9, + place: Placement { anchor: Anchor::Bottom, offset_x: 0.0, offset_y: -0.02, relative_to: RelativeTo::Slide }, + shadow: TextShadow::default(), + } + } +} + +/// Every overlay of a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OverlaySettings { + pub identity_plate: IdentityPlate, + pub rating_stars: RatingStars, + pub texts: Vec, + /// Draw [`Self::watermark`] on the photo. + pub show_watermark: bool, + /// The export watermark's settings (sizes relative to the photo, as in export). + pub watermark: Watermark, +} + +/// Tokens text overlays add to the rename tokens ([`crate::rename::TOKENS`]): tag, meaning. +pub const TOKENS: &[(&str, &str)] = &[ + ("{file}", "File name with its extension"), + ("{caption}", "Caption metadata"), + ("{copyright}", "Copyright metadata"), + ("{exposure}", "Shutter speed and aperture (1/250 s at f/8)"), + ("{shutter}", "Shutter speed"), + ("{aperture}", "Aperture (f/8)"), + ("{focal}", "Focal length (50 mm)"), + ("{date}", "Capture date as YYYY-MM-DD (or {date:%d.%m.%Y} …)"), +]; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +/// `s` cut at [`TEXT_MAX_CHARS`] characters. +fn cut(s: &mut String) { + if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) { + s.truncate(i); + } +} + +fn number(ctx: &str, k: &str, v: &Value) -> Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")), + } +} + +fn boolean(ctx: &str, k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}")) +} + +fn string(ctx: &str, k: &str, v: &Value) -> Result { + let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.{k}` must be text, not {v}"))?.to_string(); + cut(&mut s); + Ok(s) +} + +fn colour(ctx: &str, k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +fn object<'a>(ctx: &str, v: &'a Value) -> Result<&'a Map, String> { + v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}")) +} + +fn unknown(ctx: &str, k: &str, keys: &str) -> String { + format!("unknown key `{ctx}.{k}` ({keys})") +} + +impl IdentityPlate { + fn sanitized(mut self) -> Self { + let d = IdentityPlate::default(); + cut(&mut self.text); + cut(&mut self.image); + self.size = clamp_or(self.size, SIZE_MIN, PLATE_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("text".into(), json!(self.text)); + o.insert("image".into(), json!(self.image)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("behind".into(), json!(self.behind)); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "identityPlate"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "text" => self.text = string(C, k, v)?, + "image" => self.image = string(C, k, v)?.trim().to_string(), + "size" => self.size = number(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "opacity" => self.opacity = number(C, k, v)?, + "behind" => self.behind = boolean(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, text, image, size, color, opacity, anchor, offsetX, offsetY, relativeTo, behind")), + } + } + Ok(()) + } +} + +impl RatingStars { + fn sanitized(mut self) -> Self { + self.size = clamp_or(self.size, SIZE_MIN, STAR_SIZE_MAX, RatingStars::default().size); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("size".into(), json!(self.size)); + self.place.json(&mut o); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "ratingStars"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "size" => self.size = number(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, color, size, anchor, offsetX, offsetY, relativeTo")), + } + } + Ok(()) + } +} + +impl TextOverlay { + fn sanitized(mut self) -> Self { + let d = TextOverlay::default(); + cut(&mut self.text); + self.size = clamp_or(self.size, SIZE_MIN, TEXT_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + let ds = TextShadow::default(); + let sh = &mut self.shadow; + sh.opacity = clamp_or(sh.opacity, 0.0, 1.0, ds.opacity); + sh.offset = clamp_or(sh.offset, 0.0, SHADOW_OFFSET_MAX, ds.offset); + sh.radius = clamp_or(sh.radius, 0.0, SHADOW_RADIUS_MAX, ds.radius); + sh.angle = wrap_angle(sh.angle, ds.angle); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("text".into(), json!(self.text)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("shadow".into(), json!(self.shadow.on)); + o.insert("shadowOpacity".into(), json!(self.shadow.opacity)); + o.insert("shadowOffset".into(), json!(self.shadow.offset)); + o.insert("shadowRadius".into(), json!(self.shadow.radius)); + o.insert("shadowAngle".into(), json!(self.shadow.angle)); + Value::Object(o) + } + + fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> { + for (k, v) in object(ctx, v)? { + if self.place.merge(ctx, k, v)? { + continue; + } + match k.as_str() { + "text" => self.text = string(ctx, k, v)?, + "size" => self.size = number(ctx, k, v)?, + "color" => self.color = colour(ctx, k, v)?, + "opacity" => self.opacity = number(ctx, k, v)?, + "shadow" => self.shadow.on = boolean(ctx, k, v)?, + "shadowOpacity" => self.shadow.opacity = number(ctx, k, v)?, + "shadowOffset" => self.shadow.offset = number(ctx, k, v)?, + "shadowRadius" => self.shadow.radius = number(ctx, k, v)?, + "shadowAngle" => self.shadow.angle = number(ctx, k, v)?, + _ => { + return Err(unknown( + ctx, + k, + "text, size, color, opacity, anchor, offsetX, offsetY, relativeTo, shadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle", + )); + } + } + } + Ok(()) + } +} + +/// The keys [`OverlaySettings::merged`] takes. +const KEYS: &str = "identityPlate, ratingStars, texts, addText, removeText, showWatermark, watermark"; + +impl OverlaySettings { + /// Every value in range (non-finite numbers take the defaults), at most [`MAX_TEXTS`] texts. + pub fn sanitized(mut self) -> Self { + self.identity_plate = self.identity_plate.sanitized(); + self.rating_stars = self.rating_stars.sanitized(); + self.texts.truncate(MAX_TEXTS); + self.texts = self.texts.into_iter().map(TextOverlay::sanitized).collect(); + let d = Watermark::default(); + let w = &mut self.watermark; + let f = |v: f32, lo: f32, hi: f32, fb: f32| if v.is_finite() { v.clamp(lo, hi) } else { fb }; + w.size = f(w.size, 0.005, 0.5, d.size); + w.opacity = f(w.opacity, 0.0, 1.0, d.opacity); + w.inset = f(w.inset, 0.0, 0.4, d.inset); + w.image_width = f(w.image_width, 0.01, 1.0, d.image_width); + cut(&mut w.text); + w.target = None; + self + } + + /// Does any overlay draw (on some photo)? + pub fn any(&self) -> bool { + let p = &self.identity_plate; + (p.enabled && p.opacity > 0.0 && (!p.text.trim().is_empty() || !p.image.trim().is_empty())) + || self.rating_stars.enabled + || self.texts.iter().any(|t| t.opacity > 0.0 && !t.text.trim().is_empty()) + || (self.show_watermark && (!self.watermark.text.trim().is_empty() || !self.watermark.image.trim().is_empty())) + } + + pub fn to_json(&self) -> Value { + let wm = serde_json::to_value(&self.watermark).unwrap_or(Value::Null); + json!({ + "identityPlate": self.identity_plate.to_json(), + "ratingStars": self.rating_stars.to_json(), + "texts": self.texts.iter().map(TextOverlay::to_json).collect::>(), + "showWatermark": self.show_watermark, + "watermark": wm, + }) + } + + /// These settings with the keys `p` names changed, validated and clamped; a wrong type or an + /// unknown key is an error and nothing changes. `texts` replaces the list (each entry merged + /// onto the one at its index, or a new default text); `addText` appends one (an object, `{}` + /// for the default); `removeText` removes the one at an index; `watermark` takes the export + /// watermark's keys (or text alone) merged onto the current one. + pub fn merged(&self, p: &Value) -> Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "identityPlate" => s.identity_plate.merge(v)?, + "ratingStars" => s.rating_stars.merge(v)?, + "texts" => { + let list = v.as_array().ok_or_else(|| format!("`texts` must be a list of text overlays, not {v}"))?; + if list.len() > MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays, not {}", list.len())); + } + let mut next = Vec::with_capacity(list.len()); + for (i, item) in list.iter().enumerate() { + let mut t = s.texts.get(i).cloned().unwrap_or_default(); + t.merge(&format!("texts[{i}]"), item)?; + next.push(t); + } + s.texts = next; + } + "addText" => { + if s.texts.len() >= MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays")); + } + let mut t = TextOverlay::default(); + t.merge("addText", v)?; + s.texts.push(t); + } + "removeText" => { + let i = v.as_u64().ok_or_else(|| format!("`removeText` must be the index of a text overlay, not {v}"))?; + let i = usize::try_from(i).ok().filter(|i| *i < s.texts.len()).ok_or_else(|| format!("no text overlay {i}"))?; + s.texts.remove(i); + } + "showWatermark" => s.show_watermark = boolean("", k, v).map_err(|_| format!("`showWatermark` must be true or false, not {v}"))?, + "watermark" => { + crate::export::ExportOptions::validate_watermark("slideshow.overlays", v).map_err(|e| e.to_string())?; + s.watermark = match v { + Value::String(t) => Watermark { text: t.clone(), ..s.watermark.clone() }, + _ => { + let mut cur = serde_json::to_value(&s.watermark).map_err(|e| e.to_string())?; + if let (Some(c), Some(o)) = (cur.as_object_mut(), v.as_object()) { + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + c.insert(k.clone(), v.clone()); + } + } + serde_json::from_value(cur).map_err(|e| format!("`watermark`: {e}"))? + } + }; + } + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + Ok(s.sanitized()) + } + + /// A hash of the settings (for render keys). Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.clone().sanitized().to_json().to_string().hash(&mut h); + h.finish() + } + + /// Problems worth telling the user about: an identity plate image that can't be read, tags in + /// text overlays that aren't tokens (they show as typed). + pub fn warnings(&self) -> Vec { + let mut out = Vec::new(); + let p = &self.identity_plate; + if p.enabled && !p.image.trim().is_empty() && crate::export::watermark_logo(p.image.trim()).is_none() { + out.push(format!("identity plate image {} can't be read; nothing is drawn", p.image.trim())); + } + for (i, t) in self.texts.iter().enumerate() { + for tag in unknown_tokens(&t.text) { + out.push(format!("text overlay {}: {tag} isn't a token and shows as typed", i + 1)); + } + } + out + } +} + +/// An exposure as "1/250 s at f/8" (either part alone when the other is unknown). +fn exposure(p: &Photo) -> String { + let parts: Vec = [shutter(p), aperture(p)].into_iter().filter(|s| !s.is_empty()).collect(); + parts.join(" at ") +} + +fn shutter(p: &Photo) -> String { + let s = p.meta.shutter.trim(); + if s.is_empty() || s.ends_with('s') || s.ends_with('"') { s.to_string() } else { format!("{s} s") } +} + +fn aperture(p: &Photo) -> String { + p.meta.aperture.filter(|a| a.is_finite() && *a > 0.0).map(|a| format!("f/{a:.1}").replace(".0", "")).unwrap_or_default() +} + +/// The overlay tokens beyond the rename ones; `{seq}` has no meaning on a slide and stays as typed. +fn overlay_token(p: &Photo, name: &str, arg: Option<&str>, tag: &str) -> Option { + Some(match name { + "file" => p.file_name.clone(), + "caption" => p.meta.caption.clone(), + "copyright" => p.meta.copyright.clone(), + "exposure" => exposure(p), + "shutter" => shutter(p), + "aperture" => aperture(p), + "focal" => p.meta.focal_mm.filter(|f| f.is_finite() && *f > 0.0).map(|f| format!("{f:.0} mm")).unwrap_or_default(), + "date" if arg.is_none() => crate::rename::expand_tokens("{date:%Y-%m-%d}", p, 1, 1), + "seq" | "n" => format!("{{{tag}}}"), + _ => return None, + }) +} + +/// `template`'s tokens replaced for `p`: the rename tokens ([`crate::rename::expand_tokens`]) and +/// [`TOKENS`]. Unknown tags stay as typed. +pub fn expand(template: &str, p: &Photo) -> String { + crate::rename::expand_tokens_with(template, p, 1, 1, &|name, arg, tag| overlay_token(p, name, arg, tag)) +} + +/// The `{…}` tags in `template` that aren't tokens. +pub fn unknown_tokens(template: &str) -> Vec { + let p = crate::rename::sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(after) = rest.get(i..) else { break }; + let Some(j) = after.find('}') else { break }; + let Some(tag) = after.get(..j + 1) else { break }; + if expand(tag, &p) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = after.get(j + 1..).unwrap_or(""); + } + out +} + +/// What an overlay box is, for [`SlideOverlays::boxes`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + IdentityPlate, + RatingStars, + /// The text overlay at this index. + Text(usize), + Watermark, +} + +impl Kind { + pub fn id(&self) -> String { + match self { + Kind::IdentityPlate => "identityPlate".into(), + Kind::RatingStars => "ratingStars".into(), + Kind::Text(i) => format!("text{i}"), + Kind::Watermark => "watermark".into(), + } + } +} + +/// The overlays of one photo's slide: the settings, its texts expanded, its rating. +#[derive(Clone, Debug, PartialEq)] +pub struct SlideOverlays { + pub settings: OverlaySettings, + /// One per text overlay, tokens replaced. + pub texts: Vec, + pub rating: u8, +} + +/// Gap between stars, as a share of a star's size. +const STAR_GAP: f64 = 0.2; + +impl SlideOverlays { + /// `settings` resolved for `photo` (`None`: no photo, so no tokens and no rating). + pub fn resolve(settings: &OverlaySettings, photo: Option<&Photo>) -> SlideOverlays { + let settings = settings.clone().sanitized(); + let texts = settings.texts.iter().map(|t| photo.map(|p| expand(&t.text, p)).unwrap_or_else(|| t.text.clone())).collect(); + SlideOverlays { settings, texts, rating: photo.map_or(0, |p| p.rating.min(5)) } + } + + /// What makes the drawn overlays differ (for render keys). + pub fn key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.settings.pixel_key().hash(&mut h); + self.texts.hash(&mut h); + self.rating.hash(&mut h); + h.finish() + } + + fn short(l: &Layout) -> f64 { + l.slide.width().min(l.slide.height()).max(1.0) + } + + /// The identity plate's text layout, or its image scaled to its size. + fn plate(&self, l: &Layout) -> Option { + let p = &self.settings.identity_plate; + if !p.enabled || p.opacity <= 0.0 { + return None; + } + let short = Self::short(l); + let px = (p.size * short).max(1.0); + if !p.image.trim().is_empty() { + let img = crate::export::watermark_logo(p.image.trim())?; + if img.width == 0 || img.height == 0 { + return None; + } + let h = px.round().clamp(1.0, l.slide.height().max(1.0)); + let w = (h * img.width as f64 / img.height as f64).round().clamp(1.0, l.slide.width().max(1.0)); + return Some(PlateContent::Image(img, w as usize, h as usize)); + } + let text = p.text.trim(); + crate::text::TextLayout::new(text, px as f32, false, crate::fonts::CRAFT_FONTS).map(PlateContent::Text) + } + + fn text_layout(&self, i: usize, l: &Layout) -> Option { + let t = self.settings.texts.get(i)?; + let text = self.texts.get(i)?.trim(); + if t.opacity <= 0.0 { + return None; + } + crate::text::TextLayout::new(text, (t.size * Self::short(l)).max(1.0) as f32, false, crate::fonts::CRAFT_FONTS) + } + + fn stars_size(&self, l: &Layout) -> Option<(usize, f64)> { + let r = &self.settings.rating_stars; + let n = usize::from(self.rating.min(5)); + (r.enabled && n > 0).then(|| (n, (r.size * Self::short(l)).max(2.0))) + } + + /// Where each overlay goes on the slide `l` (the ones that draw something). + pub fn boxes(&self, l: &Layout) -> Vec<(Kind, Rect)> { + let mut out = Vec::new(); + let s = &self.settings; + if let Some(c) = self.plate(l) { + let (w, h) = c.size(); + out.push((Kind::IdentityPlate, s.identity_plate.place.place(l, w, h))); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + out.push((Kind::RatingStars, s.rating_stars.place.place(l, w, size))); + } + for (i, t) in s.texts.iter().enumerate() { + if let Some(tl) = self.text_layout(i, l) { + out.push((Kind::Text(i), t.place.place(l, tl.width as f64, tl.height as f64))); + } + } + if s.show_watermark && (!s.watermark.text.trim().is_empty() || !s.watermark.image.trim().is_empty()) { + out.push((Kind::Watermark, l.visible)); + } + out + } + + /// Draw the overlays drawn behind the photo (an identity plate with "render behind image"). + pub fn draw_behind(&self, img: &mut Rgba8, l: &Layout) { + if self.settings.identity_plate.behind { + self.draw_plate(img, l); + } + } + + /// Draw the overlays over the composed slide: the watermark on the photo, then the identity + /// plate (unless it is behind), the rating stars and the text overlays. + pub fn draw_front(&self, img: &mut Rgba8, l: &Layout) { + let s = &self.settings; + if s.show_watermark { + let (x0, y0, x1, y1) = pixels(&l.visible, img.width, img.height); + if x1 > x0 && y1 > y0 { + crate::export::draw_watermark_in(img, &s.watermark, x0, y0, x1 - x0, y1 - y0); + } + } + if !s.identity_plate.behind { + self.draw_plate(img, l); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + let b = s.rating_stars.place.place(l, w, size); + for i in 0..n { + let x = b.x0 + i as f64 * size * (1.0 + STAR_GAP); + star(img, x, b.y0, size, s.rating_stars.color); + } + } + for (i, t) in s.texts.iter().enumerate() { + let Some(tl) = self.text_layout(i, l) else { continue }; + let b = t.place.place(l, tl.width as f64, tl.height as f64); + let short = Self::short(l); + if t.shadow.on && t.shadow.opacity > 0.0 { + let (d, a) = (t.shadow.offset * short, t.shadow.angle.to_radians()); + let (dx, dy) = (d * a.cos(), -d * a.sin()); + shadow(img, &tl, b.x0 + dx, b.y0 + dy, t.shadow.radius * short / 2.0, (t.shadow.opacity * t.opacity) as f32); + } + draw_text(img, &tl, b.x0, b.y0, t.color, t.opacity as f32); + } + } + + fn draw_plate(&self, img: &mut Rgba8, l: &Layout) { + let p = &self.settings.identity_plate; + let Some(c) = self.plate(l) else { return }; + let (w, h) = c.size(); + let b = p.place.place(l, w, h); + match c { + PlateContent::Text(tl) => draw_text(img, &tl, b.x0, b.y0, p.color, p.opacity as f32), + PlateContent::Image(src, w, h) => { + if let Some(scaled) = scaled_graphic(p.image.trim(), &src, w, h) { + draw_graphic(img, &scaled, b.x0.round() as i64, b.y0.round() as i64, p.opacity as f32); + } + } + } + } +} + +enum PlateContent { + Text(crate::text::TextLayout), + Image(Arc, usize, usize), +} + +impl PlateContent { + fn size(&self) -> (f64, f64) { + match self { + PlateContent::Text(t) => (t.width as f64, t.height as f64), + PlateContent::Image(_, w, h) => (*w as f64, *h as f64), + } + } +} + +/// Whole pixel bounds of `r` clipped to `w × h`. +fn pixels(r: &Rect, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(r.x0, w), c(r.y0, h)); + (x0, y0, c(r.x1, w).max(x0), c(r.y1, h).max(y0)) +} + +/// Blend `c` into pixel (`x`, `y`) at `k` (0–1); outside the image does nothing. +fn blend(img: &mut Rgba8, x: i64, y: i64, k: f32, c: [u8; 3]) { + if x < 0 || y < 0 || k <= 0.0 { + return; + } + let (x, y) = (x as usize, y as usize); + if x >= img.width || y >= img.height { + return; + } + let w = img.width; + if let Some(p) = img.data.get_mut(y * w + x) { + let k = k.min(1.0); + for i in 0..3 { + p[i] = (p[i] as f32 + (c[i] as f32 - p[i] as f32) * k).round().clamp(0.0, 255.0) as u8; + } + } +} + +fn draw_text(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, c: [u8; 3], opacity: f32) { + let (w, h) = (img.width as f32, img.height as f32); + tl.draw_in(x as f32, y as f32, w, h, |gx, gy, cov| blend(img, i64::from(gx), i64::from(gy), cov * opacity, c)); +} + +/// A soft black shadow of the text at (`x`, `y`): its coverage blurred by `sigma` pixels. Only +/// the part that can reach the image is rasterised. +fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: f64, opacity: f32) { + let pad = (sigma * 3.0).ceil().clamp(0.0, 512.0); + let (iw, ih) = (img.width as f64, img.height as f64); + let mx0 = (x - pad).floor().max(-pad); + let my0 = (y - pad).floor().max(-pad); + let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); + let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); + if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() { + return; + } + let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); + let mut mask = Plane::new(w, h); + tl.draw_in((x - mx0) as f32, (y - my0) as f32, w as f32, h as f32, |gx, gy, cov| { + if gx >= 0 + && gy >= 0 + && (gx as usize) < w + && (gy as usize) < h + && let Some(m) = mask.data.get_mut(gy as usize * w + gx as usize) + { + *m = (*m + cov).min(1.0); + } + }); + let mask = if sigma > 0.3 { lightcraft_raster::blur::gaussian(&mask, sigma as f32) } else { mask }; + let (ox, oy) = (mx0 as i64, my0 as i64); + for (i, k) in mask.data.iter().enumerate() { + if *k > 1e-3 { + blend(img, ox + (i % w) as i64, oy + (i / w) as i64, k * opacity, [0, 0, 0]); + } + } +} + +/// The identity plate image scaled to its size, premultiplied: (file, its decoded image, w, h) → +/// pixels, so the slides of a slideshow don't each scale a large logo again. +type ScaledCache = std::sync::Mutex>)>>; +static SCALED: ScaledCache = std::sync::Mutex::new(None); + +/// `src` (decoded from `path`) scaled to `w × h` (premultiplied, so transparent edges stay clean). +fn scaled_graphic(path: &str, src: &Arc, w: usize, h: usize) -> Option>> { + if src.width == 0 || src.height == 0 || w == 0 || h == 0 || src.data.len() != src.width * src.height { + return None; + } + let id = Arc::as_ptr(src) as usize; + let mut cache = SCALED.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((p, i, cw, ch, img)) = cache.as_ref() + && (p.as_str(), *i, *cw, *ch) == (path, id, w, h) + { + return Some(img.clone()); + } + let pre: Image<[f32; 4]> = Image::from_fn(src.width, src.height, |x, y| { + let p = src.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = Arc::new(lightcraft_raster::resample::resize(&pre, w, h, lightcraft_raster::resample::Filter::Mitchell)); + *cache = Some((path.to_string(), id, w, h, scaled.clone())); + Some(scaled) +} + +/// The scaled graphic at (`x`, `y`), blended with its alpha × `opacity`. +fn draw_graphic(img: &mut Rgba8, scaled: &Image<[f32; 4]>, x: i64, y: i64, opacity: f32) { + for yy in 0..scaled.height { + for xx in 0..scaled.width { + let [r, g, b, a] = scaled.get(xx, yy); + let a = a.clamp(0.0, 1.0); + if a <= 1e-4 { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend(img, x + xx as i64, y + yy as i64, a * opacity, c); + } + } +} + +/// The ten points of a five-pointed star in a `size` box at (`x`, `y`), point up. +fn star_points(x: f64, y: f64, size: f64) -> [(f64, f64); 10] { + let r = size / 2.0; + // the star's height is r (1 + cos 36°); centre it vertically in the box + let cy = y + size / 2.0 + r * (1.0 - (36f64).to_radians().cos()) / 2.0; + let cx = x + size / 2.0; + let inner = r * 0.382; + let mut pts = [(0.0, 0.0); 10]; + for (i, p) in pts.iter_mut().enumerate() { + let a = (90.0 + 36.0 * i as f64).to_radians(); + let rr = if i % 2 == 0 { r } else { inner }; + *p = (cx + rr * a.cos(), cy - rr * a.sin()); + } + pts +} + +/// A filled star: even-odd scanlines, four a pixel row, with exact horizontal coverage. +fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) { + if !(x.is_finite() && y.is_finite() && size.is_finite() && size > 0.0) { + return; + } + let pts = star_points(x, y, size); + let (x0, y0) = (x.floor() as i64, y.floor() as i64); + let n = size.ceil() as usize + 2; + let mut row = vec![0f32; n]; + let mut xs: Vec = Vec::with_capacity(pts.len()); + for py in y0..y0 + n as i64 { + row.fill(0.0); + for s in 0..4 { + let sy = py as f64 + (s as f64 + 0.5) / 4.0; + xs.clear(); + for (i, &(xi, yi)) in pts.iter().enumerate() { + let Some(&(xj, yj)) = pts.get((i + pts.len() - 1) % pts.len()) else { continue }; + if (yi > sy) != (yj > sy) { + xs.push((xj - xi) * (sy - yi) / (yj - yi) + xi); + } + } + xs.sort_by(f64::total_cmp); + for &[a, b] in xs.as_chunks::<2>().0 { + let (a, b) = (a - x0 as f64, b - x0 as f64); + let first = a.floor().max(0.0) as usize; + let last = (b.ceil().max(0.0) as usize).min(n); + for (i, v) in row.iter_mut().enumerate().take(last).skip(first) { + let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)).max(0.0); + *v += cover as f32 / 4.0; + } + } + } + for (i, k) in row.iter().enumerate() { + if *k > 0.0 { + blend(img, x0 + i as i64, py, k.min(1.0), c); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(w: usize, h: usize) -> Layout { + lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) + } + + fn photo() -> Photo { + let mut p = crate::rename::sample_photo(); + p.rating = 3; + p.meta.caption = "Morning fog".into(); + p.meta.shutter = "1/250".into(); + p.meta.aperture = Some(8.0); + p.meta.focal_mm = Some(50.0); + p + } + + #[test] + fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { + let p = photo(); + assert_eq!(expand("{caption}", &p), "Morning fog"); + assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); + assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); + assert_eq!(expand("{file}", &p), p.file_name); + assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); + assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); + assert_eq!(expand("{rating}", &p), "3"); + // unknown and slide-less tags show as typed, and are reported + assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); + assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); + // a photo without metadata: empty values, no panic + let mut bare = p.clone(); + bare.meta = Default::default(); + assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); + } + + #[test] + fn nine_anchors_place_inside_the_slide_with_their_offsets() { + let l = layout(1000, 600); + let short = 600.0; + for anchor in ANCHORS { + for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { + let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); + let b = at(0.0, 0.0); + assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); + assert_eq!((b.width(), b.height()), (100.0, 40.0)); + let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + let id = anchor_id(anchor); + let want_x = if id.ends_with("eft") { + c.x0 + 50.0 + } else if id.ends_with("ight") { + c.x1 - 50.0 + } else { + (c.x0 + c.x1) / 2.0 + }; + let want_y = if id.starts_with("top") { + c.y0 + 20.0 + } else if id.starts_with("bottom") { + c.y1 - 20.0 + } else { + (c.y0 + c.y1) / 2.0 + }; + assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); + // an inward offset moves it by that share of the shorter side + let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); + let m = at(sx, sy); + assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); + } + } + // pushed off the slide: kept on it + let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); + assert_eq!((b.x1, b.y1), (1000.0, 600.0)); + } + + fn blank(w: usize, h: usize) -> Rgba8 { + Image::filled(w, h, [0, 0, 0, 255]) + } + + fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { + let (x0, y0, x1, y1) = pixels(r, img.width, img.height); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() + } + + #[test] + fn rating_stars_count_matches_the_rating() { + let l = layout(800, 500); + let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); + let mut p = photo(); + let mut widths = Vec::new(); + for rating in 0..=5u8 { + p.rating = rating; + let o = SlideOverlays::resolve(&s, Some(&p)); + let boxes = o.boxes(&l); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { + assert_eq!(rating, 0, "no stars only for an unrated photo"); + assert_eq!(lit(&img, &l.slide, 1), 0); + continue; + }; + widths.push(b.width()); + // count the stars: separate runs of lit columns along the box's middle row + let y = ((b.y0 + b.y1) / 2.0) as usize; + let mut runs = 0; + let mut was = false; + for x in 0..800 { + let on = img.get(x, y)[1] > 128; + runs += usize::from(on && !was); + was = on; + } + assert_eq!(runs, usize::from(rating), "{rating} stars"); + } + assert!(widths.windows(2).all(|w| w[1] > w[0])); + } + + #[test] + fn text_with_a_shadow_draws_the_shadow_at_its_angle() { + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged( + &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), + ) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let b = o.boxes(&l)[0].1; + let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); + o.draw_front(&mut grey, &l); + // straight down by 10 px: dark pixels below the text box's glyphs, none above + let dark = |r: Rect| { + let (x0, y0, x1, y1) = pixels(&r, 800, 500); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() + }; + assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); + assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); + // without the shadow: no dark pixels at all + let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); + let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); + SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); + assert!(grey2.data.iter().all(|p| p[0] >= 128)); + } + + #[test] + fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { + let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.png"); + let red = Image::filled(40, 20, [255u8, 0, 0, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(&path, png).unwrap(); + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) + .unwrap(); + assert!(s.warnings().is_empty()); + let o = SlideOverlays::resolve(&s, None); + let b = o.boxes(&l)[0].1; + assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let p = img.get(50, 25); + assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); + assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); + // behind the image: the photo covers it + let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); + let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); + let ob = SlideOverlays::resolve(&behind, None); + let (img, l2) = + lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); + let _ = l2; + assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); + // a missing file: reported, nothing drawn, no panic + let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); + assert_eq!(gone.warnings().len(), 1); + let mut img = blank(800, 500); + SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); + assert!(img.data.iter().all(|p| p[0] == 0)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn overlays_are_resolution_independent() { + let s = OverlaySettings::default() + .merged(&json!({ + "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, + "ratingStars": {"enabled": true, "size": 0.04}, + "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], + })) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); + assert_eq!(a.len(), 3); + for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { + assert_eq!(ka, kb); + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); + } + } + } + + #[test] + fn hostile_settings_are_refused_or_clamped_and_never_panic() { + let d = OverlaySettings::default(); + for bad in [ + json!({"identityPlate": {"size": "big"}}), + json!({"identityPlate": {"anchor": "middle"}}), + json!({"identityPlate": 3}), + json!({"ratingStars": {"color": "#zzzzzz"}}), + json!({"texts": {"text": "a"}}), + json!({"texts": [{"relativeTo": "page"}]}), + json!({"texts": [{"sparkle": 1}]}), + json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), + json!({"removeText": 0}), + json!({"removeText": -1}), + json!({"watermark": {"size": 9}}), + json!({"watermark": 5}), + json!({"showWatermark": "yes"}), + json!({"glitter": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) + .unwrap(); + assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); + assert_eq!(s.rating_stars.size, SIZE_MIN); + assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); + assert!(d.merged(&json!({"removeText": 0})).is_err()); + assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); + // round trips (command JSON and prefs.json) + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + // NaN / ∞ from a hand-edited prefs file + let mut wild = s.clone(); + wild.identity_plate.size = f64::NAN; + wild.texts[0].place.offset_y = f64::INFINITY; + wild.watermark.size = f32::NAN; + let w = wild.sanitized(); + assert_eq!( + (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), + (IdentityPlate::default().size, 0.0, Watermark::default().size) + ); + // drawing hostile text at every size: empty, huge, CJK, RTL, control characters + let huge = "W".repeat(50_000); + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] + { + let s = d + .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) + .unwrap(); + assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); + let mut bare = photo(); + bare.meta = Default::default(); + for (w, h) in [(1, 1), (17, 9), (320, 200)] { + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); + let o = SlideOverlays::resolve(&s, Some(&bare)); + let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); + o.draw_behind(&mut img, &l); + o.draw_front(&mut img, &l); + for (_, b) in o.boxes(&l) { + assert!(b.x0.is_finite() && b.y1.is_finite()); + } + } + } + } + + /// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. + #[test] + fn huge_overlays_on_a_big_slide_stay_cheap() { + let long = "Wide ".repeat(400); + let s = OverlaySettings::default() + .merged(&json!({ + "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, + "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, + })) + .unwrap(); + let mut p = photo(); + p.rating = 5; + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); + let mut img = blank(4000, 2500); + let t = std::time::Instant::now(); + SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); + assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); + assert!(img.get(100, 300)[0] > 0, "the text's start shows"); + } + + /// CJK text draws with the craft-fonts faces (skips without them). + #[test] + fn cjk_text_uses_the_craft_fonts() { + if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); + let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); + let count = |t: &crate::text::TextLayout| { + let mut n = 0usize; + t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); + n + }; + assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); + } +} diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests_slide.rs index 84a8b5274..3e41f16ee 100644 --- a/crates/engine/src/tests_slide.rs +++ b/crates/engine/src/tests_slide.rs @@ -137,3 +137,93 @@ fn a_background_image_and_the_slide_key() { let img = s.slide_now(&gone, photos[0], 320, 200).unwrap(); assert_eq!(img.get(3, 3), [255, 0, 0, 255]); } + +#[test] +fn slide_overlays_are_validated_and_persist_with_the_library() { + let lib = temp_dir("overlays"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.overlays", &json!({})).unwrap(); + assert_eq!(r["identityPlate"]["enabled"], false, "defaults: {r}"); + assert_eq!(r["texts"], json!([])); + assert!(s.slides_plain()); + let r = s + .execute( + "slideshow.overlays", + &json!({"identityPlate": {"enabled": true, "text": "Studio Nord"}, "ratingStars": {"enabled": true}, "addText": {"text": "{caption} {bogus}"}, "showWatermark": true, "watermark": "© Ann"}), + ) + .unwrap(); + assert_eq!(r["texts"].as_array().map(Vec::len), Some(1)); + assert_eq!(r["watermark"]["text"], "© Ann"); + assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1), "the unknown token is reported: {r}"); + assert!(!s.slides_plain(), "overlays make playback compose its slides"); + // a bad value changes nothing (the good keys beside it included) + for bad in + [json!({"identityPlate": {"size": "big"}, "showWatermark": false}), json!({"texts": [{"anchor": "nowhere"}]}), json!({"removeText": 5})] + { + assert!(s.execute("slideshow.overlays", &bad).is_err(), "{bad}"); + } + assert!(s.slide_overlays.show_watermark); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.overlays", &json!({})).unwrap(); + assert_eq!((r["identityPlate"]["text"].as_str(), r["texts"][0]["text"].as_str()), (Some("Studio Nord"), Some("{caption} {bogus}"))); + // the layout beside it is untouched by an overlay change + let before = s.slide_layout.clone(); + s.execute("slideshow.overlays", &json!({"removeText": 0})).unwrap(); + assert_eq!(s.slide_layout, before); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideOverlays": {"identityPlate": {"size": 1e9, "opacity": -2}, "texts": [{"size": 1e300}]}}"#) + .unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!((s.slide_overlays.identity_plate.size, s.slide_overlays.identity_plate.opacity), (crate::slide_overlay::PLATE_SIZE_MAX, 0.0)); + assert_eq!(s.slide_overlays.texts[0].size, crate::slide_overlay::TEXT_SIZE_MAX); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_draws_the_overlays() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 4, "ids": [photos[0].0]})).unwrap(); + s.execute("slideshow.layout", &json!({"margins": 0.2, "backdropColor": "#000000"})).unwrap(); + let before = s.slide_job(photos[0], 320, 200, None).unwrap().key; + let plain = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap(); + s.execute( + "slideshow.overlays", + &json!({ + "identityPlate": {"enabled": true, "text": "LC", "size": 0.1, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.01, "offsetY": 0.01}, + "ratingStars": {"enabled": true, "color": "#ffff00", "size": 0.05, "anchor": "bottomRight", "offsetX": -0.01, "offsetY": -0.01, "relativeTo": "slide"}, + "addText": {"text": "{file}", "size": 0.05, "color": "#ffffff", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.01}, + }), + ) + .unwrap(); + assert_ne!(s.slide_job(photos[0], 320, 200, None).unwrap().key, before, "overlays change the slide's render key"); + let r = s.execute("slideshow.preview", &json!({"width": 640, "height": 400})).unwrap(); + let kinds: Vec<&str> = r["overlays"].as_array().unwrap().iter().filter_map(|o| o["kind"].as_str()).collect(); + assert_eq!(kinds, ["identityPlate", "ratingStars", "text0"]); + let img = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap(); + let count = |img: &lightcraft_raster::Rgba8, b: &serde_json::Value, want: fn([u8; 4]) -> bool| { + let v: Vec = (0..4).map(|i| b[i].as_f64().unwrap() as usize).collect(); + (v[1]..v[3]).flat_map(|y| (v[0]..v[2]).map(move |x| (x, y))).filter(|&(x, y)| want(img.get(x, y))).count() + }; + for (i, (kind, want)) in [ + ("identityPlate", (|p: [u8; 4]| p[1] > 200 && p[0] < 60) as fn([u8; 4]) -> bool), + ("ratingStars", |p| p[0] > 200 && p[1] > 200 && p[2] < 60), + ("text0", |p| p[0] > 200 && p[1] > 200 && p[2] > 200), + ] + .into_iter() + .enumerate() + { + let b = &r["overlays"][i]["box"]; + assert!(count(&img, b, want) > 10, "{kind} is drawn in its box {b}"); + assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays"); + } +} diff --git a/crates/engine/src/text.rs b/crates/engine/src/text.rs new file mode 100644 index 000000000..4c22789b1 --- /dev/null +++ b/crates/engine/src/text.rs @@ -0,0 +1,207 @@ +//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter +//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines +//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out +//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor +//! it before drawing; drawing hands out per-pixel coverage, and the caller blends. +//! +//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers +//! draw as Inter's missing-glyph box: wrong-looking, never a crash. + +use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point}; + +/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). +pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); + +/// Most characters one layout draws (longer text is cut there), so hostile text stays cheap. +pub const MAX_CHARS: usize = 4000; + +/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's +/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is +/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's +/// missing-glyph box. +pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec> { + let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); + cjk.sort_by_key(|f| !f.is_mincho()); + std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() +} + +/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. +/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. +pub fn cell_glyphs(font: &FontRef<'_>, data: &harfrust::ShaperData, text: &str, vertical: bool, px: f32, left: f32, top: f32) -> Option> { + let face = harfrust::FontRef::new(font.font_data()).ok()?; + let mut buf = harfrust::UnicodeBuffer::new(); + buf.push_str(text); + buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); + buf.guess_segment_properties(); + let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); + if shaped.glyph_infos().is_empty() { + return None; + } + let sf = font.as_scaled(px); + let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); + let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); + let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); + let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; + let mut glyphs = Vec::new(); + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; + let pen = point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); + glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); + x += pos.x_advance as f32 * sx; + y -= pos.y_advance as f32 * sy; + } + Some(glyphs) +} + +/// A string laid out at one size: its glyphs relative to the box's top-left corner and the box's +/// size in pixels (lines are `px` apart; vertical text: columns `px` wide, right to left). +pub struct TextLayout { + fonts: Vec>, + /// (face, glyph, rotation centre for glyphs turned 90° in vertical text). + glyphs: Vec<(usize, Glyph, Option<(f32, f32)>)>, + pub width: f32, + pub height: f32, +} + +impl TextLayout { + /// `text` (cut at [`MAX_CHARS`]) at `px` pixels a line; `None` when there is nothing to draw + /// (empty text, a non-finite or non-positive size). + pub fn new(text: &str, px: f32, vertical: bool, craft: &'static [crate::fonts::CraftFont]) -> Option { + use unicode_segmentation::UnicodeSegmentation; + if text.is_empty() || !px.is_finite() || px <= 0.0 { + return None; + } + let text = match text.char_indices().nth(MAX_CHARS) { + Some((i, _)) => text.get(..i).unwrap_or(text), + None => text, + }; + let fonts = faces(craft); + let latin = fonts.first()?.clone(); + let vertical_data: Vec<_> = if vertical { + fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() + } else { + Vec::new() + }; + let mut glyphs = Vec::new(); + let mut x = 0.0f32; + let mut y = 0.0f32; + let mut tw = 0.0f32; + let mut prev = None; + let columns = text.split('\n').count(); + let mut column = 0usize; + // Horizontal text keeps scalar layout and kerning; only vertical cells use graphemes. + let cells: Box> = + if vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; + for cell in cells { + let Some(original) = cell.chars().next() else { continue }; + if cell == "\n" || (vertical && cell == "\r\n") { + if vertical { + column = column.saturating_add(1); + tw = tw.max(y); + y = 0.0; + } else { + tw = tw.max(x); + x = 0.0; + y += px; + } + prev = None; + continue; + } + if original.is_control() { + continue; + } + let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); + if vertical && (cjk || cell.chars().count() > 1) { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { + let data = data.as_ref()?; + let cluster = cell_glyphs(font, data, cell, cjk, px, left, y)?; + let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); + if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') + { + return None; + } + Some((face, cluster)) + }); + if let Some((face, cluster)) = shaped { + glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); + y += px; + continue; + } + } + let vertical_form = match original { + '、' => '︑', + '。' => '︒', + '(' => '︵', + ')' => '︶', + '「' => '﹁', + '」' => '﹂', + '『' => '﹃', + '』' => '﹄', + '【' => '︻', + '】' => '︼', + '…' => '︙', + other => other, + }; + let has = |font: &FontRef<'_>, c: char| font.glyph_id(c).0 != 0; + let ch = if vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; + // The first face with the glyph; Inter (its missing-glyph box) when none has it. + let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); + let sf = fonts.get(face).unwrap_or(&latin).as_scaled(PxScale::from(px)); + let id = sf.glyph_id(ch); + if vertical { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); + let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); + glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); + y += px; + } else { + if let Some((previous_face, previous)) = prev + && previous_face == face + { + x += sf.kern(previous, id); + } + glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); + x += sf.h_advance(id); + prev = Some((face, id)); + } + } + let (width, height) = if vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + Some(TextLayout { fonts, glyphs, width, height }) + } + + /// Call `f(x, y, coverage)` for every pixel the text covers with its top-left corner at + /// (`ox`, `oy`); coordinates may be negative or past the image (the caller clips). + pub fn draw(&self, ox: f32, oy: f32, f: impl FnMut(i32, i32, f32)) { + self.draw_in(ox, oy, f32::INFINITY, f32::INFINITY, f); + } + + /// [`Self::draw`], skipping the glyphs that fall wholly outside `0..clip_w × 0..clip_h` + /// (so huge text costs what shows, not what is laid out). + pub fn draw_in(&self, ox: f32, oy: f32, clip_w: f32, clip_h: f32, mut f: impl FnMut(i32, i32, f32)) { + for (face, g, rotation) in &self.glyphs { + if rotation.is_none() { + // a glyph lies within one line height below its pen's top and an em or so after it + let (px, py) = (ox + g.position.x, oy + g.position.y); + let em = g.scale.y.max(g.scale.x); + if px + 2.0 * em < 0.0 || px - em > clip_w || py + em < 0.0 || py - 2.0 * em > clip_h { + continue; + } + } + if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { + let b = o.px_bounds(); + if rotation.is_none() && (ox + b.max.x < 0.0 || ox + b.min.x > clip_w || oy + b.max.y < 0.0 || oy + b.min.y > clip_h) { + continue; + } + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + f((ox + sx) as i32, (oy + sy) as i32, cov); + }); + } + } + } +} diff --git a/crates/slideshow/src/compose.rs b/crates/slideshow/src/compose.rs index 998e591c9..61ce91f3f 100644 --- a/crates/slideshow/src/compose.rs +++ b/crates/slideshow/src/compose.rs @@ -204,6 +204,20 @@ fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec { /// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels /// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone. pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 { + compose_with(photo, backdrop_image, s, out_w, out_h, &mut |_, _| {}).0 +} + +/// [`compose`], calling `behind(slide, layout)` once the backdrop is drawn and before the shadow, +/// the stroke and the photo (so it draws behind the photo: a slideshow identity plate with +/// "render behind image"), and returning the layout it drew with (for overlays drawn on top). +pub fn compose_with( + photo: &Rgba8, + backdrop_image: Option<&Rgba8>, + s: &SlideSettings, + out_w: usize, + out_h: usize, + behind: &mut dyn FnMut(&mut Rgba8, &Layout), +) -> (Rgba8, Layout) { let s = s.clone().sanitized(); let (w, h) = slide_size(out_w, out_h); let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height; @@ -237,6 +251,7 @@ pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, } }); } + behind(&mut out, &l); // cast shadow: a blurred rectangle (separable: the product of two blurred steps) if let Some((r, sigma)) = l.shadow { let op = s.shadow_opacity as f32; @@ -261,7 +276,7 @@ pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, if has_photo { draw_image(&mut out, photo, &l.photo, &l.cell, 1.0); } - out + (out, l) } #[cfg(test)] @@ -459,6 +474,19 @@ mod tests { } } + #[test] + fn behind_draws_under_the_photo_and_the_layout_comes_back() { + let s = with(json!({"margins": 0.2})); + let mut seen = None; + let (img, l) = compose_with(&solid(10, 10, GREEN), None, &s, 200, 100, &mut |out, l| { + seen = Some(*l); + out.data.fill([255, 0, 0, 255]); + }); + assert_eq!(seen, Some(l), "the hook gets the layout the slide is drawn with"); + assert_eq!(px(&img, 2, 2), RED, "the hook's paint stays on the backdrop"); + assert_eq!(px(&img, 100, 50), GREEN, "and the photo covers it"); + } + #[test] fn hostile_input_never_panics() { let wild = SlideSettings { diff --git a/crates/slideshow/src/lib.rs b/crates/slideshow/src/lib.rs index 4d61716a5..5827491d9 100644 --- a/crates/slideshow/src/lib.rs +++ b/crates/slideshow/src/lib.rs @@ -19,5 +19,5 @@ mod compose; mod settings; -pub use compose::{Layout, Rect, compose, layout, photo_size}; +pub use compose::{Layout, Rect, compose, compose_with, layout, photo_size}; pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color}; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index fa4855fe3..d37a5e23a 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2057,5 +2057,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.", "Reset layout": "Layout zurücksetzen", "Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", + "Overlays": "Überlagerungen", + "Identity plate": "Erkennungstafel", + "Your name or studio": "Ihr Name oder Studio", + "Anchored to": "Verankert an", + "Slide": "Folie", + "Render behind image": "Hinter dem Bild rendern", + "Rating stars": "Sternebewertung", + "Text overlays": "Textüberlagerungen", + "Add text overlay": "Textüberlagerung hinzufügen", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Anderer Text erscheint wie eingegeben.", + "Use export watermark": "Export-Wasserzeichen verwenden", + "The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 4ab5578fe..3f592a18e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.", "Reset layout": "Restablecer diseño", "Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa.", + "Overlays": "Superposiciones", + "Identity plate": "Placa de identidad", + "Your name or studio": "Tu nombre o estudio", + "Anchored to": "Anclado a", + "Slide": "Diapositiva", + "Render behind image": "Representar detrás de la imagen", + "Rating stars": "Estrellas de valoración", + "Text overlays": "Superposiciones de texto", + "Add text overlay": "Añadir superposición de texto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. El resto del texto se muestra tal cual.", + "Use export watermark": "Usar la marca de agua de exportación", + "The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index e456530b5..5f1a0d596 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.", "Reset layout": "Réinitialiser la disposition", "Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu.", + "Overlays": "Incrustations", + "Identity plate": "Plaque d'identification", + "Your name or studio": "Votre nom ou studio", + "Anchored to": "Ancré à", + "Slide": "Diapositive", + "Render behind image": "Afficher derrière l'image", + "Rating stars": "Étoiles de notation", + "Text overlays": "Incrustations de texte", + "Add text overlay": "Ajouter une incrustation de texte", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Jetons : {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Le reste du texte s'affiche tel quel.", + "Use export watermark": "Utiliser le filigrane d'exportation", + "The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a8da442e4..74ef29b77 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1904,5 +1904,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。", "Reset layout": "レイアウトをリセット", "Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。" + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。", + "Overlays": "オーバーレイ", + "Identity plate": "ID プレート", + "Your name or studio": "あなたの名前またはスタジオ名", + "Anchored to": "基準", + "Slide": "スライド", + "Render behind image": "画像の背後に表示", + "Rating stars": "レーティングの星", + "Text overlays": "テキストオーバーレイ", + "Add text overlay": "テキストオーバーレイを追加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "トークン: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。その他の文字は入力どおりに表示されます。", + "Use export watermark": "書き出しの透かしを使用", + "The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e811a1be1..4653d1055 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.", "Reset layout": "Redefinir layout", "Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização.", + "Overlays": "Sobreposições", + "Identity plate": "Placa de identidade", + "Your name or studio": "Seu nome ou estúdio", + "Anchored to": "Ancorado em", + "Slide": "Slide", + "Render behind image": "Renderizar atrás da imagem", + "Rating stars": "Estrelas de classificação", + "Text overlays": "Sobreposições de texto", + "Add text overlay": "Adicionar sobreposição de texto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. O restante do texto aparece como digitado.", + "Use export watermark": "Usar a marca d'água de exportação", + "The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2685f8609..44cd11cb7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1616,5 +1616,18 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.", "Reset layout": "Сбросить макет", "Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр.", + "Overlays": "Наложения", + "Identity plate": "Фирменная табличка", + "Your name or studio": "Ваше имя или студия", + "Anchored to": "Привязка к", + "Slide": "Слайд", + "Render behind image": "Рисовать за изображением", + "Rating stars": "Звёзды рейтинга", + "Text overlays": "Текстовые наложения", + "Add text overlay": "Добавить текстовое наложение", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токены: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Остальной текст выводится как написан.", + "Use export watermark": "Использовать водяной знак экспорта", + "The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него.", + "© Your Name": "© Ваше имя" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index e3ddc10ae..a279f5c3c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2096,5 +2096,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.", "Reset layout": "Скинути макет", "Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд." + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд.", + "Overlays": "Накладання", + "Identity plate": "Фірмова табличка", + "Your name or studio": "Ваше ім'я або студія", + "Anchored to": "Прив'язка до", + "Slide": "Слайд", + "Render behind image": "Малювати за зображенням", + "Rating stars": "Зірки рейтингу", + "Text overlays": "Текстові накладання", + "Add text overlay": "Додати текстове накладання", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токени: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Решта тексту виводиться як написано.", + "Use export watermark": "Використати водяний знак експорту", + "The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 065494810..cb3b27456 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。", "Reset layout": "重置布局", "Select a photo to preview its slide": "选择一张照片以预览其幻灯片", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。" + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。", + "Overlays": "叠加", + "Identity plate": "身份标识", + "Your name or studio": "你的名字或工作室", + "Anchored to": "锚定到", + "Slide": "幻灯片", + "Render behind image": "在图像后面渲染", + "Rating stars": "评级星标", + "Text overlays": "文本叠加", + "Add text overlay": "添加文本叠加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "标记:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原样显示。", + "Use export watermark": "使用导出水印", + "The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 27802e68e..e0ba2692e 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1902,5 +1902,17 @@ "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", "Reset layout": "重設版面", "Select a photo to preview its slide": "選取一張照片以預覽其投影片", - "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。" + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。", + "Overlays": "疊加", + "Identity plate": "身分標識", + "Your name or studio": "你的名字或工作室", + "Anchored to": "錨定到", + "Slide": "幻燈片", + "Render behind image": "在影像後方繪製", + "Rating stars": "評等星號", + "Text overlays": "文字疊加", + "Add text overlay": "新增文字疊加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "標記:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原樣顯示。", + "Use export watermark": "使用匯出浮水印", + "The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。" } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 3d40f9cc1..4acd6dedc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod rules_editor; pub mod second; pub mod settings; pub mod slide_layout; +pub mod slide_overlays; pub mod slideshow; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2be60a4a5..8a7d9e8d8 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -657,6 +657,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { playback_section(app, ui, t); ui.add_space(6.0); super::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + super::slide_overlays::sections(app, ui, t); }); }); ui.add_space(12.0); diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs index c0e03844c..2f5ef0883 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -1,5 +1,6 @@ //! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's -//! Slideshow panels of the same names) and a small live preview of the composed slide. +//! Slideshow panels of the same names) and a small live preview of the composed slide (with the +//! overlays of [`super::slide_overlays`]). //! //! Every control goes through `slideshow.layout` (validated there, saved with the library). //! Sliders change the layout in memory while dragged (the preview follows) and save it when let @@ -177,7 +178,16 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }; let ppp = ui.ctx().pixels_per_point(); let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); - let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64); + // the overlays and what they show of the photo (its rating, its metadata) change it too + let shown = app.session.catalog.photo(id).filter(|_| app.session.slide_overlays.any()).map_or(0, |p| { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + p.rating.hash(&mut h); + serde_json::to_string(&p.meta).unwrap_or_default().hash(&mut h); + h.finish() + }); + let overlays = if app.session.slide_overlays.any() { app.session.slide_overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 }; + let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlays.rotate_left(29); let now = ui.input(|i| i.time); let mem = egui::Id::new("settingsSlideshowPreviewKey"); let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); diff --git a/crates/ui-egui/src/panels/slide_overlays.rs b/crates/ui-egui/src/panels/slide_overlays.rs new file mode 100644 index 000000000..3824eeb0d --- /dev/null +++ b/crates/ui-egui/src/panels/slide_overlays.rs @@ -0,0 +1,265 @@ +//! Settings ▸ Slideshow ▸ Overlays (Lightroom Classic's Slideshow Overlays panel): the identity +//! plate (text or an image file), rating stars, text overlays with tokens and the watermark. +//! +//! Every control goes through `slideshow.overlays` (validated there, saved with the library), like +//! the Layout sections beside it ([`super::slide_layout`]); the live preview next to them shows the +//! overlays on the active photo's slide. Sliders change the settings in memory while dragged and +//! save them when let go; text fields save when the edit ends (Return or leaving the field). + +use egui::RichText; +use lightcraft_engine::export::Anchor; +use lightcraft_engine::slide_overlay::{ + ANCHORS, OFFSET_MAX, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STAR_SIZE_MAX, TEXT_SIZE_MAX, +}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +#[derive(Clone, Copy)] +enum Unit { + /// A fraction shown as a percentage (of the slide's shorter side, or an opacity). + Percent, + Degrees, +} + +fn set(app: &mut LightcraftApp, p: Value) { + let _ = app.run("slideshow.overlays", p); +} + +/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match unit { + Unit::Percent => (100.0, " %"), + Unit::Degrees => (1.0, "°"), + }; + let mut v = value * k; + let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let p = wrap(v / k); + if r.changed() + && let Ok(next) = app.session.slide_overlays.merged(&p) + { + app.session.slide_overlays = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + set(app, p); + } +} + +/// A colour button (`button:{id}`). +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: [u8; 3], wrap: &dyn Fn([u8; 3]) -> Value) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, wrap(c)); + } +} + +/// A checkbox (`check:settings.{id}`); true when it is on. +fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, label: &str, wrap: &dyn Fn(bool) -> Value) -> bool { + let mut v = value; + if check(ui, &format!("settings.{id}"), &mut v, label) { + set(app, wrap(v)); + } + v +} + +/// A one-line text field (`field:{id}`), saved when the edit ends. +fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) { + let mem = egui::Id::new(("slideshow-overlay-field", id)); + let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); + let widget = format!("field:{id}"); + let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui); + if r.editing { + ui.data_mut(|d| d.insert_temp(mem, s.clone())); + } else { + ui.data_mut(|d| d.remove::(mem)); + } + if r.committed() && s != value { + set(app, wrap(&s)); + } +} + +/// The nine anchors as a 3 × 3 grid of buttons (`button:{id}-{0..8}`); true when one is picked. +fn anchor_grid(ui: &mut egui::Ui, id: &str, value: &mut Anchor) -> bool { + const GLYPHS: [&str; 9] = ["↖", "↑", "↗", "←", "•", "→", "↙", "↓", "↘"]; + let mut changed = false; + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); + for r in 0..3 { + ui.horizontal(|ui| { + for c in 0..3 { + let i = r * 3 + c; + let (Some(a), Some(g)) = (ANCHORS.get(i), GLYPHS.get(i)) else { continue }; + if crate::widgets::text_button(ui, &format!("{id}-{i}"), g, *value == *a).clicked() && *value != *a { + *value = *a; + changed = true; + } + } + }); + } + }); + changed +} + +/// Anchor, offsets and what the overlay is anchored to; `wrap` turns placement keys into the request. +fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p: &Placement, photo_too: bool, wrap: &dyn Fn(Value) -> Value) { + row(ui, t, "Position", |ui| { + let mut a = p.anchor; + if anchor_grid(ui, &format!("{id}Anchor"), &mut a) { + set(app, wrap(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)}))); + } + }); + if photo_too { + row(ui, t, "Anchored to", |ui| { + let mut r = p.relative_to; + if choices(ui, &format!("{id}RelativeTo"), &[(RelativeTo::Slide, "Slide"), (RelativeTo::Photo, "Photo")], &mut r) { + let v = if r == RelativeTo::Photo { "photo" } else { "slide" }; + set(app, wrap(json!({"relativeTo": v}))); + } + }); + } + row(ui, t, "Offset X", |ui| { + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + }); + row(ui, t, "Offset Y", |ui| { + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + }); +} + +/// The request changing text overlay `i` of `n` by `change` (the others kept). +fn text_change(i: usize, n: usize, change: Value) -> Value { + let list: Vec = (0..n).map(|k| if k == i { change.clone() } else { json!({}) }).collect(); + json!({ "texts": list }) +} + +/// The Overlays sections. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s: OverlaySettings = app.session.slide_overlays.clone(); + heading(ui, t, "Overlays"); + + // identity plate + let p = s.identity_plate.clone(); + let plate = |v: Value| json!({ "identityPlate": v }); + if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) { + let mem = egui::Id::new("slideshow-plate-graphic"); + let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::(mem)).unwrap_or(false); + let before = graphic; + row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic)); + if graphic != before { + ui.data_mut(|d| d.insert_temp(mem, graphic)); + if !graphic && !p.image.is_empty() { + set(app, plate(json!({"image": ""}))); + } + } + if graphic { + row(ui, t, "Image", |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "slideshowPlateChoose", "Choose…", false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + set(app, plate(json!({"image": f}))); + } + text(app, ui, "slideshowPlateImage", &p.image, "logo.png", &|v| plate(json!({"image": v}))); + }); + } else { + row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); + } + row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); + toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); + } + + // rating stars + ui.add_space(4.0); + let r = s.rating_stars.clone(); + let stars = |v: Value| json!({ "ratingStars": v }); + if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); + row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + placement(app, ui, t, "slideshowStars", &r.place, true, &stars); + } + + // text overlays + ui.add_space(6.0); + ui.label(RichText::new(crate::i18n::tr("Text overlays")).font(t.semibold(11.5)).color(t.text)); + let n = s.texts.len(); + for (i, o) in s.texts.iter().enumerate() { + let id = format!("slideshowText{i}"); + let wrap = |v: Value| text_change(i, n, v); + ui.add_space(2.0); + row(ui, t, &format!("{} {}", crate::i18n::tr("Text"), i + 1), |ui| { + text(app, ui, &id, &o.text, "{title} · {exposure}", &|v| wrap(json!({"text": v}))); + if crate::widgets::text_button(ui, &format!("{id}Remove"), "Remove", false).clicked() { + set(app, json!({"removeText": i})); + } + }); + if app.session.slide_overlays.texts.len() != n { + return; // removed: the indices moved; drawn again next frame + } + row(ui, t, "Size", |ui| { + slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + }); + row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); + row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + placement(app, ui, t, &id, &o.place, true, &wrap); + let sh = o.shadow; + if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + }); + row(ui, t, "Offset", |ui| { + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + }); + row(ui, t, "Radius", |ui| { + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + }); + row(ui, t, "Angle", |ui| { + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + }); + } + } + if n < lightcraft_engine::slide_overlay::MAX_TEXTS && crate::widgets::text_button(ui, "slideshowAddText", "Add text overlay", false).clicked() { + set(app, json!({"addText": {}})); + } + hint( + ui, + t, + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.", + ); + + // watermark + ui.add_space(4.0); + let w = s.watermark.clone(); + let wm = |v: Value| json!({ "watermark": v }); + if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { + row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); + row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + }); + row(ui, t, "Position", |ui| { + let mut a = w.anchor; + if anchor_grid(ui, "slideshowWatermarkAnchor", &mut a) { + set(app, wm(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)}))); + } + }); + let export_wm = app.session.last_export.as_ref().and_then(|e| e.get("watermark")).filter(|v| v.is_object() || v.is_string()).cloned(); + if let Some(e) = export_wm + && crate::widgets::text_button(ui, "slideshowUseExportWatermark", "Use export watermark", false).clicked() + { + set(app, wm(e)); + } + hint(ui, t, "The export watermark, drawn on the photo and sized relative to it."); + } + for warning in app.session.slide_overlays.warnings() { + ui.label(RichText::new(warning).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index 13b8f0c5a..42a514d5f 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -182,7 +182,7 @@ fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture let photo = app.session.catalog.photo(id)?.clone(); let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); let native = super::detail::output_px(&frame); - if !app.session.slide_layout.is_plain() { + if !app.session.slides_plain() { let (w, h) = slide_px(area, ppp, texture_side); let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side); return app.session.slide_job(id, w, h, Some(edge)); diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 36558267b..b7f26c768 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -344,3 +344,86 @@ fn settings_shows_the_slide_layout_and_a_live_preview() { assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); h.settle(SETTLE); } + +#[test] +fn overlays_show_during_playback() { + let mut h = grid(); + assert!(h.app.session.slides_plain()); + // the default layout with an identity plate: playback composes the slide to draw it + run( + &mut h, + "slideshow.overlays", + json!({"identityPlate": {"enabled": true, "text": "LC", "size": 0.12, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.02, "offsetY": 0.02}}), + ); + assert!(!h.app.session.slides_plain()); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = play(&h).current(); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); + h.step(); + assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current)).unwrap().size, [1000, 700]); + let img = h.paint(); + // the plate's box: 14 px in from the top-left corner, 84 px high + let green = (14..98).flat_map(|y| (14..120).map(move |x| (x, y))).filter(|&(x, y)| { + let c = painted(&img, x, y); + c[1] > 200 && c[0] < 80 && c[2] < 80 + }); + assert!(green.count() > 200, "the identity plate is on the slide"); + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_overlays_and_the_preview_follows_them() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + // scroll down to the Overlays section + let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + let r = h.request("ui.scroll", json!({"dy": -800}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..10 { + h.step(); + } + } + for w in ["check:settings.slideshowPlate", "check:settings.slideshowStars", "button:slideshowAddText", "check:settings.slideshowWatermark"] { + assert!(has(&h, w), "{w}"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowAddText"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_overlays.texts.len(), 1); + h.step(); + assert!(has(&h, "field:slideshowText0"), "the text overlay's template field"); + // its rows are below the fold: scroll on down + for _ in 0..4 { + let r = h.request("ui.scroll", json!({"dy": -800}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..10 { + h.step(); + } + } + assert!(has(&h, "button:slideshowText0Anchor-4"), "its anchor grid"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Anchor-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_overlays.texts[0].place.anchor, lightcraft_engine::export::Anchor::TopLeft, "picked in the grid"); + // removed from its row the text overlay from its row + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Remove"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.texts.is_empty()); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowPlate"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.identity_plate.enabled); + // the preview is rendered again with the overlays + let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key); + run(&mut h, "slideshow.overlays", json!({"identityPlate": {"text": "Studio", "color": "#ff00ff"}})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); + h.settle(SETTLE); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2d0f88ab1..2a4525d3e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | -| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels). Sides 16–4096; one side given takes the other from the aspect preview | +| `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` | +| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index 845d054fd..1631eab88 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -200,7 +200,10 @@ Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it — written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout -before playing it. `cmd_slideshow_playback` sets the timing and fades. +before playing it. `cmd_slideshow_playback` sets the timing and fades. `cmd_slideshow_overlays` sets the +overlays drawn on every slide (identity plate text or image, rating stars, text overlays with tokens such as +`{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that +aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. ## Resources diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6995f6f16..1cc7972fd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 17 | 31 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 51 | 69 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 18 | 30 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 52 | 68 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.1% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.4% of 163. ## Top gaps and shortcut notes @@ -730,11 +730,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | | LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | -| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | | LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, overlays and titles on the slides (LRC-SS-OVERLAYS, LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, titles on the slides (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index a59175377..6f14bced6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,19 @@ ## 2026-10-10 +### Slideshow overlays +- Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an + image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the + photo's rating as stars, and any number of text overlays (up to 16) built from tokens: `{caption}`, `{title}`, + `{file}`, `{date}`, `{exposure}` ("1/250 s at f/8"), `{iso}`, `{focal}`, `{camera}`, `{lens}`, `{rating}` and the + other file-naming tokens, with a size, colour, opacity and an optional soft shadow (opacity, offset, radius, angle). + Each sits at one of nine anchors of the slide or of the photo, moved by an offset, and never leaves the slide. +- The export watermark can be drawn on every slide's photo too ("Use export watermark" takes the one you last + exported with). +- Set them in Settings ▸ Slideshow ▸ Overlays; the slide preview there and View ▸ Slideshow show them. Sizes are a + share of the slide, so slides look the same on any screen. They are saved with the library. Agents use + `slideshow.overlays`; `slideshow.preview` now also returns where each overlay landed. + ### Slideshow layout, options and backdrop - Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width), From 570b2a10c545dd4b86cdea2179f40c35457a30f0 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:59:58 +0200 Subject: [PATCH 004/315] LRC-SS-PLAYBACK: the render-ahead test waits for the slide up too (renders finish in any order) --- crates/ui-egui/src/tests_slideshow.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 06e402bef..2005b9947 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -74,9 +74,12 @@ fn next_slides_are_prepared_before_they_are_due() { let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD); assert_eq!(ahead.len(), 2); let start = h.time(); - assert!(h.step_until(SETTLE, |h| ahead.iter().all(|id| has_slide(h, *id))), "the next two slides are rendered"); + // the slide up and the next two; their renders can finish in any order let current = play(&h).current(); - assert!(has_slide(&h, current)); + assert!( + h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))), + "the slide up and the next two slides are rendered" + ); assert!(h.time() - start < 60.0, "before the first is due"); assert_eq!(play(&h).current(), current, "still on the first slide"); h.settle(SETTLE); From f98078415eeb0246b7a1ca769beee2c203b237e2 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:24:54 +0200 Subject: [PATCH 005/315] LRC-SS-TITLES: slideshow intro and ending screens Intro and ending title screens (Lightroom Classic's Titles panel): each on or off, a background colour and, centred, the Overlays identity plate or a line of text, in the plate's colour or an override colour, at a scale, for its own duration (default: the slide duration). - Playback timeline: titles are items (Item::Title) of Playback; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; arrows step across them; fades, pause and readiness work as for slides; n of N counts photos only. - Engine: slide_title (TitleSettings in prefs.json, TitleCompose drawing through the slide composer and overlay text renderer), slideshow.titles get/set, slideshow.preview {title}. - UI: titles composed on a render worker (Renderer::request_title), the control bar shows Intro / End, Settings > Slideshow > Titles section and a Photo / Intro / Ending preview choice; strings in the nine locales. - Docs: parity row (partial), playback note, whats-new, control and MCP. --- Cargo.lock | 2 +- crates/engine/src/cmd/slideshow.rs | 90 +++- crates/engine/src/lib.rs | 4 + crates/engine/src/library.rs | 4 + crates/engine/src/slide_title.rs | 526 ++++++++++++++++++++++ crates/engine/src/slideshow.rs | 461 +++++++++++++++---- crates/engine/src/tests_slide.rs | 83 ++++ crates/ui-egui/locales/de.json | 18 +- crates/ui-egui/locales/es.json | 18 +- crates/ui-egui/locales/fr.json | 18 +- crates/ui-egui/locales/ja.json | 18 +- crates/ui-egui/locales/pt-br.json | 18 +- crates/ui-egui/locales/ru.json | 18 +- crates/ui-egui/locales/uk.json | 18 +- crates/ui-egui/locales/zh-hans.json | 18 +- crates/ui-egui/locales/zh-hant.json | 18 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 2 + crates/ui-egui/src/panels/slide_layout.rs | 46 ++ crates/ui-egui/src/panels/slide_titles.rs | 168 +++++++ crates/ui-egui/src/panels/slideshow.rs | 81 ++-- crates/ui-egui/src/render.rs | 24 +- crates/ui-egui/src/tests_slideshow.rs | 255 +++++++++-- docs/control-protocol.md | 3 +- docs/mcp.md | 4 +- docs/parity-checklist.md | 10 +- docs/whats-new.md | 12 + 27 files changed, 1760 insertions(+), 178 deletions(-) create mode 100644 crates/engine/src/slide_title.rs create mode 100644 crates/ui-egui/src/panels/slide_titles.rs diff --git a/Cargo.lock b/Cargo.lock index cefc9fdc3..e15467671 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3420,7 +3420,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.5.0" +version = "0.6.0" dependencies = [ "lightcraft-raster", "serde", diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 1ac391055..62e4b4313 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -4,7 +4,9 @@ //! colour, colour wash, background image). Both are library preferences, saved in the library's //! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`: //! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every -//! slide (also a library preference). `slideshow.preview` composes one slide as a PNG. +//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour, +//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes +//! one slide, or a title screen, as a PNG. use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; @@ -53,6 +55,20 @@ fn overlays(s: &mut Session, p: &Value) -> Result { Ok(out) } +fn titles(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?; + if next != s.slide_titles { + s.slide_titles = next; + s.save_prefs()?; + } + } + let mut out = s.slide_titles.to_json(); + out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays)); + Ok(out) +} + /// A slide size from the request: both sides given, one (the other from the aspect preview), or /// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { @@ -76,8 +92,58 @@ fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { }) } +/// Write `png` to `path`, or put it in the reply as base64. +fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> { + match path { + Some(path) => { + crate::export::write_file(path, png).map_err(crate::EngineError::Other)?; + out["path"] = json!(path); + } + None => out["png"] = json!(base64(png)), + } + Ok(()) +} + +fn encode_png(img: &lightcraft_raster::Rgba8) -> Result> { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| crate::EngineError::Other(format!("PNG: {e}"))) +} + +/// `slideshow.preview {title}`: a title screen (whether or not it is switched on). +fn preview_title(s: &mut Session, p: &Value, title: &Value) -> Result { + const C: &str = "slideshow.preview"; + let t = title + .as_str() + .and_then(crate::slideshow::Title::parse) + .ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?; + if p.get("id").is_some_and(|v| !v.is_null()) { + return Err(bad(C, "pass `id` or `title`, not both")); + } + let (w, h) = preview_size(s, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let c = s.title_compose(t, w, h); + let img = c.compose(); + let png = encode_png(&img)?; + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "title": t.id(), + "enabled": s.slide_titles.screen(t).enabled, + "width": img.width, + "height": img.height, + "plate": c.plate_box().map(rect), + }); + deliver(&png, path, &mut out)?; + Ok(out) +} + fn preview(s: &mut Session, p: &Value) -> Result { const C: &str = "slideshow.preview"; + if let Some(t) = p.get("title").filter(|v| !v.is_null()) { + return preview_title(s, p, t); + } let id = match p.get("id") { None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?, Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?), @@ -92,8 +158,7 @@ fn preview(s: &mut Session, p: &Value) -> Result { } let settings = s.slide_layout.clone(); let img = s.slide_now(&settings, id, w, h).map_err(crate::EngineError::Other)?; - let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()) - .map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))?; + let png = encode_png(&img)?; let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height); let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); @@ -107,13 +172,7 @@ fn preview(s: &mut Session, p: &Value) -> Result { let photo = s.catalog.photo(id).cloned(); let o = crate::slide_overlay::SlideOverlays::resolve(&s.slide_overlays, photo.as_deref()); out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect(); - match path { - Some(path) => { - crate::export::write_file(path, &png).map_err(crate::EngineError::Other)?; - out["path"] = json!(path); - } - None => out["png"] = json!(base64(&png)), - } + deliver(&png, path, &mut out)?; Ok(out) } @@ -162,12 +221,21 @@ pub fn specs() -> Vec { always, overlays ), + cmd!( + "slideshow.titles", + "Slideshow Title Settings", + [], + None, + "{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)", + always, + titles + ), cmd!( query "slideshow.preview", "Slideshow Slide Preview", [], None, - "{id?: photo (default: the active one), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}", + "{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}", always, preview ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fab21a5d9..00f39baec 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,6 +57,7 @@ pub mod segment; pub mod sidecar; pub mod slide; pub mod slide_overlay; +pub mod slide_title; pub mod slideshow; pub mod smart; pub mod sync; @@ -282,6 +283,8 @@ pub struct Session { pub slide_layout: slide::SlideSettings, /// Slideshow overlays (identity plate, rating stars, text overlays, watermark). pub slide_overlays: slide_overlay::OverlaySettings, + /// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json). + pub slide_titles: slide_title::TitleSettings, /// The background image the slides last rendered (shared by slide jobs: rendered once). pub slide_backdrop: std::sync::Arc, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). @@ -369,6 +372,7 @@ impl Session { slideshow: slideshow::PlaybackSettings::default(), slide_layout: slide::SlideSettings::default(), slide_overlays: slide_overlay::OverlaySettings::default(), + slide_titles: slide_title::TitleSettings::default(), slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 6c7bafaef..4ae3538f8 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -164,6 +164,8 @@ struct PrefsFile { slide_layout: crate::slide::SlideSettings, /// Slideshow overlays. slide_overlays: crate::slide_overlay::OverlaySettings, + /// Slideshow intro and ending screens. + slide_titles: crate::slide_title::TitleSettings, } fn presets_json(s: &Session) -> String { @@ -349,6 +351,7 @@ impl Session { self.slideshow = prefs.slideshow.sanitized(); self.slide_layout = prefs.slide_layout.sanitized(); self.slide_overlays = prefs.slide_overlays.sanitized(); + self.slide_titles = prefs.slide_titles.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { @@ -588,6 +591,7 @@ impl Session { slideshow: self.slideshow.clone(), slide_layout: self.slide_layout.clone(), slide_overlays: self.slide_overlays.clone(), + slide_titles: self.slide_titles.clone(), }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/slide_title.rs b/crates/engine/src/slide_title.rs new file mode 100644 index 000000000..79ac3489c --- /dev/null +++ b/crates/engine/src/slide_title.rs @@ -0,0 +1,526 @@ +//! Slideshow title screens (Lightroom Classic's Slideshow ▸ Titles panel): an intro shown before +//! the first slide and an ending shown after the last one, each switched on on its own, with a +//! background colour and, centred on it, the identity plate (the one of the Overlays panel: its +//! text or image) or a line of text, at a scale. [`TitleSettings`] is a library preference +//! (`prefs.json`), edited with the `slideshow.titles` command; [`TitleCompose`] draws one screen. +//! +//! **One composer:** a title screen is a slide with no photo: `lightcraft_slideshow::compose` +//! fills it with the title's colour, then the overlay code ([`crate::slide_overlay`]) draws the +//! plate or text with the shared text renderer ([`crate::text`]). Playback, the Settings preview +//! and `slideshow.preview {title}` all go through [`TitleCompose::compose`], so they draw the same +//! pixels. +//! +//! **Timing** is in [`crate::slideshow::Playback`]: each screen stays up for its own duration +//! (default: the slide duration) and fades in and out with the playback fade. +//! +//! **Resolution independence:** the scale is the height of the plate (or of a text line) as a +//! fraction of the slide's shorter side; text wider than the slide is scaled down to fit. +//! +//! **Never crash:** settings are validated by [`TitleSettings::merged`] and clamped by +//! [`TitleSettings::sanitized`]; an unreadable plate image draws the colour alone (and +//! `slideshow.titles` reports it). + +use std::sync::Arc; + +use lightcraft_raster::Rgba8; +use lightcraft_slideshow::{Layout, Rect, SlideSettings, hex, parse_color}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::export::Anchor; +use crate::slide_overlay::{IdentityPlate, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SIZE_MIN, SlideOverlays, TEXT_MAX_CHARS}; +pub use crate::slideshow::Title; + +/// Widest a title's plate or text may be (fraction of the slide's width): wider is scaled down. +pub const FIT_WIDTH: f64 = 0.9; + +/// What a title screen shows on its colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TitleContent { + /// The colour alone. + None, + /// The identity plate of the Overlays panel (its text or image). + #[default] + IdentityPlate, + /// This screen's own text. + Text, +} + +impl TitleContent { + pub const ALL: [TitleContent; 3] = [TitleContent::None, TitleContent::IdentityPlate, TitleContent::Text]; + + pub fn id(self) -> &'static str { + match self { + TitleContent::None => "none", + TitleContent::IdentityPlate => "identityPlate", + TitleContent::Text => "text", + } + } + + fn parse(s: &str) -> Option { + TitleContent::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// One title screen. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TitleScreen { + pub enabled: bool, + /// The screen's background colour. + pub color: [u8; 3], + pub content: TitleContent, + /// The text drawn with [`TitleContent::Text`]. + pub text: String, + /// Draw the plate or text in [`Self::text_color`] (off: in the identity plate's colour). + /// An image plate keeps its own colours. + pub override_color: bool, + pub text_color: [u8; 3], + /// Height of the plate (or of a text line), fraction of the slide's shorter side. + pub scale: f64, + /// Seconds the screen stays up (`None`: the slide duration). + pub duration: Option, +} + +impl TitleScreen { + fn new(text: &str) -> Self { + TitleScreen { + enabled: false, + color: [0, 0, 0], + content: TitleContent::IdentityPlate, + text: text.into(), + override_color: false, + text_color: [255, 255, 255], + scale: 0.1, + duration: None, + } + } +} + +/// The intro and ending screens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TitleSettings { + pub intro: TitleScreen, + pub ending: TitleScreen, +} + +impl Default for TitleSettings { + fn default() -> Self { + TitleSettings { intro: TitleScreen::new("Welcome"), ending: TitleScreen::new("The End") } + } +} + +impl Default for TitleScreen { + fn default() -> Self { + TitleScreen::new("") + } +} + +fn cut(s: &mut String) { + if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) { + s.truncate(i); + } +} + +const SCREEN_KEYS: &str = "enabled, color, content, text, overrideColor, textColor, scale, duration"; + +impl TitleScreen { + fn sanitized(mut self) -> Self { + cut(&mut self.text); + self.scale = if self.scale.is_finite() { self.scale.clamp(SIZE_MIN, PLATE_SIZE_MAX) } else { 0.1 }; + self.duration = self.duration.filter(|d| d.is_finite()).map(|d| d.clamp(crate::slideshow::SLIDE_MIN, crate::slideshow::SLIDE_MAX)); + self + } + + pub fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("content".into(), json!(self.content.id())); + o.insert("text".into(), json!(self.text)); + o.insert("overrideColor".into(), json!(self.override_color)); + o.insert("textColor".into(), json!(hex(self.text_color))); + o.insert("scale".into(), json!(self.scale)); + o.insert("duration".into(), json!(self.duration)); + Value::Object(o) + } + + fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> { + let obj = v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}"))?; + let num = |k: &str, v: &Value| match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")), + }; + let boolean = |k: &str, v: &Value| v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}")); + let colour = |k: &str, v: &Value| parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")); + for (k, v) in obj { + match k.as_str() { + "enabled" => self.enabled = boolean(k, v)?, + "color" => self.color = colour(k, v)?, + "content" => { + self.content = v + .as_str() + .and_then(TitleContent::parse) + .ok_or_else(|| format!("`{ctx}.content` must be \"none\", \"identityPlate\" or \"text\", not {v}"))? + } + "text" => { + let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.text` must be text, not {v}"))?.to_string(); + cut(&mut s); + self.text = s; + } + "overrideColor" => self.override_color = boolean(k, v)?, + "textColor" => self.text_color = colour(k, v)?, + "scale" => self.scale = num(k, v)?, + "duration" => { + self.duration = match v { + Value::Null => None, + _ => Some(num(k, v).map_err(|_| format!("`{ctx}.duration` must be seconds or null (the slide duration), not {v}"))?), + } + } + _ => return Err(format!("unknown key `{ctx}.{k}` ({SCREEN_KEYS})")), + } + } + Ok(()) + } +} + +impl TitleSettings { + pub fn screen(&self, t: Title) -> &TitleScreen { + match t { + Title::Intro => &self.intro, + Title::Ending => &self.ending, + } + } + + /// Every value in range (non-finite numbers take the defaults). + pub fn sanitized(mut self) -> Self { + self.intro = self.intro.sanitized(); + self.ending = self.ending.sanitized(); + self + } + + pub fn to_json(&self) -> Value { + json!({"intro": self.intro.to_json(), "ending": self.ending.to_json()}) + } + + /// These settings with the keys `p` names changed, validated and clamped; a wrong type or an + /// unknown key is an error and nothing changes. + pub fn merged(&self, p: &Value) -> Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "intro" => s.intro.merge(k, v)?, + "ending" => s.ending.merge(k, v)?, + _ => return Err(format!("unknown setting `{k}` (intro, ending)")), + } + } + Ok(s.sanitized()) + } + + /// How long each screen that is on stays up, given the slide duration (for + /// [`crate::slideshow::Playback::with_titles`]): (intro, ending). + pub fn durations(&self, slide: f64) -> (Option, Option) { + let d = |s: &TitleScreen| s.enabled.then(|| s.duration.unwrap_or(slide)); + (d(&self.intro), d(&self.ending)) + } + + /// Problems worth telling the user about: a screen showing an identity plate image that + /// can't be read, or one with nothing to show. + pub fn warnings(&self, overlays: &OverlaySettings) -> Vec { + let mut out = Vec::new(); + let plate = &overlays.identity_plate; + for t in Title::ALL { + let s = self.screen(t); + if !s.enabled { + continue; + } + match s.content { + TitleContent::IdentityPlate if !plate.image.trim().is_empty() && crate::export::watermark_logo(plate.image.trim()).is_none() => { + out.push(format!("{} screen: identity plate image {} can't be read; only the colour shows", t.id(), plate.image.trim())) + } + TitleContent::IdentityPlate if plate.image.trim().is_empty() && plate.text.trim().is_empty() => { + out.push(format!("{} screen: the identity plate has no text or image; only the colour shows", t.id())) + } + TitleContent::Text if s.text.trim().is_empty() => out.push(format!("{} screen: no text; only the colour shows", t.id())), + _ => {} + } + } + out + } +} + +/// How to draw one title screen at a size: its colour and its plate or text, resolved from the +/// library's settings (no session needed, so it runs on a render worker). +#[derive(Clone, Debug)] +pub struct TitleCompose { + pub title: Title, + /// The screen as a slide: its colour as the backdrop, nothing else. + pub settings: SlideSettings, + /// The plate or text, as a centred identity plate (`None`: the colour alone). + pub overlays: Option>, + pub width: usize, + pub height: usize, +} + +impl TitleCompose { + /// Screen `title` of `titles` at `width × height` (a bigger one is drawn at the composer's + /// [`lightcraft_slideshow::MAX_SIDE`]), + /// with `overlays`' identity plate when the screen shows it. + pub fn new(titles: &TitleSettings, overlays: &OverlaySettings, title: Title, width: usize, height: usize) -> Self { + let s = titles.screen(title).clone().sanitized(); + let settings = SlideSettings { backdrop_color: s.color, ..SlideSettings::default() }.sanitized(); + let plate = &overlays.identity_plate; + let centre = Placement { anchor: Anchor::Center, offset_x: 0.0, offset_y: 0.0, relative_to: RelativeTo::Slide }; + let colour = |own: [u8; 3]| if s.override_color { s.text_color } else { own }; + let shown = match s.content { + TitleContent::None => None, + TitleContent::IdentityPlate => Some(IdentityPlate { + enabled: true, + text: plate.text.clone(), + image: plate.image.clone(), + size: s.scale, + color: colour(plate.color), + opacity: plate.opacity, + place: centre, + behind: false, + }), + TitleContent::Text => Some(IdentityPlate { + enabled: true, + text: s.text.clone(), + image: String::new(), + size: s.scale, + color: colour(plate.color), + opacity: 1.0, + place: centre, + behind: false, + }), + }; + let mut compose = TitleCompose { title, settings, overlays: None, width, height }; + if let Some(p) = shown.filter(|p| !p.text.trim().is_empty() || !p.image.trim().is_empty()) { + let resolved = + |p: IdentityPlate| Arc::new(SlideOverlays::resolve(&OverlaySettings { identity_plate: p, ..OverlaySettings::default() }, None)); + compose.overlays = Some(resolved(p.clone())); + // a long text (or a wide logo) at a large scale: shrink it to fit the slide's width + if let Some(b) = compose.plate_box() { + let max = FIT_WIDTH * compose.layout().slide.width(); + if b.width() > max && b.width() > 0.0 { + let size = (p.size * max / b.width()).max(SIZE_MIN); + compose.overlays = Some(resolved(IdentityPlate { size, ..p })); + } + } + } + compose + } + + fn layout(&self) -> Layout { + lightcraft_slideshow::layout(&self.settings, 1.0, self.width, self.height) + } + + /// Where the plate or text is on the screen (`None`: nothing is drawn but the colour). + pub fn plate_box(&self) -> Option { + let o = self.overlays.as_ref()?; + o.boxes(&self.layout()).into_iter().next().map(|(_, r)| r) + } + + /// What makes the screen's pixels differ (for render keys). + pub fn key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + "title".hash(&mut h); + self.title.hash(&mut h); + self.settings.pixel_key().hash(&mut h); + self.overlays.as_ref().map(|o| o.key()).hash(&mut h); + (self.width, self.height).hash(&mut h); + h.finish() + } + + /// The screen's pixels. + pub fn compose(&self) -> Rgba8 { + let empty = Rgba8::new(0, 0); + let (mut img, l) = lightcraft_slideshow::compose_with(&empty, None, &self.settings, self.width, self.height, &mut |_, _| {}); + if let Some(o) = &self.overlays { + o.draw_front(&mut img, &l); + } + img + } + + /// [`Self::compose`] as a render result (for a frontend's render queue): request `request_id`, + /// under this screen's key. Title screens show no photo (`photo` is 0). + pub fn result(&self, request_id: u64) -> crate::media::RenderResult { + let image = self.compose(); + let histogram = lightcraft_raster::Histogram::of_srgb8(&image); + crate::media::RenderResult { + request_id, + source_key: None, + photo: lightcraft_catalog::PhotoId(0), + level: crate::media::SourceLevel::Preview, + key: self.key(), + rendered: Ok(lightcraft_pipeline::Rendered { image, histogram, deep: None }), + loaded: None, + quick: None, + display: None, + } + } +} + +impl crate::Session { + /// Title screen `title` at `width × height` with the library's titles and identity plate. + pub fn title_compose(&self, title: Title, width: usize, height: usize) -> Arc { + Arc::new(TitleCompose::new(&self.slide_titles, &self.slide_overlays, title, width, height)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(img: &Rgba8, x: usize, y: usize) -> [u8; 3] { + let p = img.get(x, y); + [p[0], p[1], p[2]] + } + + /// The bounding box of the pixels that differ from `bg` (x0, y0, x1, y1). + fn ink(img: &Rgba8, bg: [u8; 3]) -> Option<(usize, usize, usize, usize)> { + let mut b: Option<(usize, usize, usize, usize)> = None; + for y in 0..img.height { + for x in 0..img.width { + let c = at(img, x, y); + if c.iter().zip(bg).any(|(a, b)| a.abs_diff(b) > 40) { + b = Some(match b { + None => (x, y, x + 1, y + 1), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)), + }); + } + } + } + b + } + + fn text_titles(scale: f64) -> TitleSettings { + TitleSettings::default() + .merged(&json!({"intro": {"enabled": true, "color": "#203040", "content": "text", "text": "Holiday", "overrideColor": true, "textColor": "#ffcc00", "scale": scale}})) + .unwrap() + } + + #[test] + fn a_text_title_is_centred_in_its_colours_and_scales_with_the_slide() { + let o = OverlaySettings::default(); + let t = text_titles(0.2); + let mut boxes = Vec::new(); + for (w, h) in [(400, 250), (800, 500)] { + let c = TitleCompose::new(&t, &o, Title::Intro, w, h); + let img = c.compose(); + assert_eq!((img.width, img.height), (w, h)); + assert_eq!(at(&img, 2, 2), [0x20, 0x30, 0x40], "the title's colour"); + let (x0, y0, x1, y1) = ink(&img, [0x20, 0x30, 0x40]).expect("the text is drawn"); + // centred (glyph ink sits inside the line box, so allow some slack vertically) + let (cx, cy) = ((x0 + x1) as f64 / 2.0, (y0 + y1) as f64 / 2.0); + assert!((cx - w as f64 / 2.0).abs() < w as f64 * 0.03, "centred across: {cx} of {w}"); + assert!((cy - h as f64 / 2.0).abs() < h as f64 * 0.06, "centred down: {cy} of {h}"); + let mid = at(&img, (x0..x1).find(|x| at(&img, *x, (y0 + y1) / 2) != [0x20, 0x30, 0x40]).unwrap_or(x0), (y0 + y1) / 2); + assert!(mid[0] > 150 && mid[2] < 120, "drawn in the text colour: {mid:?}"); + boxes.push(((x1 - x0) as f64 / w as f64, (y1 - y0) as f64 / h as f64)); + let b = c.plate_box().unwrap(); + assert!((b.height() - 0.2 * h as f64).abs() < 2.0, "the scale is the line height: {b:?}"); + } + let ((w1, h1), (w2, h2)) = (boxes[0], boxes[1]); + assert!((w1 - w2).abs() < 0.02 && (h1 - h2).abs() < 0.02, "proportional at two sizes: {boxes:?}"); + // a bigger scale draws bigger text + let big = ink(&TitleCompose::new(&text_titles(0.3), &o, Title::Intro, 800, 500).compose(), [0x20, 0x30, 0x40]).unwrap(); + assert!((big.2 - big.0) as f64 / 800.0 > w2 * 1.3); + } + + #[test] + fn the_identity_plate_shows_in_its_own_colour_unless_overridden() { + let o = OverlaySettings::default() + .merged(&json!({"identityPlate": {"text": "Studio", "color": "#00ff00", "opacity": 1, "anchor": "topLeft"}})) + .unwrap(); + assert!(!o.identity_plate.enabled, "the overlay plate itself may be off"); + let t = TitleSettings::default().merged(&json!({"ending": {"enabled": true, "color": "#000000", "scale": 0.15}})).unwrap(); + let img = TitleCompose::new(&t, &o, Title::Ending, 600, 400).compose(); + let (x0, y0, x1, y1) = ink(&img, [0, 0, 0]).expect("the plate is drawn"); + assert!(x0 > 100 && x1 < 500 && y0 > 120 && y1 < 280, "centred, not at the overlay's anchor: {:?}", (x0, y0, x1, y1)); + let greens = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| { + let c = at(&img, x, y); + c[1] > 200 && c[0] < 60 + }); + assert!(greens.count() > 100, "in the plate's colour"); + let over = t.merged(&json!({"ending": {"overrideColor": true, "textColor": "#ff0000"}})).unwrap(); + let img = TitleCompose::new(&over, &o, Title::Ending, 600, 400).compose(); + let reds = img.data.iter().filter(|p| p[0] > 200 && p[1] < 60).count(); + assert!(reds > 100, "overridden"); + // the colour alone + let none = t.merged(&json!({"ending": {"content": "none", "color": "#123456"}})).unwrap(); + let c = TitleCompose::new(&none, &o, Title::Ending, 64, 40); + assert!(c.plate_box().is_none()); + assert!(c.compose().data.iter().all(|p| p[..3] == [0x12, 0x34, 0x56])); + } + + #[test] + fn a_missing_plate_image_draws_the_colour_and_is_reported() { + let o = OverlaySettings::default().merged(&json!({"identityPlate": {"image": "/no/such/logo.png"}})).unwrap(); + let t = TitleSettings::default().merged(&json!({"intro": {"enabled": true, "color": "#336699"}})).unwrap(); + let c = TitleCompose::new(&t, &o, Title::Intro, 120, 80); + assert!(c.compose().data.iter().all(|p| p[..3] == [0x33, 0x66, 0x99])); + let w = t.warnings(&o); + assert!(w.iter().any(|w| w.contains("intro") && w.contains("can't be read")), "{w:?}"); + let t = t.merged(&json!({"intro": {"content": "text", "text": " "}})).unwrap(); + assert!(t.warnings(&o).iter().any(|w| w.contains("no text"))); + } + + #[test] + fn long_text_is_scaled_down_to_fit_the_width() { + let t = TitleSettings::default() + .merged(&json!({"intro": {"enabled": true, "content": "text", "text": "A very long title that would never fit across", "scale": 0.5}})) + .unwrap(); + let c = TitleCompose::new(&t, &OverlaySettings::default(), Title::Intro, 800, 450); + let b = c.plate_box().unwrap(); + assert!(b.width() <= 800.0 * FIT_WIDTH + 2.0 && b.x0 >= 0.0, "{b:?}"); + } + + #[test] + fn settings_are_validated_clamped_and_round_trip() { + let d = TitleSettings::default(); + assert!(!d.intro.enabled && !d.ending.enabled, "off by default"); + assert_eq!(d.durations(4.0), (None, None)); + for bad in [ + json!({"intro": {"enabled": 1}}), + json!({"intro": {"color": "#12"}}), + json!({"ending": {"content": "video"}}), + json!({"ending": {"scale": "big"}}), + json!({"ending": {"duration": "long"}}), + json!({"intro": {"sparkle": true}}), + json!({"intro": []}), + json!({"credits": {}}), + json!({"INTRO": {}}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"intro": {"enabled": true, "scale": 9, "duration": 1e9}, "ending": {"enabled": true, "scale": -1, "duration": 0}})) + .unwrap(); + assert_eq!((s.intro.scale, s.ending.scale), (PLATE_SIZE_MAX, SIZE_MIN)); + assert_eq!((s.intro.duration, s.ending.duration), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN))); + assert_eq!(s.durations(4.0), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN))); + let s = s.merged(&json!({"intro": {"duration": null}})).unwrap(); + assert_eq!(s.durations(4.0).0, Some(4.0), "null: the slide duration"); + assert_eq!(d.merged(&s.to_json()).unwrap(), s, "the JSON form reads back"); + let back: TitleSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + let partial: TitleSettings = serde_json::from_str(r#"{"ending": {"enabled": true}}"#).unwrap(); + assert!(partial.ending.enabled && !partial.intro.enabled); + // NaN / ∞ from a hand-edited file + let s = TitleSettings { intro: TitleScreen { scale: f64::NAN, duration: Some(f64::INFINITY), ..d.intro.clone() }, ..d.clone() }.sanitized(); + assert_eq!((s.intro.scale, s.intro.duration), (0.1, None)); + // any size composes + for (w, h) in [(0, 0), (1, 100_000), (usize::MAX, 3)] { + let c = TitleCompose::new(&text_titles(0.5), &OverlaySettings::default(), Title::Intro, w, h); + let img = c.compose(); + assert!(img.width >= 1 && img.height >= 1 && img.width <= lightcraft_slideshow::MAX_SIDE); + } + } +} diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs index bbe146a65..8f615bf0c 100644 --- a/crates/engine/src/slideshow.rs +++ b/crates/engine/src/slideshow.rs @@ -1,5 +1,6 @@ //! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that -//! plays the photos in view (durations, fades, random order, repeat, pause, stepping). +//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an +//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]). //! //! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any //! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline. @@ -131,15 +132,72 @@ fn color(v: &Value) -> std::result::Result, String> { } } +/// A title screen (Lightroom Classic's Titles panel): the intro before the first slide, the +/// ending after the last one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Title { + Intro, + Ending, +} + +impl Title { + pub const ALL: [Title; 2] = [Title::Intro, Title::Ending]; + + pub fn id(self) -> &'static str { + match self { + Title::Intro => "intro", + Title::Ending => "ending", + } + } + + pub fn parse(s: &str) -> Option { + Title::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// One slide of the timeline: a photo, or a title screen (not a photo: it shows no photo, never +/// moves the selection and isn't counted in "n of N"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Item { + Title(Title), + Photo(PhotoId), +} + +impl From<PhotoId> for Item { + fn from(p: PhotoId) -> Item { + Item::Photo(p) + } +} + +impl Item { + pub const INTRO: Item = Item::Title(Title::Intro); + pub const ENDING: Item = Item::Title(Title::Ending); + + /// The photo this slide shows (`None` for a title screen). + pub fn photo(self) -> Option<PhotoId> { + match self { + Item::Photo(p) => Some(p), + Item::Title(_) => None, + } + } + + pub fn title(self) -> Option<Title> { + match self { + Item::Title(t) => Some(t), + Item::Photo(_) => None, + } + } +} + /// What a tick did. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Tick { /// Nothing changed (the slide is up, paused, or a transition is running). Idle, /// The next slide is due but not ready: the current one is held. - Waiting(PhotoId), + Waiting(Item), /// A new slide came up (its transition starts now). - Advanced(PhotoId), + Advanced(Item), /// The last slide was shown and repeat is off. Ended, } @@ -147,34 +205,51 @@ pub enum Tick { /// What to draw at a moment of playback. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Frame { - pub current: PhotoId, + pub current: Item, /// The slide being faded out, while a transition runs. - pub from: Option<PhotoId>, + pub from: Option<Item>, /// How far the transition is (0–1; 1 when there is none). pub progress: f32, pub fade_color: Option<[u8; 3]>, } -/// One run of a slideshow over a fixed list of photos. +/// Where the timeline is: the intro, the photos, or the ending. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Intro, + Photos, + Ending, +} + +/// One run of a slideshow over a fixed list of photos, with an optional intro and ending. +/// +/// **Titles:** the intro plays once, before the first photo; the ending once, after the last photo +/// when repeat is off (with repeat on the show only ends when it is stopped, so the ending never +/// comes up; passes follow each other without titles between them). Random order shuffles the +/// photos only: titles keep their places. #[derive(Clone, Debug)] pub struct Playback { settings: PlaybackSettings, photos: Vec<PhotoId>, /// This pass's order (indices into `photos`). order: Vec<usize>, - /// Index into `order` of the slide up now. + /// Index into `order` of the photo up now (the last one while the ending is up). pos: usize, + phase: Phase, pass: u64, seed: u64, + /// How long the intro and the ending stay up (`None`: no such screen). + intro: Option<f64>, + ending: Option<f64>, /// When the current slide came up (its transition started). started: f64, /// The slide fading out (until the transition ends). - from: Option<PhotoId>, + from: Option<Item>, /// Paused at this time. paused_at: Option<f64>, ended: bool, /// The next slide was due but not ready at the last tick. - held: Option<PhotoId>, + held: Option<Item>, } /// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed). @@ -216,7 +291,36 @@ impl Playback { (first..n).chain(0..first).collect() }; let now = if now.is_finite() { now } else { 0.0 }; - Some(Playback { settings, photos, order, pos: 0, pass: 0, seed, started: now, from: None, paused_at: None, ended: false, held: None }) + Some(Playback { + settings, + photos, + order, + pos: 0, + phase: Phase::Photos, + pass: 0, + seed, + intro: None, + ending: None, + started: now, + from: None, + paused_at: None, + ended: false, + held: None, + }) + } + + /// With an intro and / or an ending screen, each up for its duration in seconds (clamped like + /// a slide's; a non-finite one takes the slide duration). Call it right after [`Self::new`]: + /// the show then starts at the intro. + pub fn with_titles(mut self, intro: Option<f64>, ending: Option<f64>) -> Self { + let slide = self.settings.slide_duration; + let dur = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { slide }; + self.intro = intro.map(dur); + self.ending = ending.map(dur); + if self.intro.is_some() && self.pass == 0 && self.pos == 0 && self.phase == Phase::Photos { + self.phase = Phase::Intro; + } + self } pub fn settings(&self) -> &PlaybackSettings { @@ -241,14 +345,53 @@ impl Playback { order.get(pos).and_then(|i| self.photos.get(*i)).copied() } - /// The slide up now. - pub fn current(&self) -> PhotoId { - self.photo_at(&self.order, self.pos).or_else(|| self.photos.first().copied()).unwrap_or(PhotoId(0)) + fn item_at(&self, phase: Phase, order: &[usize], pos: usize) -> Option<Item> { + match phase { + Phase::Intro => Some(Item::INTRO), + Phase::Ending => Some(Item::ENDING), + Phase::Photos => self.photo_at(order, pos).map(Item::Photo), + } } - /// (slide number from 1, slides per pass). + /// The place after (`phase`, `pos`, `pass`); `order` is that pass's order and becomes the next + /// pass's when it wraps. `None`: the show ends there. + fn following(&self, phase: Phase, pos: usize, pass: u64, order: &mut Vec<usize>) -> Option<(Phase, usize, u64)> { + match phase { + Phase::Intro => Some((Phase::Photos, 0, pass)), + Phase::Photos if pos + 1 < order.len() => Some((Phase::Photos, pos + 1, pass)), + Phase::Photos if self.settings.repeat => { + *order = self.order_after(pass + 1, order); + Some((Phase::Photos, 0, pass + 1)) + } + Phase::Photos if self.ending.is_some() => Some((Phase::Ending, pos, pass)), + Phase::Photos | Phase::Ending => None, + } + } + + /// The slide up now. + pub fn current(&self) -> Item { + self.item_at(self.phase, &self.order, self.pos).or_else(|| self.photos.first().copied().map(Item::Photo)).unwrap_or(Item::Photo(PhotoId(0))) + } + + /// The photo up now (`None` while a title screen is up). + pub fn current_photo(&self) -> Option<PhotoId> { + self.current().photo() + } + + /// (photo number from 1, photos per pass). Titles aren't photos: (0, N) while the intro is up, + /// (N, N) while the ending is; [`Self::title`] says which. pub fn position(&self) -> (usize, usize) { - (self.pos + 1, self.order.len()) + let n = self.order.len(); + match self.phase { + Phase::Intro => (0, n), + Phase::Photos => (self.pos + 1, n), + Phase::Ending => (n, n), + } + } + + /// The title screen up now, if one is. + pub fn title(&self) -> Option<Title> { + self.current().title() } pub fn is_paused(&self) -> bool { @@ -260,7 +403,7 @@ impl Playback { } /// The slide that was due but not ready at the last tick (the current one is held for it). - pub fn waiting_for(&self) -> Option<PhotoId> { + pub fn waiting_for(&self) -> Option<Item> { self.held } @@ -270,9 +413,23 @@ impl Playback { self.paused_at.map_or(now, |p| p.min(now)) } + /// How long `item` stays up (seconds). + pub fn duration(&self, item: Item) -> f64 { + match item { + Item::Title(Title::Intro) => self.intro.unwrap_or(self.settings.slide_duration), + Item::Title(Title::Ending) => self.ending.unwrap_or(self.settings.slide_duration), + Item::Photo(_) => self.settings.slide_duration, + } + } + + /// How long the transition to `item` takes: the fade, never longer than the slide. + fn fade_to(&self, item: Item) -> f64 { + self.settings.fade_duration.min(self.duration(item)) + } + /// When the next slide is due. pub fn due(&self) -> f64 { - self.started + self.settings.slide_duration + self.started + self.duration(self.current()) } /// Seconds until the next slide is due (as of `now`; frozen while paused). @@ -280,24 +437,18 @@ impl Playback { (self.due() - self.clock(now)).max(0.0) } - /// The next `n` slides in playing order (into the next pass when repeating; never the one up - /// now twice). - pub fn upcoming(&self, n: usize) -> Vec<PhotoId> { + /// The next `n` slides in playing order (titles included; into the next pass when repeating; + /// never the one up now twice). + pub fn upcoming(&self, n: usize) -> Vec<Item> { let mut out = Vec::new(); let mut order = self.order.clone(); - let (mut pos, mut pass) = (self.pos, self.pass); + let (mut phase, mut pos, mut pass) = (self.phase, self.pos, self.pass); + let current = self.current(); while out.len() < n { - pos += 1; - if pos >= order.len() { - if !self.settings.repeat { - break; - } - pass += 1; - order = self.order_after(pass, &order); - pos = 0; - } - match self.photo_at(&order, pos) { - Some(p) if p != self.current() && !out.contains(&p) => out.push(p), + let Some(next) = self.following(phase, pos, pass, &mut order) else { break }; + (phase, pos, pass) = next; + match self.item_at(phase, &order, pos) { + Some(it) if it != current && !out.contains(&it) => out.push(it), _ => break, } } @@ -306,27 +457,29 @@ impl Playback { /// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat. fn advance(&mut self) -> Tick { - if self.pos + 1 < self.order.len() { - self.pos += 1; - } else if self.settings.repeat { - self.pass += 1; - self.order = self.order_after(self.pass, &self.order); - self.pos = 0; - } else { - self.ended = true; - return Tick::Ended; + let mut order = std::mem::take(&mut self.order); + let next = self.following(self.phase, self.pos, self.pass, &mut order); + self.order = order; + match next { + Some((phase, pos, pass)) => { + (self.phase, self.pos, self.pass) = (phase, pos, pass); + Tick::Advanced(self.current()) + } + None => { + self.ended = true; + Tick::Ended + } } - Tick::Advanced(self.current()) } - /// Advance the timeline to `now`. `ready(photo)` says whether a slide can be shown: the + /// Advance the timeline to `now`. `ready(slide)` says whether a slide can be shown: the /// transition to the next slide waits until it is. - pub fn tick(&mut self, now: f64, ready: impl Fn(PhotoId) -> bool) -> Tick { + pub fn tick(&mut self, now: f64, ready: impl Fn(Item) -> bool) -> Tick { if self.ended { return Tick::Ended; } let now = self.clock(now); - if self.from.is_some() && now >= self.started + self.settings.fade_duration { + if self.from.is_some() && now >= self.started + self.fade_to(self.current()) { self.from = None; } if self.paused_at.is_some() { @@ -356,9 +509,9 @@ impl Playback { self.held = None; let old = self.current(); let t = self.advance(); - if let Tick::Advanced(_) = t { + if let Tick::Advanced(new) = t { self.started = now; - self.from = (self.settings.fade_duration > 0.0).then_some(old); + self.from = (self.fade_to(new) > 0.0).then_some(old); } t } @@ -366,12 +519,13 @@ impl Playback { /// What to draw at `now`. pub fn frame(&self, now: f64) -> Frame { let now = self.clock(now); - let fade = self.settings.fade_duration; + let current = self.current(); + let fade = self.fade_to(current); let (from, progress) = match self.from { Some(f) if fade > 0.0 && now < self.started + fade => (Some(f), ((now - self.started) / fade).clamp(0.0, 1.0) as f32), _ => (None, 1.0), }; - Frame { current: self.current(), from, progress, fade_color: self.settings.fade_color } + Frame { current, from, progress, fade_color: self.settings.fade_color } } /// Pause or resume at `now`; resuming keeps the time the slide had left. Returns whether it @@ -389,15 +543,17 @@ impl Playback { } /// Step `delta` slides (←/→) at `now`: a cut, and the new slide gets its full time (paused - /// playback stays paused). Past the last slide without repeat playback ends; before the first - /// it stays there. + /// playback stays paused). Past the last slide (the ending, when there is one) without repeat + /// playback ends; before the first it stays there (the intro, when there is one: in the first + /// pass ← from the first photo goes back to it). pub fn step(&mut self, delta: i32, now: f64) -> Tick { if self.ended { return Tick::Ended; } let now = if now.is_finite() { now } else { self.started }; // (more steps than slides would only go round again) - let steps = delta.unsigned_abs().min(self.order.len().min(u32::MAX as usize) as u32); + let slides = self.order.len().saturating_add(2).min(u32::MAX as usize) as u32; + let steps = delta.unsigned_abs().min(slides); let mut t = Tick::Idle; if delta > 0 { for _ in 0..steps { @@ -408,10 +564,13 @@ impl Playback { } } else { for _ in 0..steps { - if self.pos > 0 { - self.pos -= 1; - } else if self.settings.repeat { - self.pos = self.order.len().saturating_sub(1); + match self.phase { + // (the ending keeps the last photo's place) + Phase::Ending => self.phase = Phase::Photos, + Phase::Photos if self.pos > 0 => self.pos -= 1, + Phase::Photos if self.intro.is_some() && self.pass == 0 => self.phase = Phase::Intro, + Phase::Photos if self.settings.repeat => self.pos = self.order.len().saturating_sub(1), + Phase::Photos | Phase::Intro => {} } } t = Tick::Advanced(self.current()); @@ -438,34 +597,38 @@ mod tests { PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } } - fn all_ready(_: PhotoId) -> bool { + fn ph(n: u64) -> Item { + Item::Photo(PhotoId(n)) + } + + fn all_ready(_: Item) -> bool { true } #[test] fn timeline_durations_and_fade_progress() { let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); - assert_eq!(p.current(), PhotoId(2), "starts at the selected photo"); + assert_eq!(p.current(), ph(2), "starts at the selected photo"); assert_eq!(p.position(), (1, 3)); assert_eq!(p.tick(13.9, all_ready), Tick::Idle); assert_eq!(p.frame(13.9).from, None); - assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(PhotoId(3))); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3))); let f = p.frame(14.25); - assert_eq!((f.current, f.from), (PhotoId(3), Some(PhotoId(2)))); + assert_eq!((f.current, f.from), (ph(3), Some(ph(2)))); assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); assert_eq!(p.tick(15.0, all_ready), Tick::Idle); assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); // the slide period counts from its transition: next at 18 assert_eq!(p.tick(17.99, all_ready), Tick::Idle); - assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(PhotoId(1)), "wraps to the photos before the start"); + assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start"); assert_eq!(p.position(), (3, 3)); } #[test] fn fade_zero_is_a_cut_and_fade_color_is_reported() { let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(2))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2))); assert_eq!(p.frame(2.0).from, None); let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); @@ -478,13 +641,13 @@ mod tests { let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); p.tick(4.0, all_ready); assert!(p.frame(4.5).from.is_some(), "fading"); - assert_eq!(p.step(1, 4.5), Tick::Advanced(PhotoId(3))); - assert_eq!(p.frame(4.5), Frame { current: PhotoId(3), from: None, progress: 1.0, fade_color: None }, "a cut"); + assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3))); + assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut"); assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); - assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(2))); - assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(1))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1))); // repeat on: before the first is the last - assert_eq!(p.step(-1, 6.0), Tick::Advanced(PhotoId(4))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4))); assert_eq!(p.remaining(6.0), 4.0); } @@ -494,10 +657,10 @@ mod tests { let run = |seed: u64| { let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); - let mut seen = vec![p.current()]; + let mut seen = vec![p.current_photo().unwrap()]; for k in 1..60 { p.tick(k as f64, all_ready); - seen.push(p.current()); + seen.push(p.current_photo().unwrap()); } seen }; @@ -519,14 +682,14 @@ mod tests { fn repeat_off_stops_after_the_last_slide() { let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); - assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(PhotoId(3))); - assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(PhotoId(1))); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1))); assert_eq!(p.upcoming(2), vec![], "nothing after the last"); assert_eq!(p.tick(3.0, all_ready), Tick::Ended); assert!(p.is_ended()); // → past the last one ends it too let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); - assert_eq!(p.step(1, 0.5), Tick::Advanced(PhotoId(2))); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2))); assert_eq!(p.step(1, 0.6), Tick::Ended); // with repeat: it goes on let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); @@ -543,7 +706,7 @@ mod tests { assert_eq!(p.remaining(100.0), 1.0); assert!(!p.toggle_pause(100.0)); assert_eq!(p.tick(100.9, all_ready), Tick::Idle); - assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(PhotoId(2)), "1 s left after resuming"); + assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming"); // pausing mid-fade freezes the fade p.toggle_pause(101.25); assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); @@ -554,25 +717,25 @@ mod tests { #[test] fn a_transition_waits_for_the_next_slide_to_be_ready() { let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); - assert_eq!(p.upcoming(2), vec![PhotoId(2), PhotoId(3)], "what to prepare"); - let not_two = |id: PhotoId| id != PhotoId(2); - assert_eq!(p.tick(2.0, not_two), Tick::Waiting(PhotoId(2))); - assert_eq!(p.frame(3.0), Frame { current: PhotoId(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); - assert_eq!(p.waiting_for(), Some(PhotoId(2))); - assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(PhotoId(2))); - assert_eq!(p.frame(3.5).from, Some(PhotoId(1)), "the fade starts once it is ready"); + assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare"); + let not_two = |id: Item| id != ph(2); + assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2))); + assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); + assert_eq!(p.waiting_for(), Some(ph(2))); + assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready"); assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); } #[test] fn a_slide_s_time_starts_once_it_is_ready() { let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); - let none = |_: PhotoId| false; + let none = |_: Item| false; assert_eq!(p.tick(1.0, none), Tick::Idle); assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); assert_eq!(p.remaining(5.0), 2.0); assert_eq!(p.tick(6.0, all_ready), Tick::Idle); - assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(PhotoId(2)), "2 s from when it could be shown"); + assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown"); } #[test] @@ -619,4 +782,138 @@ mod tests { assert_eq!(p.tick(5.0, all_ready), Tick::Idle); assert_eq!(p.frame(5.0).from, None); } + + fn titled(n: u64, s: PlaybackSettings, intro: Option<f64>, ending: Option<f64>) -> Playback { + Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending) + } + + #[test] + fn titles_play_once_around_the_photos_for_their_own_durations() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(3.0)); + assert_eq!(p.current(), Item::INTRO, "the intro comes first"); + assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos"); + assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]); + assert_eq!(p.tick(1.99, all_ready), Tick::Idle); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s"); + let f = p.frame(2.5); + assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide"); + assert!((f.progress - 0.5).abs() < 1e-6); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide"); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING)); + assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending))); + assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in"); + assert_eq!(p.tick(16.99, all_ready), Tick::Idle); + assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends"); + // without titles nothing changes + let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None); + assert_eq!(p.current(), ph(1)); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(2.0, all_ready), Tick::Ended); + } + + #[test] + fn a_fade_never_outlasts_a_short_title() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) }; + let mut p = titled(1, s, Some(0.5), Some(0.5)); + assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1))); + assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s"); + assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING)); + assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade"); + assert_eq!(p.frame(5.0).from, None); + assert_eq!(p.tick(5.0, all_ready), Tick::Ended); + } + + #[test] + fn with_repeat_the_intro_plays_once_and_the_ending_never() { + let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + for k in 1..12 { + p.tick(k as f64, all_ready); + seen.push(p.current()); + assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating"); + } + assert_eq!(seen[0], Item::INTRO); + assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}"); + assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]); + } + + #[test] + fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() { + for seed in [1, 2, 3] { + let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + let mut k = 1.0; + while p.tick(k, all_ready) != Tick::Ended { + seen.push(p.current()); + k += 1.0; + } + assert_eq!(seen.len(), 12, "{seen:?}"); + assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}"); + let mut photos: Vec<PhotoId> = seen[1..11].iter().filter_map(|i| i.photo()).collect(); + assert_ne!(photos, ids(10), "shuffled"); + photos.sort(); + assert_eq!(photos, ids(10), "every photo once, between the titles"); + } + } + + #[test] + fn arrows_step_across_the_titles() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(2.0)); + assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there"); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1))); + assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro"); + assert_eq!(p.remaining(0.6), 2.0, "with its full time"); + assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.position(), (3, 3)); + assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo"); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show"); + assert_eq!(p.step(1, 2.0), Tick::Ended); + // repeating: ← from the first photo of a later pass wraps to the last, not the intro + let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None); + assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again"); + assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current())); + assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current())); + } + + #[test] + fn a_title_pauses_and_waits_like_a_slide() { + let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None); + let not_intro = |i: Item| i != Item::INTRO; + assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 3.0); + assert!(p.toggle_pause(6.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle); + assert_eq!(p.remaining(100.0), 2.0, "paused on the intro"); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(101.9, all_ready), Tick::Idle); + let not_one = |i: Item| i != ph(1); + assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held"); + assert_eq!(p.frame(102.5).current, Item::INTRO); + assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1))); + } + + #[test] + fn hostile_title_durations_are_clamped() { + let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX)); + let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0)); + // a single photo between titles, repeating: the photo holds after the intro + let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0)); + assert_eq!(p.upcoming(3), vec![ph(1)]); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(3), vec![]); + assert_eq!(p.tick(9.0, all_ready), Tick::Idle); + assert_eq!(Title::parse(" Ending "), Some(Title::Ending)); + assert_eq!(Title::parse("credits"), None); + } } diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests_slide.rs index 3e41f16ee..7f57de970 100644 --- a/crates/engine/src/tests_slide.rs +++ b/crates/engine/src/tests_slide.rs @@ -227,3 +227,86 @@ fn slide_preview_draws_the_overlays() { assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays"); } } + +#[test] +fn slide_titles_are_validated_persist_and_preview() { + let lib = temp_dir("titles"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.titles", &json!({})).unwrap(); + assert_eq!((r["intro"]["enabled"].as_bool(), r["ending"]["enabled"].as_bool()), (Some(false), Some(false)), "off by default: {r}"); + assert_eq!(r["intro"]["content"], "identityPlate"); + assert_eq!(r["intro"]["duration"], serde_json::Value::Null, "the slide duration"); + let r = s + .execute( + "slideshow.titles", + &json!({"intro": {"enabled": true, "color": "#102030", "content": "text", "text": "Iceland 2026", "scale": 0.12, "duration": 3}, "ending": {"enabled": true, "color": [255, 255, 255]}}), + ) + .unwrap(); + assert_eq!((r["intro"]["color"].as_str(), r["intro"]["duration"].as_f64()), (Some("#102030"), Some(3.0))); + assert_eq!(r["ending"]["color"], "#ffffff"); + // a bad value changes nothing, the good keys beside it included + for bad in [ + json!({"intro": {"enabled": false, "scale": "huge"}}), + json!({"ending": {"content": "credits"}}), + json!({"intro": {"duration": -1, "color": "blue"}}), + json!({"middle": {}}), + ] { + assert!(s.execute("slideshow.titles", &bad).is_err(), "{bad}"); + } + assert!(s.slide_titles.intro.enabled); + // out of range: clamped + let r = s.execute("slideshow.titles", &json!({"ending": {"scale": 4, "duration": 1e6}})).unwrap(); + assert_eq!((r["ending"]["scale"].as_f64(), r["ending"]["duration"].as_f64()), (Some(0.5), Some(crate::slideshow::SLIDE_MAX))); + // a plate image that can't be read is reported + s.execute("slideshow.overlays", &json!({"identityPlate": {"image": "/no/such/plate.png"}})).unwrap(); + let r = s.execute("slideshow.titles", &json!({})).unwrap(); + let w = r["warnings"].as_array().unwrap(); + assert!(w.iter().any(|w| w.as_str().unwrap().contains("ending screen")), "{r}"); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.slide_titles.intro.text, "Iceland 2026", "saved with the library"); + assert_eq!(s.slide_titles.durations(4.0), (Some(3.0), Some(crate::slideshow::SLIDE_MAX))); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideTitles": {"intro": {"enabled": true, "scale": -4, "duration": 1e99}}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.slide_titles.intro.enabled); + assert_eq!((s.slide_titles.intro.scale, s.slide_titles.intro.duration), (crate::slide_overlay::SIZE_MIN, Some(crate::slideshow::SLIDE_MAX))); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_renders_a_title_screen() { + let mut s = Session::with_demo(); + s.execute( + "slideshow.titles", + &json!({"ending": {"color": "#00ff00", "content": "text", "text": "Fin", "overrideColor": true, "textColor": "#ff0000", "scale": 0.2}}), + ) + .unwrap(); + let r = s.execute("slideshow.preview", &json!({"title": "ending", "width": 400, "height": 250})).unwrap(); + assert_eq!( + (r["title"].as_str(), r["enabled"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), + (Some("ending"), Some(false), Some(400), Some(250)) + ); + let png = base64_decode(r["png"].as_str().unwrap()); + assert_eq!(png_size(&png), (400, 250)); + let b: Vec<f64> = r["plate"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!((b[3] - b[1] - 50.0).abs() < 1.5, "20 % of the 250 px short side: {b:?}"); + assert!(((b[0] + b[2]) / 2.0 - 200.0).abs() < 1.0 && ((b[1] + b[3]) / 2.0 - 125.0).abs() < 1.0, "centred: {b:?}"); + // the same pixels as playback composes + let img = s.title_compose(crate::slideshow::Title::Ending, 400, 250).compose(); + assert_eq!(img.get(3, 3), [0, 255, 0, 255]); + assert!(img.data.iter().any(|p| p[0] > 200 && p[1] < 60), "the red text"); + // no photo needed; refused: an unknown title, an id with it, bad sizes + s.execute("library.select", &json!({"ids": []})).unwrap(); + assert!(s.execute("slideshow.preview", &json!({"title": "intro"})).is_ok()); + for bad in [json!({"title": "credits"}), json!({"title": 1}), json!({"title": "intro", "id": 1}), json!({"title": "intro", "width": 3})] { + assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 498d7e312..c98bd2f41 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2077,5 +2077,21 @@ "Add text overlay": "Textüberlagerung hinzufügen", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Anderer Text erscheint wie eingegeben.", "Use export watermark": "Export-Wasserzeichen verwenden", - "The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert." + "The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert.", + "Titles": "Titel", + "Intro screen": "Intro-Bildschirm", + "Ending screen": "Abschlussbildschirm", + "Shows": "Zeigt", + "Colour only": "Nur Farbe", + "Override colour": "Farbe überschreiben", + "Own duration (off: the slide duration)": "Eigene Dauer (aus: die Dia-Dauer)", + "Duration": "Dauer", + "Title text": "Titeltext", + "Intro": "Intro", + "Ending": "Abschluss", + "End": "Ende", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Das Intro läuft einmal vor dem ersten Foto, der Abschluss nach dem letzten Foto, wenn Wiederholen aus ist. Die Erkennungstafel ist die unter Überlagerungen eingestellte. Titel werden wie Dias überblendet, und die zufällige Reihenfolge verschiebt sie nie.", + "This screen is off: turn it on under Titles to play it.": "Dieser Bildschirm ist aus: Schalten Sie ihn unter Titel ein, um ihn abzuspielen.", + "The intro screen, before the first photo.": "Der Intro-Bildschirm, vor dem ersten Foto.", + "The ending screen, after the last photo.": "Der Abschlussbildschirm, nach dem letzten Foto." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 6436a56e6..e18046491 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1922,5 +1922,21 @@ "Add text overlay": "Añadir superposición de texto", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. El resto del texto se muestra tal cual.", "Use export watermark": "Usar la marca de agua de exportación", - "The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella." + "The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella.", + "Titles": "Títulos", + "Intro screen": "Pantalla de introducción", + "Ending screen": "Pantalla final", + "Shows": "Muestra", + "Colour only": "Solo color", + "Override colour": "Reemplazar color", + "Own duration (off: the slide duration)": "Duración propia (desactivada: la de la diapositiva)", + "Duration": "Duración", + "Title text": "Texto del título", + "Intro": "Introducción", + "Ending": "Final", + "End": "Fin", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "La introducción se reproduce una vez antes de la primera foto; el final, después de la última foto cuando Repetir está desactivado. La placa de identidad es la definida en Superposiciones. Los títulos se funden como las diapositivas y el orden aleatorio nunca los mueve.", + "This screen is off: turn it on under Titles to play it.": "Esta pantalla está desactivada: actívela en Títulos para reproducirla.", + "The intro screen, before the first photo.": "La pantalla de introducción, antes de la primera foto.", + "The ending screen, after the last photo.": "La pantalla final, después de la última foto." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 466678f24..5eab00c00 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1922,5 +1922,21 @@ "Add text overlay": "Ajouter une incrustation de texte", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Jetons : {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Le reste du texte s'affiche tel quel.", "Use export watermark": "Utiliser le filigrane d'exportation", - "The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle." + "The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle.", + "Titles": "Titres", + "Intro screen": "Écran d'introduction", + "Ending screen": "Écran de fin", + "Shows": "Affiche", + "Colour only": "Couleur seule", + "Override colour": "Remplacer la couleur", + "Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)", + "Duration": "Durée", + "Title text": "Texte du titre", + "Intro": "Intro", + "Ending": "Fin", + "End": "Fin", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'introduction est lue une fois avant la première photo ; l'écran de fin après la dernière photo lorsque Répéter est désactivé. La plaque d'identification est celle définie sous Incrustations. Les titres apparaissent en fondu comme les diapositives et l'ordre aléatoire ne les déplace jamais.", + "This screen is off: turn it on under Titles to play it.": "Cet écran est désactivé : activez-le sous Titres pour le lire.", + "The intro screen, before the first photo.": "L'écran d'introduction, avant la première photo.", + "The ending screen, after the last photo.": "L'écran de fin, après la dernière photo." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index dd9261e05..81c57d3e1 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1924,5 +1924,21 @@ "Add text overlay": "テキストオーバーレイを追加", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "トークン: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。その他の文字は入力どおりに表示されます。", "Use export watermark": "書き出しの透かしを使用", - "The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。" + "The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。", + "Titles": "タイトル", + "Intro screen": "イントロ画面", + "Ending screen": "エンディング画面", + "Shows": "表示内容", + "Colour only": "カラーのみ", + "Override colour": "カラーを上書き", + "Own duration (off: the slide duration)": "個別の再生時間(オフ:スライドの再生時間)", + "Duration": "再生時間", + "Title text": "タイトルテキスト", + "Intro": "イントロ", + "Ending": "エンディング", + "End": "終了", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "イントロは最初の写真の前に 1 回再生され、エンディングは繰り返しがオフのとき最後の写真の後に再生されます。ID プレートはオーバーレイで設定したものです。タイトルはスライドと同様にフェードし、ランダム順でも位置は変わりません。", + "This screen is off: turn it on under Titles to play it.": "この画面はオフです。再生するにはタイトルでオンにしてください。", + "The intro screen, before the first photo.": "イントロ画面(最初の写真の前)。", + "The ending screen, after the last photo.": "エンディング画面(最後の写真の後)。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 6dd3944e5..9be14b0d2 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1922,5 +1922,21 @@ "Add text overlay": "Adicionar sobreposição de texto", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. O restante do texto aparece como digitado.", "Use export watermark": "Usar a marca d'água de exportação", - "The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela." + "The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela.", + "Titles": "Títulos", + "Intro screen": "Tela de abertura", + "Ending screen": "Tela de encerramento", + "Shows": "Mostra", + "Colour only": "Somente cor", + "Override colour": "Substituir cor", + "Own duration (off: the slide duration)": "Duração própria (desativada: a duração do slide)", + "Duration": "Duração", + "Title text": "Texto do título", + "Intro": "Abertura", + "Ending": "Encerramento", + "End": "Fim", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "A abertura é exibida uma vez antes da primeira foto; o encerramento, após a última foto quando Repetir está desativado. A placa de identidade é a definida em Sobreposições. Os títulos fazem transição como os slides e a ordem aleatória nunca os move.", + "This screen is off: turn it on under Titles to play it.": "Esta tela está desativada: ative-a em Títulos para exibi-la.", + "The intro screen, before the first photo.": "A tela de abertura, antes da primeira foto.", + "The ending screen, after the last photo.": "A tela de encerramento, após a última foto." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fc96f4c44..34cb5f33c 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1637,5 +1637,21 @@ "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токены: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Остальной текст выводится как написан.", "Use export watermark": "Использовать водяной знак экспорта", "The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него.", - "© Your Name": "© Ваше имя" + "© Your Name": "© Ваше имя", + "Titles": "Титры", + "Intro screen": "Вступительный экран", + "Ending screen": "Заключительный экран", + "Shows": "Показывает", + "Colour only": "Только цвет", + "Override colour": "Заменить цвет", + "Own duration (off: the slide duration)": "Своя длительность (выкл.: длительность слайда)", + "Duration": "Длительность", + "Title text": "Текст титра", + "Intro": "Вступление", + "Ending": "Финал", + "End": "Конец", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступление показывается один раз перед первым фото, финал — после последнего фото, если повтор выключен. Фирменная табличка — та, что задана в разделе «Наложения». Титры появляются и исчезают как слайды, а случайный порядок их не перемещает.", + "This screen is off: turn it on under Titles to play it.": "Этот экран выключен: включите его в разделе «Титры», чтобы показывать.", + "The intro screen, before the first photo.": "Вступительный экран, перед первым фото.", + "The ending screen, after the last photo.": "Заключительный экран, после последнего фото." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 1b530c8d6..2b41d5769 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2116,5 +2116,21 @@ "Add text overlay": "Додати текстове накладання", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токени: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Решта тексту виводиться як написано.", "Use export watermark": "Використати водяний знак експорту", - "The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього." + "The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього.", + "Titles": "Титри", + "Intro screen": "Вступний екран", + "Ending screen": "Завершальний екран", + "Shows": "Показує", + "Colour only": "Лише колір", + "Override colour": "Замінити колір", + "Own duration (off: the slide duration)": "Власна тривалість (вимк.: тривалість слайда)", + "Duration": "Тривалість", + "Title text": "Текст титру", + "Intro": "Вступ", + "Ending": "Фінал", + "End": "Кінець", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступ показується один раз перед першим фото, фінал — після останнього фото, якщо повтор вимкнено. Фірмова табличка — та, що задана в розділі «Накладання». Титри з'являються й зникають як слайди, а випадковий порядок їх не переміщує.", + "This screen is off: turn it on under Titles to play it.": "Цей екран вимкнено: увімкніть його в розділі «Титри», щоб показувати.", + "The intro screen, before the first photo.": "Вступний екран, перед першим фото.", + "The ending screen, after the last photo.": "Завершальний екран, після останнього фото." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b5c7d1d0d..9dfa236e0 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1922,5 +1922,21 @@ "Add text overlay": "添加文本叠加", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "标记:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原样显示。", "Use export watermark": "使用导出水印", - "The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。" + "The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。", + "Titles": "标题", + "Intro screen": "片头画面", + "Ending screen": "片尾画面", + "Shows": "显示", + "Colour only": "仅颜色", + "Override colour": "覆盖颜色", + "Own duration (off: the slide duration)": "单独时长(关闭:使用幻灯片时长)", + "Duration": "时长", + "Title text": "标题文字", + "Intro": "片头", + "Ending": "片尾", + "End": "结束", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片头在第一张照片之前播放一次;关闭“重复”时,片尾在最后一张照片之后播放。身份标识即“叠加”中设置的那个。标题与幻灯片一样淡入淡出,随机顺序不会移动它们。", + "This screen is off: turn it on under Titles to play it.": "此画面已关闭:在“标题”中打开即可播放。", + "The intro screen, before the first photo.": "片头画面,在第一张照片之前。", + "The ending screen, after the last photo.": "片尾画面,在最后一张照片之后。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f6746891c..c217a16d6 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1922,5 +1922,21 @@ "Add text overlay": "新增文字疊加", "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "標記:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原樣顯示。", "Use export watermark": "使用匯出浮水印", - "The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。" + "The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。", + "Titles": "標題", + "Intro screen": "片頭畫面", + "Ending screen": "片尾畫面", + "Shows": "顯示", + "Colour only": "僅顏色", + "Override colour": "覆寫顏色", + "Own duration (off: the slide duration)": "個別時長(關閉:使用幻燈片時長)", + "Duration": "時長", + "Title text": "標題文字", + "Intro": "片頭", + "Ending": "片尾", + "End": "結束", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片頭在第一張照片之前播放一次;關閉「重複」時,片尾在最後一張照片之後播放。身分標識即「疊加」中設定的那個。標題與幻燈片一樣淡入淡出,隨機順序不會移動它們。", + "This screen is off: turn it on under Titles to play it.": "此畫面已關閉:在「標題」中開啟即可播放。", + "The intro screen, before the first photo.": "片頭畫面,在第一張照片之前。", + "The ending screen, after the last photo.": "片尾畫面,在最後一張照片之後。" } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 4acd6dedc..363c53651 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -29,6 +29,7 @@ pub mod second; pub mod settings; pub mod slide_layout; pub mod slide_overlays; +pub mod slide_titles; pub mod slideshow; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 8a7d9e8d8..ce87d94cb 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -659,6 +659,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { super::slide_layout::sections(app, ui, t); ui.add_space(6.0); super::slide_overlays::sections(app, ui, t); + ui.add_space(6.0); + super::slide_titles::sections(app, ui, t); }); }); ui.add_space(12.0); diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs index 2f5ef0883..4a5bde3fa 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -171,6 +171,11 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { register(ui.ctx(), "slideshow:preview", rect); let p = ui.painter_at(rect.expand(1.0)); p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); + super::slide_titles::preview_choice(ui); + if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { + title_preview(app, ui, t, rect, title); + return; + } let Some(id) = app.session.active() else { p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a photo to preview its slide"), t.font(11.5), Color32::from_gray(200)); @@ -231,6 +236,47 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview."); } +/// The preview showing title screen `title` (composed off the UI thread like the photo's slide, +/// once its settings have been still for [`DEBOUNCE`] seconds). +fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: Rect, title: lightcraft_engine::slideshow::Title) { + let ppp = ui.ctx().pixels_per_point(); + let (pw, ph) = (((rect.width() * ppp).round() as usize).max(16), ((rect.height() * ppp).round() as usize).max(16)); + let compose = app.session.title_compose(title, pw, ph); + let key = compose.key(); + let now = ui.input(|i| i.time); + let mem = egui::Id::new("settingsSlideshowTitlePreviewKey"); + let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); + if last != key { + ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now))); + } + let still = if last == key { now - since } else { 0.0 }; + // a title render in the slot (a photo's has its id): shown until the new one lands + let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)); + if current.is_none() || still >= DEBOUNCE { + app.renderer.request_title(Slot::SlidePreview, compose, 90); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE))); + } + let p = ui.painter_at(rect.expand(1.0)); + match app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)) { + Some(tex) => { + let drawn = super::detail::fit_texture_rect(rect, tex.size); + p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + let c = app.session.slide_titles.screen(title).color; + p.rect_filled(rect, 0.0, Color32::from_rgb(c[0], c[1], c[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); + } + } + let hint_text = match (title, app.session.slide_titles.screen(title).enabled) { + (_, false) => "This screen is off: turn it on under Titles to play it.", + (lightcraft_engine::slideshow::Title::Intro, true) => "The intro screen, before the first photo.", + (lightcraft_engine::slideshow::Title::Ending, true) => "The ending screen, after the last photo.", + }; + hint(ui, t, hint_text); +} + /// The default layout (for the Reset button). pub fn reset(app: &mut LightcraftApp) { let d = SlideSettings::default().to_json(); diff --git a/crates/ui-egui/src/panels/slide_titles.rs b/crates/ui-egui/src/panels/slide_titles.rs new file mode 100644 index 000000000..257790995 --- /dev/null +++ b/crates/ui-egui/src/panels/slide_titles.rs @@ -0,0 +1,168 @@ +//! Settings ▸ Slideshow ▸ Titles (Lightroom Classic's Slideshow Titles panel): the intro and +//! ending screens, each on or off, with a background colour and, centred on it, the identity plate +//! of the Overlays section or a line of text, at a scale, for its own duration. +//! +//! Every control goes through `slideshow.titles` (validated there, saved with the library), like +//! the Layout and Overlays sections beside it. Changing a screen's settings switches the live +//! preview ([`super::slide_layout::preview`]) to that screen; its Photo / Intro / Ending buttons +//! pick what it shows ([`preview_shows`]). + +use lightcraft_engine::slide_overlay::{PLATE_SIZE_MAX, SIZE_MIN}; +use lightcraft_engine::slide_title::{TitleContent, TitleScreen}; +use lightcraft_engine::slideshow::{SLIDE_MAX, SLIDE_MIN, Title}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Where the preview's choice lives (egui memory): `None` = the photo's slide. +fn preview_key() -> egui::Id { + egui::Id::new("settingsSlideshowPreviewShows") +} + +/// What the Settings preview shows: a title screen, or (`None`) the active photo's slide. +pub fn preview_shows(ctx: &egui::Context) -> Option<Title> { + ctx.data(|d| d.get_temp::<Option<Title>>(preview_key())).flatten() +} + +pub fn set_preview_shows(ctx: &egui::Context, t: Option<Title>) { + ctx.data_mut(|d| d.insert_temp(preview_key(), t)); +} + +/// The Photo / Intro / Ending buttons over the preview (`button:settingsSlideshowPreviewShows-{0,1,2}`). +pub fn preview_choice(ui: &mut egui::Ui) { + let mut v = preview_shows(ui.ctx()); + let opts = [(None, "Photo"), (Some(Title::Intro), "Intro"), (Some(Title::Ending), "Ending")]; + ui.horizontal(|ui| { + if choices(ui, "settingsSlideshowPreviewShows", &opts, &mut v) { + set_preview_shows(ui.ctx(), v); + } + }); +} + +/// Change `key` of screen `t` (and show that screen in the preview). +fn set(app: &mut LightcraftApp, ctx: &egui::Context, t: Title, key: &str, v: Value) { + let _ = app.run("slideshow.titles", json!({ t.id(): { key: v } })); + set_preview_shows(ctx, Some(t)); +} + +/// A slider (`slider:{id}`) for `key` of screen `t`; `k` scales the value for display. +#[allow(clippy::too_many_arguments)] +fn slider( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + t: Title, + key: &str, + value: f64, + range: std::ops::RangeInclusive<f64>, + k: f64, + suffix: &str, +) { + let mut v = value * k; + let r = ui.add(egui::Slider::new(&mut v, (range.start() * k)..=(range.end() * k)).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let p = json!({ t.id(): { key: v / k } }); + if r.changed() { + if let Ok(next) = app.session.slide_titles.merged(&p) { + app.session.slide_titles = next; + } + set_preview_shows(ui.ctx(), Some(t)); + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.titles", p); + } +} + +/// A colour button (`button:{id}`) for `key` of screen `t`. +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: &str, value: [u8; 3]) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, ui.ctx(), t, key, json!(c)); + } +} + +/// The screen's text field (`field:{id}`), saved when the edit ends. +fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, value: &str) { + let mem = egui::Id::new(("slideshow-title-field", id)); + let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); + let widget = format!("field:{id}"); + let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr("Title text")).width(ui.available_width().clamp(80.0, 260.0)).show(ui); + if r.editing { + ui.data_mut(|d| d.insert_temp(mem, s.clone())); + } else { + ui.data_mut(|d| d.remove::<String>(mem)); + } + if r.committed() && s != value { + set(app, ui.ctx(), t, "text", json!(s)); + } +} + +/// One screen's controls; `name` prefixes the widget ids (`slideshowIntro…`). +fn screen(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, title: Title, s: &TitleScreen) { + let (name, content_id, label) = match title { + Title::Intro => ("slideshowIntro", "settingsSlideshowIntroContent", "Intro screen"), + Title::Ending => ("slideshowEnding", "settingsSlideshowEndingContent", "Ending screen"), + }; + let mut on = s.enabled; + if check(ui, &format!("settings.{name}"), &mut on, label) { + set(app, ui.ctx(), title, "enabled", json!(on)); + } + if !on { + return; + } + row(ui, t, "Colour", |ui| colour(app, ui, &format!("{name}Color"), title, "color", s.color)); + row(ui, t, "Shows", |ui| { + let mut c = s.content; + let opts = [(TitleContent::IdentityPlate, "Identity plate"), (TitleContent::Text, "Text"), (TitleContent::None, "Colour only")]; + if choices(ui, content_id, &opts, &mut c) { + set(app, ui.ctx(), title, "content", json!(c.id())); + } + }); + if s.content == TitleContent::None { + return; + } + if s.content == TitleContent::Text { + row(ui, t, "Text", |ui| text(app, ui, &format!("{name}Text"), title, &s.text)); + } + row(ui, t, "Scale", |ui| slider(app, ui, &format!("{name}Scale"), title, "scale", s.scale, SIZE_MIN..=PLATE_SIZE_MAX, 100.0, " %")); + ui.horizontal(|ui| { + let mut over = s.override_color; + if check(ui, &format!("settings.{name}Override"), &mut over, "Override colour") { + set(app, ui.ctx(), title, "overrideColor", json!(over)); + } + if over { + colour(app, ui, &format!("{name}TextColor"), title, "textColor", s.text_color); + } + }); + let mut own = s.duration.is_some(); + if check(ui, &format!("settings.{name}OwnDuration"), &mut own, "Own duration (off: the slide duration)") { + let d = if own { json!(app.session.slideshow.slide_duration) } else { Value::Null }; + set(app, ui.ctx(), title, "duration", d); + } + if let Some(d) = s.duration { + row(ui, t, "Duration", |ui| slider(app, ui, &format!("{name}Duration"), title, "duration", d, SLIDE_MIN..=SLIDE_MAX, 1.0, " s")); + } +} + +/// The Titles section. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s = app.session.slide_titles.clone(); + heading(ui, t, "Titles"); + screen(app, ui, t, Title::Intro, &s.intro); + screen(app, ui, t, Title::Ending, &s.ending); + hint( + ui, + t, + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.", + ); + let warnings = s.warnings(&app.session.slide_overlays); + for w in warnings { + ui.label(egui::RichText::new(w).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs index 42a514d5f..a0f7ba09f 100644 --- a/crates/ui-egui/src/panels/slideshow.rs +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -16,10 +16,16 @@ //! plain layout, else the slide composed with the library's layout (`slideshow.layout`: margins, //! zoom to fill, stroke, shadow, backdrop) by `lightcraft-slideshow` on the render worker. A //! composed slide is a screen-sized texture; the playback (fades, controls) is the same. +//! +//! Title screens (`slideshow.titles`: the intro before the first photo, the ending after the last +//! one) are slides of the same timeline ([`Item::Title`]): composed at the screen's pixels on a +//! render worker ([`crate::render::Renderer::request_title`]), prepared ahead, waited for and +//! faded like photo slides. They never move the selection, and the controls show "Intro" / "End" +//! in place of "n / N" while one is up (titles aren't photos, so N stays the number of photos). use egui::{Align2, Color32, Pos2, Rect, pos2, vec2}; use lightcraft_catalog::PhotoId; -use lightcraft_engine::slideshow::{Playback, Tick}; +use lightcraft_engine::slideshow::{Item, Playback, Tick, Title}; use lightcraft_pipeline::geometry::Frame; use serde_json::{Value, json}; @@ -43,13 +49,15 @@ pub struct Run { pub pointer: Option<Pos2>, /// Slides no render can be made for (gone from the catalog, nothing to read): shown with /// whatever stand-in there is, never waited for. - pub unrenderable: Vec<PhotoId>, + pub unrenderable: Vec<Item>, } /// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?, /// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's /// playback settings; the keys given change them for this run only (`interval` = slideDuration, -/// `seed` fixes the random order). +/// `seed` fixes the random order). The intro and ending screens that are on (`slideshow.titles`) +/// play before the first photo and after the last one. With no photos in view there is nothing +/// to show (titles alone don't make a slideshow). pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result<Value, String> { let mut overrides = p.as_object().cloned().unwrap_or_default(); if let Some(i) = overrides.remove("interval") { @@ -61,11 +69,13 @@ pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result<Value, Stri }; let settings = app.session.slideshow.merged(&Value::Object(overrides)).map_err(|e| format!("view.slideshow: {e}"))?; let photos = app.session.visible_cloned(); + let (intro, ending) = app.session.slide_titles.durations(settings.slide_duration); let Some(play) = Playback::new(photos, app.session.active(), settings, seed, now) else { return Err("no photos to show".into()); }; - let first = play.current(); - if app.session.active() != Some(first) { + let play = play.with_titles(intro, ending); + let first = play.current_photo().or_else(|| play.upcoming(1).first().and_then(|i| i.photo())); + if let Some(first) = first.filter(|f| app.session.active() != Some(*f)) { let _ = app.run("library.select", json!({"ids": [first.0]})); } let (_, slides) = play.position(); @@ -109,13 +119,14 @@ pub fn state(play: &Playback, now: f64) -> Value { let (n, of) = play.position(); let f = play.frame(now); json!({ - "current": play.current().0, + "current": play.current_photo().map(|p| p.0), + "title": play.title().map(Title::id), "slide": n, "of": of, "paused": play.is_paused(), "fading": f.from.is_some(), "progress": f.progress, - "waitingFor": play.waiting_for().map(|p| p.0), + "waitingFor": play.waiting_for().map(|i| i.photo().map_or_else(|| json!(i.title().map(Title::id)), |p| json!(p.0))), "remaining": play.remaining(now), }) } @@ -123,7 +134,7 @@ pub fn state(play: &Playback, now: f64) -> Value { /// Act on what the timeline did: follow the slide with the selection, or end. fn after(app: &mut LightcraftApp, t: Tick) { match t { - Tick::Advanced(id) if app.session.active() != Some(id) => { + Tick::Advanced(Item::Photo(id)) if app.session.active() != Some(id) => { let _ = app.run("library.select", json!({"ids": [id.0]})); } Tick::Ended => end(app), @@ -131,13 +142,15 @@ fn after(app: &mut LightcraftApp, t: Tick) { } } -/// Can `id` be shown as a slide now: its texture exists (or it can't be rendered at all, and its -/// stand-in is all there will be: playback goes on rather than waiting forever). -pub fn ready(app: &LightcraftApp, id: PhotoId) -> bool { - app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id) - || app.renderer.failure(Slot::Slide(id)).is_some() - || app.session.catalog.photo(id).is_none() - || app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&id)) +/// Can `item` be shown as a slide now: its texture exists (or it can't be rendered at all, and +/// its stand-in is all there will be: playback goes on rather than waiting forever). +pub fn ready(app: &LightcraftApp, item: Item) -> bool { + let slot = Slot::Slide(item); + let rendered = match item { + Item::Photo(id) => app.renderer.textures.get(&slot).is_some_and(|t| t.photo == id) || app.session.catalog.photo(id).is_none(), + Item::Title(_) => app.renderer.textures.contains_key(&slot), + }; + rendered || app.renderer.failure(slot).is_some() || app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&item)) } /// Advance a running slideshow (every frame, before drawing). @@ -211,7 +224,7 @@ pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) { } /// The slides to keep rendered now, most wanted first, with their render priority. -fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> { +fn wanted(play: &Playback, now: f64) -> Vec<(Item, u32)> { let f = play.frame(now); let mut v = vec![(f.current, 100)]; if let Some(from) = f.from { @@ -234,12 +247,14 @@ fn wanted(play: &Playback, now: f64) -> Vec<(PhotoId, u32)> { v } -/// The texture to draw for slide `id`: its own render, else the loupe's or a thumbnail (a slide -/// stepped to with ←/→ shows at once, sharp when its render lands). -fn texture(app: &LightcraftApp, id: PhotoId) -> Option<&crate::render::Tex> { +/// The texture to draw for slide `item`: its own render, else (a photo) the loupe's or a +/// thumbnail (a slide stepped to with ←/→ shows at once, sharp when its render lands). A title +/// screen has nothing to stand in for it: the black canvas shows until it lands. +fn texture(app: &LightcraftApp, item: Item) -> Option<&crate::render::Tex> { + let Item::Photo(id) = item else { return app.renderer.textures.get(&Slot::Slide(item)) }; app.renderer .textures - .get(&Slot::Slide(id)) + .get(&Slot::Slide(item)) .filter(|t| t.photo == id) .or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)) .or_else(|| app.renderer.thumb(id)) @@ -284,10 +299,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let want = wanted(&run.play, now); let frame = run.play.frame(now); let mut unrenderable = Vec::new(); - for (id, priority) in &want { - match slide_job(app, *id, canvas, ppp, side) { - Some(job) => app.renderer.request(Slot::Slide(*id), job, *priority), - None => unrenderable.push(*id), + for (item, priority) in &want { + match *item { + Item::Title(t) => { + let (w, h) = slide_px(canvas, ppp, side); + let compose = app.session.title_compose(t, w, h); + app.renderer.request_title(Slot::Slide(*item), compose, *priority); + } + Item::Photo(id) => match slide_job(app, id, canvas, ppp, side) { + Some(job) => app.renderer.request(Slot::Slide(*item), job, *priority), + None => unrenderable.push(*item), + }, } } if let Some(run) = app.ui.slideshow.as_mut() { @@ -297,12 +319,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } - let keep: Vec<PhotoId> = want.iter().map(|(id, _)| *id).collect(); + let keep: Vec<Item> = want.iter().map(|(id, _)| *id).collect(); app.renderer.release_slides(&keep); let p = ui.painter_at(canvas); p.rect_filled(canvas, 0.0, Color32::BLACK); let rect_of = |t: &crate::render::Tex| super::detail::fit_texture_rect(canvas, t.size); - let draw = |id: PhotoId, alpha: f32| -> Option<Rect> { + let draw = |id: Item, alpha: f32| -> Option<Rect> { let t = texture(app, id)?; let r = rect_of(t); p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(alpha.clamp(0.0, 1.0))); @@ -369,6 +391,7 @@ fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64) } let paused = run.play.is_paused(); let (n, of) = run.play.position(); + let title = run.play.title(); // fade out over the last half second let alpha = (left / 0.5).clamp(0.0, 1.0) as f32; let p = ui.painter_at(canvas); @@ -390,7 +413,11 @@ fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64) fire = Some("slideshow.next"); } let (r, _) = child.allocate_exact_size(vec2(118.0, 36.0), egui::Sense::hover()); - let label = format!("{n} / {of}"); + let label = match title { + Some(Title::Intro) => crate::i18n::tr("Intro").to_string(), + Some(Title::Ending) => crate::i18n::tr("End").to_string(), + None => format!("{n} / {of}"), + }; child.painter().text(r.center(), Align2::CENTER_CENTER, label, t.font(13.0), Color32::from_gray(230).gamma_multiply(alpha)); if icon_button(&mut child, "slideshowEnd", Icon::Close, size, false, true, "End slideshow (Esc)").clicked() { fire = Some("slideshow.end"); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 1a7d1f4fc..931b87b32 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -53,8 +53,8 @@ pub enum Slot { /// The second window's view of the active photo. Second, /// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see - /// [`crate::panels::slideshow`]). - Slide(PhotoId), + /// [`crate::panels::slideshow`]): a photo's, or a title screen. + Slide(lightcraft_engine::slideshow::Item), /// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being /// edited). SlidePreview, @@ -440,6 +440,24 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display))); } + /// Render a slideshow title screen into `slot` (once per screen key, like [`Self::request`]): + /// a slide with no photo, composed on a worker (on wasm: the job pool's inline runs, not the + /// offload, which only knows photo renders). + pub fn request_title(&mut self, slot: Slot, compose: Arc<lightcraft_engine::slide_title::TitleCompose>, priority: u32) { + if matches!(slot, Slot::Slide(_)) { + self.slides_live = true; + } + let key = compose.key(); + if !self.request_needed(slot, key, priority) { + return; + } + self.pending.insert(slot, (key, priority)); + let request_id = lightcraft_preview::next_tick(); + self.request_ids.insert(slot, request_id); + let display = self.display.clone(); + self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| compose.result(request_id), &display))); + } + /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one /// replaces it while queued). Memory stays bounded by the engine's source caches. pub fn prefetch(&mut self, slot: Slot, mut job: RenderJob, priority: u32) { @@ -456,7 +474,7 @@ impl Renderer { /// Drop the slideshow slides other than `keep`: their textures and their requests that have /// not started (a slide that is no longer near is not worth rendering). - pub fn release_slides(&mut self, keep: &[PhotoId]) { + pub fn release_slides(&mut self, keep: &[lightcraft_engine::slideshow::Item]) { if !self.slides_live { return; } diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index 43364816e..e11d33158 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -5,6 +5,7 @@ use std::time::Duration; use lightcraft_catalog::PhotoId; +use lightcraft_engine::slideshow::Item; use serde_json::json; use crate::headless::Headless; @@ -32,8 +33,18 @@ fn play(h: &Headless) -> &lightcraft_engine::slideshow::Playback { &h.app.ui.slideshow.as_ref().expect("a slideshow is playing").play } -fn has_slide(h: &Headless, id: PhotoId) -> bool { - h.app.renderer.textures.get(&Slot::Slide(id)).is_some_and(|t| t.photo == id) +fn has_slide(h: &Headless, item: impl Into<Item>) -> bool { + let item = item.into(); + let tex = h.app.renderer.textures.get(&Slot::Slide(item)); + match item { + Item::Photo(id) => tex.is_some_and(|t| t.photo == id), + Item::Title(_) => tex.is_some(), + } +} + +/// The photo up now (none while a title screen is). +fn photo_up(h: &Headless) -> PhotoId { + play(h).current_photo().expect("a photo is up") } fn slides_held(h: &Headless) -> usize { @@ -75,20 +86,20 @@ fn next_slides_are_prepared_before_they_are_due() { assert_eq!(ahead.len(), 2); let start = h.time(); // the slide up and the next two; their renders can finish in any order - let current = play(&h).current(); + let current = photo_up(&h); assert!( h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))), "the slide up and the next two slides are rendered" ); assert!(h.time() - start < 60.0, "before the first is due"); - assert_eq!(play(&h).current(), current, "still on the first slide"); + assert_eq!(photo_up(&h), current, "still on the first slide"); h.settle(SETTLE); // off: only the slide up is rendered until the next one is due let mut h = grid(); run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false})); let next = play(&h).upcoming(1)[0]; - let current = play(&h).current(); + let current = photo_up(&h); assert!(h.step_until(SETTLE, |h| has_slide(h, current))); for _ in 0..30 { h.step(); @@ -124,12 +135,12 @@ fn arrows_cut_and_repeat_off_ends_after_the_last_slide() { run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 2, "repeat": false})); let r = h.request("ui.key", json!({"key": "right"}), T); assert_eq!(r["ok"], true, "{r}"); - assert_eq!(play(&h).current(), photos[1]); + assert_eq!(photo_up(&h), photos[1]); assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows"); assert!(play(&h).frame(h.time()).from.is_none(), "a cut, not a fade"); let r = h.request("ui.key", json!({"key": "left"}), T); assert_eq!(r["ok"], true, "{r}"); - assert_eq!(play(&h).current(), photos[0]); + assert_eq!(photo_up(&h), photos[0]); for _ in 1..photos.len() { run(&mut h, "slideshow.next", json!({})); } @@ -146,10 +157,10 @@ fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() { let photos = h.app.session.visible_cloned(); run(&mut h, "library.select", json!({"ids": [photos[3].0]})); run(&mut h, "view.slideshow", json!({"randomOrder": true, "seed": seed, "interval": 60})); - let mut seen = vec![play(&h).current()]; + let mut seen = vec![photo_up(&h)]; for _ in 1..photos.len() { run(&mut h, "slideshow.next", json!({})); - seen.push(play(&h).current()); + seen.push(photo_up(&h)); } h.settle(SETTLE); (photos, seen) @@ -236,6 +247,7 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "preparePreviews": false})); // the next slide goes away before it is due let next = play(&h).upcoming(1)[0]; + let next = next.photo().unwrap(); run(&mut h, "photo.delete", json!({"ids": [next.0]})); run(&mut h, "photo.deletePermanently", json!({"ids": [next.0]})); assert!(h.app.session.catalog.photo(next).is_none()); @@ -244,6 +256,30 @@ fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { h.settle(SETTLE); } +/// Scroll the Settings ▸ Slideshow column until widget `id` is in view (near its top): the +/// sections are taller than the dialog. +fn reveal(h: &mut Headless, id: &str) { + let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); + for _ in 0..40 { + h.step(); + let col = find(h, "slideshow:preview").expect("the Slideshow settings are open"); + let dy = match find(h, id) { + Some(w) if w.center().y >= col.top() + 10.0 && w.center().y <= col.top() + 340.0 => return, + Some(w) => (col.top() + 80.0 - w.center().y).clamp(-500.0, 500.0), + None => -300.0, + }; + // (the pointer over the settings column, left of the preview) + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:preview", "fx": -1.0, "fy": 0.5}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..8 { + h.step(); + } + } + panic!("{id} never came into view"); +} + fn painted(img: &egui::ColorImage, x: usize, y: usize) -> [u8; 3] { let c = img.pixels[y * img.size[0] + x]; [c.r(), c.g(), c.b()] @@ -255,10 +291,10 @@ fn a_composed_slide_shows_during_playback() { let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"})); assert_eq!(r["marginTop"], 0.1); run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); - let current = play(&h).current(); + let current = photo_up(&h); assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); h.step(); - let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap(); + let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap(); assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen"); let img = h.paint(); assert_eq!(img.size, [1000, 700]); @@ -282,11 +318,11 @@ fn the_default_layout_plays_the_photo_as_before() { let mut h = grid(); assert!(h.app.session.slide_layout.is_plain()); run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); - let current = play(&h).current(); + let current = photo_up(&h); assert!(h.step_until(SETTLE, |h| has_slide(h, current))); h.step(); // the photo's own render (its aspect, not the screen's), fitted on black, as before - let tex = h.app.renderer.textures.get(&Slot::Slide(current)).unwrap(); + let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap(); let aspect = h.app.session.slide_photo_aspect(current).unwrap(); let tex_aspect = tex.size[0] as f64 / tex.size[1] as f64; assert!((tex_aspect - aspect).abs() < 0.02, "{tex_aspect} vs {aspect}"); @@ -324,13 +360,7 @@ fn settings_shows_the_slide_layout_and_a_live_preview() { // the preview is the composed slide, rendered off the UI thread assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); // a checkbox goes through slideshow.layout, and its controls appear (scrolled into view) - let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T); - assert_eq!(r["ok"], true, "{r}"); - let r = h.request("ui.scroll", json!({"dy": -600}), T); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..30 { - h.step(); - } + reveal(&mut h, "check:settings.slideshowShadow"); let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -360,10 +390,10 @@ fn overlays_show_during_playback() { ); assert!(!h.app.session.slides_plain()); run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); - let current = play(&h).current(); + let current = photo_up(&h); assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); h.step(); - assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current)).unwrap().size, [1000, 700]); + assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap().size, [1000, 700]); let img = h.paint(); // the plate's box: 14 px in from the top-left corner, 84 px high let green = (14..98).flat_map(|y| (14..120).map(move |x| (x, y))).filter(|&(x, y)| { @@ -383,15 +413,7 @@ fn settings_shows_the_overlays_and_the_preview_follows_them() { assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); // scroll down to the Overlays section - let r = h.request("ui.hoverWidget", json!({"id": "check:settings.slideshowRandom"}), T); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..4 { - let r = h.request("ui.scroll", json!({"dy": -800}), T); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..10 { - h.step(); - } - } + reveal(&mut h, "button:slideshowAddText"); for w in ["check:settings.slideshowPlate", "check:settings.slideshowStars", "button:slideshowAddText", "check:settings.slideshowWatermark"] { assert!(has(&h, w), "{w}"); } @@ -402,24 +424,20 @@ fn settings_shows_the_overlays_and_the_preview_follows_them() { h.step(); assert!(has(&h, "field:slideshowText0"), "the text overlay's template field"); // its rows are below the fold: scroll on down - for _ in 0..4 { - let r = h.request("ui.scroll", json!({"dy": -800}), T); - assert_eq!(r["ok"], true, "{r}"); - for _ in 0..10 { - h.step(); - } - } + reveal(&mut h, "button:slideshowText0Anchor-0"); assert!(has(&h, "button:slideshowText0Anchor-4"), "its anchor grid"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Anchor-0"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert_eq!(h.app.session.slide_overlays.texts[0].place.anchor, lightcraft_engine::export::Anchor::TopLeft, "picked in the grid"); // removed from its row the text overlay from its row + reveal(&mut h, "button:slideshowText0Remove"); let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Remove"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); assert!(h.app.session.slide_overlays.texts.is_empty()); h.step(); + reveal(&mut h, "check:settings.slideshowPlate"); let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowPlate"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -430,3 +448,164 @@ fn settings_shows_the_overlays_and_the_preview_follows_them() { assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); h.settle(SETTLE); } + +/// Shows the playback controls (the pointer moves over the canvas). +fn show_controls(h: &mut Headless) { + for fx in [0.3, 0.6] { + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": fx, "fy": 0.3}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); +} + +#[test] +fn the_intro_plays_before_the_first_photo_and_the_ending_closes_the_show() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + let n = photos.len(); + run( + &mut h, + "slideshow.titles", + json!({ + "intro": {"enabled": true, "color": "#ff00ff", "content": "text", "text": "Hello", "overrideColor": true, "textColor": "#00ff00", "scale": 0.15, "duration": 0.5}, + "ending": {"enabled": true, "color": "#00ffff", "content": "none"} + }), + ); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "repeat": false})); + assert_eq!(play(&h).current(), Item::INTRO, "the intro comes first"); + assert_eq!(h.app.session.active(), Some(photos[0]), "the selection is the first photo"); + let st = run(&mut h, "slideshow.togglePause", json!({})); + assert_eq!( + (st["title"].as_str(), st["current"].as_u64(), st["slide"].as_u64(), st["of"].as_u64()), + (Some("intro"), None, Some(0), Some(n as u64)), + "{st}" + ); + run(&mut h, "slideshow.togglePause", json!({})); + // the intro screen is composed off the UI thread at the screen's size, then shown + assert!(h.step_until(SETTLE, |h| has_slide(h, Item::INTRO)), "the intro is composed"); + assert_eq!(h.app.renderer.textures.get(&Slot::Slide(Item::INTRO)).unwrap().size, [1000, 700]); + h.step(); + let img = h.paint(); + assert_eq!(painted(&img, 5, 5), [255, 0, 255], "the intro's colour"); + let green = (280..420).flat_map(|y| (300..700).map(move |x| (x, y))).filter(|&(x, y)| { + let c = painted(&img, x, y); + c[1] > 200 && c[0] < 80 && c[2] < 80 + }); + assert!(green.count() > 300, "its text, centred"); + show_controls(&mut h); + let label = h.painted_text(); + assert!(label.iter().any(|t| t == "Intro"), "the controls say Intro, not a photo number: {label:?}"); + assert!(!label.iter().any(|t| t.contains(&format!("0 / {n}")))); + // after its own 0.5 s, the first photo (plain layout: the photo on black) + assert!(h.step_until(SETTLE, |h| play(h).current_photo() == Some(photos[0]) && has_slide(h, photos[0])), "the first photo follows"); + h.step(); + let img = h.paint(); + for (x, y) in [(5, 5), (500, 350)] { + assert_ne!(painted(&img, x, y), [255, 0, 255], "the intro is gone at ({x}, {y})"); + } + show_controls(&mut h); + assert!(h.painted_text().iter().any(|t| t == &format!("1 / {n}")), "titles aren't counted: photo 1 of {n}"); + // to the last photo, then the ending + for _ in 1..n { + run(&mut h, "slideshow.next", json!({})); + } + let st = run(&mut h, "slideshow.next", json!({})); + assert_eq!((st["title"].as_str(), st["slide"].as_u64()), (Some("ending"), Some(n as u64)), "{st}"); + assert_eq!(h.app.session.active(), Some(photos[n - 1]), "the selection stays on the last photo"); + assert!(h.step_until(SETTLE, |h| has_slide(h, Item::ENDING))); + h.step(); + assert_eq!(painted(&h.paint(), 500, 350), [0, 255, 255], "the ending's colour alone"); + show_controls(&mut h); + assert!(h.painted_text().iter().any(|t| t == "End")); + // ← goes back to the last photo, → past the ending ends the show + let st = run(&mut h, "slideshow.previous", json!({})); + assert_eq!(st["current"].as_u64(), Some(photos[n - 1].0)); + run(&mut h, "slideshow.next", json!({})); + run(&mut h, "slideshow.next", json!({})); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "ended after the ending"); + h.settle(SETTLE); +} + +#[test] +fn titles_survive_deleting_the_only_photo_and_need_photos_to_start() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + let rest: Vec<u64> = photos[1..].iter().map(|p| p.0).collect(); + run(&mut h, "photo.delete", json!({"ids": rest})); + assert_eq!(h.app.session.visible_cloned(), vec![photos[0]]); + run(&mut h, "slideshow.titles", json!({"intro": {"enabled": true, "duration": 0.5}, "ending": {"enabled": true, "duration": 0.5}})); + run(&mut h, "slideshow.overlays", json!({"identityPlate": {"image": "/no/such/plate.png"}})); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.2, "repeat": false})); + assert_eq!(play(&h).current(), Item::INTRO); + // the only photo goes away while the intro is up + assert_eq!(h.app.session.active(), Some(photos[0])); + run(&mut h, "photo.deletePermanently", json!({"ids": [photos[0].0]})); + assert!(h.app.session.catalog.photo(photos[0]).is_none()); + let mut seen = Vec::new(); + let t0 = std::time::Instant::now(); + while let Some(r) = h.app.ui.slideshow.as_ref() { + if !seen.contains(&r.play.current()) { + seen.push(r.play.current()); + } + assert!(t0.elapsed() < SETTLE, "the show plays on to its end: {seen:?}"); + h.step(); + } + assert_eq!(seen, vec![Item::INTRO, Item::Photo(photos[0]), Item::ENDING], "intro, the (gone) photo, ending"); + // no photos in view: nothing to show, titles or not (as before) + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {}}), T); + assert_ne!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("no photos to show"), "{r}"); + assert!(h.app.ui.slideshow.is_none()); + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + for w in ["button:settingsSlideshowPreviewShows-0", "button:settingsSlideshowPreviewShows-1", "button:settingsSlideshowPreviewShows-2"] { + assert!(has(&h, w), "{w}"); + } + // scroll down to the Titles section, at the end + reveal(&mut h, "check:settings.slideshowIntro"); + assert!(has(&h, "check:settings.slideshowEnding")); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowIntro"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_titles.intro.enabled, "the checkbox goes through slideshow.titles"); + h.step(); + for w in [ + "button:slideshowIntroColor", + "button:settingsSlideshowIntroContent-1", + "slider:slideshowIntroScale", + "check:settings.slideshowIntroOwnDuration", + ] { + assert!(has(&h, w), "{w}"); + } + // the preview switched to the intro screen + assert_eq!(crate::panels::slide_titles::preview_shows(&h.view.ctx), Some(lightcraft_engine::slideshow::Title::Intro)); + run(&mut h, "slideshow.titles", json!({"intro": {"color": "#ff8000", "content": "none"}})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == PhotoId(0)))); + assert!(h.step_until(SETTLE, |h| { + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + painted(&img, rect.center().x as usize, rect.center().y as usize) == [255, 128, 0] + })); + // Text: its field shows + reveal(&mut h, "button:settingsSlideshowIntroContent-1"); + let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowIntroContent-1"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_titles.intro.content, lightcraft_engine::slide_title::TitleContent::Text); + h.step(); + assert!(has(&h, "field:slideshowIntroText")); + // back to the photo's slide + let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowPreviewShows-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + h.settle(SETTLE); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7c79d2425..efe960c43 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -79,7 +79,8 @@ filmstrip is visible. | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | | `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` | -| `engine.execute {command: "slideshow.preview"}` | `{id?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it: a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | +| `engine.execute {command: "slideshow.titles"}` | `{intro?: screen, ending?: screen}`, a screen being `{enabled?, color?, content?: "none"\|"identityPlate"\|"text", text?, overrideColor?, textColor?, scale?, duration?: seconds\|null}` | The intro and ending screens, saved with the library: a background colour and, centred, the Overlays identity plate or the screen's own text, in the plate's colour or `textColor` when `overrideColor` is on, `scale` = its height as a fraction of the slide's shorter side; `duration` null = the slide duration. The intro plays once before the first photo, the ending after the last when repeat is off. Replies with the settings and `warnings` | +| `engine.execute {command: "slideshow.preview"}` | `{id?, title?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it (with `title: "intro"\|"ending"`, that title screen instead → `{title, enabled, width, height, plate}`): a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index 1631eab88..3cb3586a8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -203,7 +203,9 @@ written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent c before playing it. `cmd_slideshow_playback` sets the timing and fades. `cmd_slideshow_overlays` sets the overlays drawn on every slide (identity plate text or image, rating stars, text overlays with tokens such as `{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that -aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. +aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. `cmd_slideshow_titles` sets the intro and ending +screens (colour, the identity plate or a line of text, scale, duration), and `cmd_slideshow_preview {title: "intro"}` +renders one. ## Resources diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1cc7972fd..f9e4678cc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 18 | 30 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 52 | 68 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 19 | 29 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 53 | 67 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.4% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.0%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.7% of 163. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -732,9 +732,9 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | | LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | -| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | +| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow), titles in slideshow export (LRC-SS-EXPORT) | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on, titles on the slides (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 6f14bced6..07d178b25 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,18 @@ ## 2026-10-10 +### Slideshow intro and ending screens +- A slideshow can now open with an intro screen and close with an ending screen, like Lightroom Classic's Titles + panel. Each is switched on on its own and has a background colour and, centred on it, your identity plate (the one + set under Overlays: its text or image) or a line of text of its own, in the plate's colour or one you pick, at a + scale. Each stays up for its own duration (by default the slide duration) and fades in and out like a slide. +- The intro plays once before the first photo; the ending after the last photo when Repeat is off (while repeating + the show runs until you stop it, so the ending doesn't come up). Random order never moves them, ←/→ step across them, + and the control bar says Intro or End while one is up (the photo count only counts photos). +- Set them in Settings ▸ Slideshow ▸ Titles; the preview there switches between the photo's slide, the intro and the + ending. They are saved with the library. Agents use `slideshow.titles`, and `slideshow.preview {title: "intro"}` + renders a screen as a PNG. + ### Slideshow overlays - Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the From 6bfe2235af82262652ccecd09ea8edd132c0b029 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:22:11 +0200 Subject: [PATCH 006/315] LRC-SS-TEMPLATES: slideshow templates: built-ins of our own, user templates, browser with hover preview A slideshow template is the Playback (not "prepare previews"), Layout / Options / Backdrop, Overlays and Titles settings as one versioned JSON document (format lightcraft.slideshowTemplate, version 1). - Engine: slide_template (SlideLook, Template, built-ins, user template files in <settings>/slideshow-templates read through each section's own merged() validation; size and count caps; newer versions refused; bad files reported and skipped; file names sanitised so ids never leave the folder). Applying replaces the four settings in one step and keeps the replaced ones as "previous" for the session (prefs have no undo). - Five built-ins of our own: Plain Black, Gallery Wall, Edge to Edge, Cinema Wide, Shooting Data (no Lightroom names or designs, no images). - Commands slideshow.templates.list/apply/save/update/rename/delete/ import/export; slideshow.preview {template} previews without applying. - UI: Template Browser at the top of Settings > Slideshow (built-in, user, previous settings; click applies, hover previews on the selected photo, right-click update / rename / export / delete; save and import), file dialogs on the desktop; strings in the nine locales. - Docs: parity row (partial), whats-new, control protocol and MCP. --- apps/lightcraft-cli/src/main.rs | 9 +- apps/lightcraft-web/src/web.rs | 2 + apps/lightcraft/src/main.rs | 22 +- crates/engine/src/cmd/slideshow.rs | 213 +++++- crates/engine/src/lib.rs | 12 + crates/engine/src/slide.rs | 50 +- crates/engine/src/slide_template.rs | 632 ++++++++++++++++++ .../src/slideshow_templates/cinema-wide.json | 19 + .../src/slideshow_templates/edge-to-edge.json | 10 + .../src/slideshow_templates/gallery-wall.json | 20 + .../src/slideshow_templates/plain-black.json | 10 + .../slideshow_templates/shooting-data.json | 18 + crates/engine/src/tests_slide_template.rs | 360 ++++++++++ crates/mcp/src/headless.rs | 13 +- crates/ui-egui/locales/de-formats.json | 11 +- crates/ui-egui/locales/de.json | 26 +- crates/ui-egui/locales/es-formats.json | 11 +- crates/ui-egui/locales/es.json | 26 +- crates/ui-egui/locales/fr-formats.json | 11 +- crates/ui-egui/locales/fr.json | 26 +- crates/ui-egui/locales/ja-formats.json | 11 +- crates/ui-egui/locales/ja.json | 26 +- crates/ui-egui/locales/pt-br-formats.json | 11 +- crates/ui-egui/locales/pt-br.json | 26 +- crates/ui-egui/locales/ru-formats.json | 11 +- crates/ui-egui/locales/ru.json | 27 +- crates/ui-egui/locales/uk-formats.json | 11 +- crates/ui-egui/locales/uk.json | 26 +- crates/ui-egui/locales/zh-hans-formats.json | 11 +- crates/ui-egui/locales/zh-hans.json | 26 +- crates/ui-egui/locales/zh-hant-formats.json | 11 +- crates/ui-egui/locales/zh-hant.json | 26 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/settings.rs | 2 + crates/ui-egui/src/panels/slide_layout.rs | 32 +- crates/ui-egui/src/panels/slide_templates.rs | 274 ++++++++ crates/ui-egui/src/tests_slideshow.rs | 114 ++++ docs/control-protocol.md | 6 +- docs/mcp.md | 6 +- docs/parity-checklist.md | 8 +- docs/whats-new.md | 21 + 42 files changed, 2135 insertions(+), 57 deletions(-) create mode 100644 crates/engine/src/slide_template.rs create mode 100644 crates/engine/src/slideshow_templates/cinema-wide.json create mode 100644 crates/engine/src/slideshow_templates/edge-to-edge.json create mode 100644 crates/engine/src/slideshow_templates/gallery-wall.json create mode 100644 crates/engine/src/slideshow_templates/plain-black.json create mode 100644 crates/engine/src/slideshow_templates/shooting-data.json create mode 100644 crates/engine/src/tests_slide_template.rs create mode 100644 crates/ui-egui/src/panels/slide_templates.rs diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 592a534a9..dbcef01ef 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -732,12 +732,15 @@ fn snapshot(args: &[String]) -> Result<(), String> { let t0 = Instant::now(); let mut session = match &library { Some(dir) => { - let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + let mut s = + Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(); s.open_library(dir, false).map_err(|e| library_error(dir, e))?; s } - None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), - None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), + None if files.is_empty() => { + Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates() + } + None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(), }; // Apply compute policy before imports or the first scripted query can discover an adapter. session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?; diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 592d05cd1..bbf14ea50 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -218,6 +218,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen save_keyword_list: None, pick_curve_preset_files: None, save_curve_preset_file: None, + pick_slideshow_templates: None, + save_slideshow_template: None, write: Some(Box::new(download)), // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 09cb14343..438104cf4 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -567,6 +567,24 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_slideshow_templates: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_slideshow_template: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), // atomic (temp file + sync + rename): a failed write never leaves a truncated file write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), write: Some(Box::new(lightcraft_engine::export::write_file)), @@ -579,10 +597,12 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services } } -/// The library session with the folder face models are kept in (`<config>/models`). +/// The library session with the folders face models (`<config>/models`) and slideshow templates +/// (`<config>/slideshow-templates`) are kept in. fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) { let (mut s, problem) = open_library_session(in_memory, dir, seed_demo); s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); + s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); (s, problem) } diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 62e4b4313..92c1a325c 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -6,12 +6,15 @@ //! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every //! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour, //! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes -//! one slide, or a title screen, as a PNG. +//! one slide, or a title screen, as a PNG (with the library's settings, or a template's). +//! `slideshow.templates.*`: the Template Browser (built-in and user templates; see +//! [`crate::slide_template`]). use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::slide_template::{self as tpl, SlideLook}; use crate::{Result, Session}; /// Largest side `slideshow.preview` renders. @@ -71,7 +74,7 @@ fn titles(s: &mut Session, p: &Value) -> Result<Value> { /// A slide size from the request: both sides given, one (the other from the aspect preview), or /// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. -fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { +fn preview_size(layout: &lightcraft_slideshow::SlideSettings, p: &Value) -> Result<(usize, usize)> { const C: &str = "slideshow.preview"; let side = |k: &str| -> Result<Option<usize>> { match p.get(k) { @@ -82,7 +85,7 @@ fn preview_size(s: &Session, p: &Value) -> Result<(usize, usize)> { }, } }; - let ratio = s.slide_layout.aspect.ratio(f64::NAN); + let ratio = layout.aspect.ratio(f64::NAN); let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX); Ok(match (side("width")?, side("height")?) { (Some(w), Some(h)) => (w, h), @@ -110,7 +113,7 @@ fn encode_png(img: &lightcraft_raster::Rgba8) -> Result<Vec<u8>> { } /// `slideshow.preview {title}`: a title screen (whether or not it is switched on). -fn preview_title(s: &mut Session, p: &Value, title: &Value) -> Result<Value> { +fn preview_title(s: &mut Session, p: &Value, title: &Value, look: &SlideLook) -> Result<Value> { const C: &str = "slideshow.preview"; let t = title .as_str() @@ -119,18 +122,18 @@ fn preview_title(s: &mut Session, p: &Value, title: &Value) -> Result<Value> { if p.get("id").is_some_and(|v| !v.is_null()) { return Err(bad(C, "pass `id` or `title`, not both")); } - let (w, h) = preview_size(s, p)?; + let (w, h) = preview_size(&look.layout, p)?; let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); if let Some(path) = path { s.check_write_target(path).map_err(|e| bad(C, e))?; } - let c = s.title_compose(t, w, h); + let c = crate::slide_title::TitleCompose::new(&look.titles, &look.overlays, t, w, h); let img = c.compose(); let png = encode_png(&img)?; let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); let mut out = json!({ "title": t.id(), - "enabled": s.slide_titles.screen(t).enabled, + "enabled": look.titles.screen(t).enabled, "width": img.width, "height": img.height, "plate": c.plate_box().map(rect), @@ -141,8 +144,19 @@ fn preview_title(s: &mut Session, p: &Value, title: &Value) -> Result<Value> { fn preview(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "slideshow.preview"; + // a template's look (the Template Browser's hover preview), else the library's settings + let template = match p.get("template") { + None | Some(Value::Null) => None, + Some(Value::String(id)) => Some(s.slide_template(id.trim()).map_err(|e| bad(C, e))?), + Some(v) => return Err(bad(C, format!("`template` must be a template id, not {v}"))), + }; + let look = template.as_ref().map_or_else(|| SlideLook::of(s), |t| t.look.clone()); if let Some(t) = p.get("title").filter(|v| !v.is_null()) { - return preview_title(s, p, t); + let mut out = preview_title(s, p, t, &look)?; + if let Some(t) = &template { + out["template"] = json!(t.id); + } + return Ok(out); } let id = match p.get("id") { None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?, @@ -151,13 +165,13 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> { if s.catalog.photo(id).is_none() { return Err(bad(C, format!("no photo {}", id.0))); } - let (w, h) = preview_size(s, p)?; + let (w, h) = preview_size(&look.layout, p)?; let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); if let Some(path) = path { s.check_write_target(path).map_err(|e| bad(C, e))?; } - let settings = s.slide_layout.clone(); - let img = s.slide_now(&settings, id, w, h).map_err(crate::EngineError::Other)?; + let settings = look.layout.clone(); + let img = s.slide_now_look(&settings, &look.overlays, id, w, h).map_err(crate::EngineError::Other)?; let png = encode_png(&img)?; let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height); @@ -170,8 +184,11 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> { "photo": rect(l.visible), }); let photo = s.catalog.photo(id).cloned(); - let o = crate::slide_overlay::SlideOverlays::resolve(&s.slide_overlays, photo.as_deref()); + let o = crate::slide_overlay::SlideOverlays::resolve(&look.overlays, photo.as_deref()); out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect(); + if let Some(t) = &template { + out["template"] = json!(t.id); + } deliver(&png, path, &mut out)?; Ok(out) } @@ -192,6 +209,104 @@ fn base64(bytes: &[u8]) -> String { out } +// ------------------------------------------------------------------------------- templates + +/// The settings as the `slideshow.*` commands return them (for template replies). +fn settings_json(s: &Session) -> Value { + json!({ + "playback": s.slideshow.to_json(), + "layout": s.slide_layout.to_json(), + "overlays": s.slide_overlays.to_json(), + "titles": s.slide_titles.to_json(), + }) +} + +fn tpl_list(s: &mut Session, _p: &Value) -> Result<Value> { + let l = s.slide_templates(); + Ok(json!({ + "templates": l.templates.iter().map(|t| t.info()).collect::<Vec<_>>(), + "previous": s.slide_templates_previous.is_some(), + "folder": s.slide_templates_dir.as_ref().map(|d| d.to_string_lossy().to_string()), + "problems": l.problems, + })) +} + +/// The template `id` or `name` names. +fn named(s: &mut Session, p: &Value, c: &str) -> Result<std::sync::Arc<tpl::Template>> { + s.slide_template_named(str_param(p, "id"), str_param(p, "name")).map_err(|e| bad(c, e)) +} + +fn tpl_apply(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.apply"; + let t = named(s, p, C)?; + let warnings = s.apply_slide_look(&t.look)?; + let mut out = json!({"applied": t.id, "name": t.name, "warnings": warnings, "previous": s.slide_templates_previous.is_some()}); + out["settings"] = settings_json(s); + Ok(out) +} + +fn tpl_reply(t: &tpl::Template) -> Value { + t.info() +} + +fn tpl_save(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.save"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let replace = match p.get("replace") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`replace` must be true or false, not {v}")))?, + }; + let t = s.save_slide_template(name, replace).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn id_param<'a>(p: &'a Value, c: &str) -> Result<&'a str> { + str_param(p, "id").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(c, "missing `id` (a template id from slideshow.templates.list)")) +} + +fn tpl_update(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.update"; + let id = id_param(p, C)?; + let t = s.update_slide_template(id).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn tpl_rename(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.rename"; + let id = id_param(p, C)?; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let t = s.rename_slide_template(id, name).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn tpl_delete(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.delete"; + let id = id_param(p, C)?; + let t = s.delete_slide_template(id).map_err(|e| bad(C, e))?; + Ok(json!({"deleted": t.id, "name": t.name})) +} + +fn tpl_import(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.import"; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let (t, renamed) = s.import_slide_template(std::path::Path::new(path)).map_err(|e| bad(C, e))?; + let mut out = tpl_reply(&t); + out["renamed"] = json!(renamed); + out["warnings"] = json!(t.look.warnings(s)); + Ok(out) +} + +fn tpl_export(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.export"; + let id = id_param(p, C)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let t = s.slide_template(id).map_err(|e| bad(C, e))?; + s.check_write_target(path).map_err(|e| bad(C, e))?; + let bytes = serde_json::to_vec_pretty(&t.to_doc()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + crate::export::write_file(path, &bytes).map_err(crate::EngineError::Other)?; + Ok(json!({"id": t.id, "name": t.name, "path": path})) +} + pub fn specs() -> Vec<CommandSpec> { vec![ cmd!( @@ -230,12 +345,84 @@ pub fn specs() -> Vec<CommandSpec> { always, titles ), + cmd!( + query "slideshow.templates.list", + "Slideshow Templates", + [], + None, + "{} → {templates: [{id: \"builtin:…\"|\"user:…\", name, builtin, group: \"builtin\"|\"user\", description?, path? (a user template's file)}], previous: bool (the settings before the last apply can be restored with apply {id: \"previous\"}), folder: the user templates folder | null, problems: [\"file: why it was skipped\"]} — built-ins first, then user templates by name (files in <settings>/slideshow-templates, at most 1000)", + always, + tpl_list + ), + cmd!( + "slideshow.templates.apply", + "Apply Slideshow Template", + [], + None, + "{id: template id | \"previous\" (the settings before the last apply) | name: template name (case-insensitive)} — replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's and saves them with the library; there is no undo: the replaced settings are kept as \"previous\" for this session → {applied, name, warnings: [a background photo not in this library, an unreadable identity plate / watermark image…], previous, settings: {playback, layout, overlays, titles}}", + always, + tpl_apply + ), + cmd!( + "slideshow.templates.save", + "Save Slideshow Template", + [], + None, + "{name: 1–80 characters, replace?: bool (over a user template of that name)} — the current playback, layout, overlays and titles settings as a user template, <settings>/slideshow-templates/<name>.json (path separators and other reserved characters become _); a name taken (case-insensitive) or a built-in's is refused unless replace → {id, name, builtin, group, path}", + always, + tpl_save + ), + cmd!( + "slideshow.templates.update", + "Update Slideshow Template", + [], + None, + "{id: user template} — replace its settings with the current ones (name kept); built-ins can't change → {id, name, builtin, group, path}", + always, + tpl_update + ), + cmd!( + "slideshow.templates.rename", + "Rename Slideshow Template", + [], + None, + "{id: user template, name} — its file follows the name; a name taken is refused → {id (the new one), name, builtin, group, path}", + always, + tpl_rename + ), + cmd!( + "slideshow.templates.delete", + "Delete Slideshow Template", + [], + None, + "{id: user template} — deletes its file → {deleted, name}", + always, + tpl_delete + ), + cmd!( + "slideshow.templates.import", + "Import Slideshow Template", + [], + None, + "{path: a template .json file (at most 256 KB)} — checked like a saved one (format, version, every setting) and saved as a user template under its name (else the file's); a name taken gets \" (2)\"… → {id, name, builtin, group, path, renamed, warnings}", + always, + tpl_import + ), + cmd!( + "slideshow.templates.export", + "Export Slideshow Template", + [], + None, + "{id: template (built-in or user), path: .json file to write} → {id, name, path}", + always, + tpl_export + ), cmd!( query "slideshow.preview", "Slideshow Slide Preview", [], None, - "{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}", + "{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", template?: a template id (slideshow.templates.list): compose with its settings instead of the library's, which don't change (the Template Browser's hover preview), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}", always, preview ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 00f39baec..5cb09fd87 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,6 +57,7 @@ pub mod segment; pub mod sidecar; pub mod slide; pub mod slide_overlay; +pub mod slide_template; pub mod slide_title; pub mod slideshow; pub mod smart; @@ -285,6 +286,14 @@ pub struct Session { pub slide_overlays: slide_overlay::OverlaySettings, /// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json). pub slide_titles: slide_title::TitleSettings, + /// Where user slideshow templates are kept (`<config>/slideshow-templates`; + /// [`Session::with_default_slide_templates`]). `None`: the built-in templates only. + pub slide_templates_dir: Option<std::path::PathBuf>, + /// The slideshow settings before the last template was applied ("Previous settings"; this + /// session only: library preferences have no undo). + pub slide_templates_previous: Option<slide_template::SlideLook>, + /// The templates as last listed. + pub(crate) slide_templates_cache: slide_template::Cache, /// The background image the slides last rendered (shared by slide jobs: rendered once). pub slide_backdrop: std::sync::Arc<slide::BackdropCache>, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). @@ -373,6 +382,9 @@ impl Session { slide_layout: slide::SlideSettings::default(), slide_overlays: slide_overlay::OverlaySettings::default(), slide_titles: slide_title::TitleSettings::default(), + slide_templates_dir: None, + slide_templates_previous: None, + slide_templates_cache: None, slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs index ab252cf6d..009a2e2ac 100644 --- a/crates/engine/src/slide.rs +++ b/crates/engine/src/slide.rs @@ -103,6 +103,19 @@ impl crate::Session { /// (the photo `settings.background_image` names, when it is in the catalog) and the library's /// overlays resolved for `photo`. pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option<PhotoId>) -> Arc<SlideCompose> { + let overlays = self.slide_overlays.clone(); + self.slide_compose_with(settings, &overlays, width, height, photo) + } + + /// [`Self::slide_compose`] with `overlays` in place of the library's (a template's preview). + pub fn slide_compose_with( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + width: usize, + height: usize, + photo: Option<PhotoId>, + ) -> Arc<SlideCompose> { let settings = settings.clone().sanitized(); let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { let id = PhotoId(bid); @@ -113,9 +126,9 @@ impl crate::Session { let job = self.render_job(id, w, h, false, true)?; Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) }); - let overlays = self.slide_overlays.any().then(|| { + let overlays = overlays.any().then(|| { let p = photo.and_then(|id| self.catalog.photo(id)); - Arc::new(crate::slide_overlay::SlideOverlays::resolve(&self.slide_overlays, p.map(|p| &**p))) + Arc::new(crate::slide_overlay::SlideOverlays::resolve(overlays, p.map(|p| &**p))) }); Arc::new(SlideCompose { settings, width, height, backdrop, overlays }) } @@ -139,6 +152,22 @@ impl crate::Session { height: usize, photo_edge: Option<usize>, view: bool, + ) -> Option<RenderJob> { + let overlays = self.slide_overlays.clone(); + self.slide_job_look(settings, &overlays, id, width, height, photo_edge, view) + } + + /// [`Self::slide_job_with`] with `overlays` in place of the library's (a template's preview). + #[allow(clippy::too_many_arguments)] + pub fn slide_job_look( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option<usize>, + view: bool, ) -> Option<RenderJob> { let aspect = self.slide_photo_aspect(id)?; let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height); @@ -147,13 +176,26 @@ impl crate::Session { (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); } let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; - let compose = self.slide_compose(settings, width, height, Some(id)); + let compose = self.slide_compose_with(settings, overlays, width, height, Some(id)); Some(job.with_slide(compose)) } /// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread. pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result<Rgba8, String> { - let job = self.slide_job_with(settings, id, width, height, None, false).ok_or("no such photo")?; + let overlays = self.slide_overlays.clone(); + self.slide_now_look(settings, &overlays, id, width, height) + } + + /// [`Self::slide_now`] with `overlays` in place of the library's. + pub fn slide_now_look( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + id: PhotoId, + width: usize, + height: usize, + ) -> Result<Rgba8, String> { + let job = self.slide_job_look(settings, overlays, id, width, height, None, false).ok_or("no such photo")?; let r = job.run(); self.accept(&r); r.rendered.map(|r| r.image) diff --git a/crates/engine/src/slide_template.rs b/crates/engine/src/slide_template.rs new file mode 100644 index 000000000..32e26ed59 --- /dev/null +++ b/crates/engine/src/slide_template.rs @@ -0,0 +1,632 @@ +//! Slideshow templates (Lightroom Classic's Template Browser): a named snapshot of how a +//! slideshow looks and plays — the Playback settings, the slide's Layout / Options / Backdrop, +//! the Overlays and the Titles — as one JSON document with a format version. +//! +//! Built-in templates are our own designs ([`builtins`], JSON files beside this module). User +//! templates are files in the settings folder, `<config>/slideshow-templates/<name>.json` +//! ([`Session::slide_templates_dir`]); they are shared by every library on the machine, like +//! Lightroom's templates. Every section is read through the settings' own `merged()` (so a +//! hand-edited or hostile file can never set a value the panels couldn't), files are capped in +//! size and number, a file that can't be used is reported and skipped, and file names are made +//! from the template's name so they can never leave the templates folder. +//! +//! Applying a template replaces the four settings in one step and saves them with the library. +//! Library preferences have no undo, so the settings it replaced are kept for the session as +//! "Previous settings" ([`PREVIOUS`]): applying that puts them back (and applying it again goes +//! forward). "Prepare previews" is a machine preference, not part of a look: templates neither +//! save nor change it. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::slide::SlideSettings; +use crate::slide_overlay::OverlaySettings; +use crate::slide_title::TitleSettings; +use crate::slideshow::PlaybackSettings; + +/// The `format` of a template document. +pub const FORMAT: &str = "lightcraft.slideshowTemplate"; +/// The newest document version this build reads (and the one it writes). +pub const VERSION: u64 = 1; +/// Largest template file read (bytes). +pub const MAX_FILE: u64 = 256 * 1024; +/// Most user templates listed (the rest of a folder that holds more are reported, not read). +pub const MAX_TEMPLATES: usize = 1000; +/// Directory entries looked at before giving up on a huge folder. +const MAX_SCAN: usize = 20_000; +/// Longest template name (characters). +pub const NAME_MAX: usize = 80; +/// Longest description (characters). +pub const DESCRIPTION_MAX: usize = 500; +/// Longest file stem made from a name (characters). +const STEM_MAX: usize = 64; +/// The folder under the settings folder user templates live in. +pub const DIR_NAME: &str = "slideshow-templates"; +/// Id prefix of built-in templates. +pub const BUILTIN: &str = "builtin:"; +/// Id prefix of user templates (followed by the file stem). +pub const USER: &str = "user:"; +/// Id of the settings before the last template was applied. +pub const PREVIOUS: &str = "previous"; + +/// Everything a template sets. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SlideLook { + pub playback: PlaybackSettings, + pub layout: SlideSettings, + pub overlays: OverlaySettings, + pub titles: TitleSettings, +} + +impl SlideLook { + /// The library's current slideshow settings. + pub fn of(s: &Session) -> SlideLook { + SlideLook { + playback: s.slideshow.clone(), + layout: s.slide_layout.clone(), + overlays: s.slide_overlays.clone(), + titles: s.slide_titles.clone(), + } + } + + /// The four sections as JSON (playback without `preparePreviews`, which isn't part of a look). + pub fn sections(&self) -> Map<String, Value> { + let mut playback = self.playback.to_json(); + if let Some(o) = playback.as_object_mut() { + o.remove("preparePreviews"); + } + let mut m = Map::new(); + m.insert("playback".into(), playback); + m.insert("layout".into(), self.layout.to_json()); + m.insert("overlays".into(), self.overlays.to_json()); + m.insert("titles".into(), self.titles.to_json()); + m + } + + /// Read the sections of a template document: each one merged onto the defaults by the + /// settings' own validation (a missing section is the defaults; a wrong type, an unknown key + /// or a non-finite number is an error). + fn from_sections(doc: &Map<String, Value>) -> Result<SlideLook, String> { + fn section<T>(doc: &Map<String, Value>, key: &str, merge: impl Fn(&Value) -> Result<T, String>, default: T) -> Result<T, String> { + match doc.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v @ Value::Object(_)) => merge(v).map_err(|e| format!("{key}: {e}")), + Some(v) => Err(format!("`{key}` must be an object, not {v}")), + } + } + let mut playback = section(doc, "playback", |v| PlaybackSettings::default().merged(v), PlaybackSettings::default())?; + playback.prepare_previews = PlaybackSettings::default().prepare_previews; + Ok(SlideLook { + playback, + layout: section(doc, "layout", |v| SlideSettings::default().merged(v), SlideSettings::default())?, + overlays: section(doc, "overlays", |v| OverlaySettings::default().merged(v), OverlaySettings::default())?, + titles: section(doc, "titles", |v| TitleSettings::default().merged(v), TitleSettings::default())?, + }) + } + + /// What doesn't work with this look in `s`'s library: a background photo that isn't in it, + /// an identity plate or watermark image file that can't be read, unknown tokens, a title + /// screen with nothing to show. The look still applies; these draw nothing. + pub fn warnings(&self, s: &Session) -> Vec<String> { + let mut out = Vec::new(); + if let Some(id) = self.layout.background_image + && s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none() + { + out.push(format!("the background image (photo {id}) isn't in this library; the backdrop colour shows instead")); + } + out.extend(self.overlays.warnings()); + let w = &self.overlays.watermark; + if self.overlays.show_watermark && !w.image.trim().is_empty() && crate::export::watermark_logo(w.image.trim()).is_none() { + out.push(format!("watermark image {} can't be read; nothing is drawn", w.image.trim())); + } + for t in self.titles.warnings(&self.overlays) { + if !out.contains(&t) { + out.push(t); + } + } + out + } +} + +/// A template: built in or a user's file. +#[derive(Clone, Debug, PartialEq)] +pub struct Template { + /// `builtin:<slug>` or `user:<file stem>`. + pub id: String, + pub name: String, + pub description: String, + pub builtin: bool, + /// The file a user template is read from. + pub path: Option<PathBuf>, + pub look: SlideLook, +} + +impl Template { + /// The template as a document (what a user template's file holds, and what export writes). + pub fn to_doc(&self) -> Value { + doc(&self.name, &self.description, &self.look) + } + + /// The template for listings (no settings). + pub fn info(&self) -> Value { + let mut v = json!({ + "id": self.id, + "name": self.name, + "builtin": self.builtin, + "group": if self.builtin { "builtin" } else { "user" }, + }); + if !self.description.is_empty() { + v["description"] = json!(self.description); + } + if let Some(p) = &self.path { + v["path"] = json!(p.to_string_lossy()); + } + v + } +} + +/// A template document for `look`. +pub fn doc(name: &str, description: &str, look: &SlideLook) -> Value { + let mut m = Map::new(); + m.insert("format".into(), json!(FORMAT)); + m.insert("version".into(), json!(VERSION)); + m.insert("name".into(), json!(name)); + if !description.is_empty() { + m.insert("description".into(), json!(description)); + } + m.extend(look.sections()); + Value::Object(m) +} + +/// A template document read and checked: its name (`None` when it has none), description and look. +#[derive(Clone, Debug, PartialEq)] +pub struct Parsed { + pub name: Option<String>, + pub description: String, + pub look: SlideLook, +} + +/// Read a template document. Refused: more than [`MAX_FILE`] bytes, not JSON, another format, a +/// version from a newer LightCraft, a bad name, unknown keys and any setting its section's +/// validation refuses. +pub fn parse(bytes: &[u8]) -> Result<Parsed, String> { + if bytes.len() as u64 > MAX_FILE { + return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); + } + let text = std::str::from_utf8(bytes).map_err(|_| "not a slideshow template (not UTF-8 text)".to_string())?; + let text = text.trim_start_matches('\u{feff}'); + let v: Value = serde_json::from_str(text).map_err(|e| format!("not a slideshow template: {e}"))?; + let Value::Object(o) = v else { return Err("not a slideshow template (not a JSON object)".into()) }; + match o.get("format").and_then(Value::as_str) { + Some(FORMAT) => {} + Some(other) => return Err(format!("not a slideshow template (format `{other}`, expected `{FORMAT}`)")), + None => return Err(format!("not a slideshow template (no `format`: \"{FORMAT}\")")), + } + match o.get("version") { + Some(v) => match v.as_u64() { + Some(n) if n > VERSION => { + return Err(format!( + "made by a newer LightCraft (template version {n}; this one reads version {VERSION}): update LightCraft to use it" + )); + } + Some(n) if n >= 1 => {} + _ => return Err(format!("`version` must be a whole number from 1, not {v}")), + }, + None => return Err("no `version`".into()), + } + for k in o.keys() { + if !matches!(k.as_str(), "format" | "version" | "name" | "description" | "playback" | "layout" | "overlays" | "titles") { + return Err(format!("unknown key `{k}` (format, version, name, description, playback, layout, overlays, titles)")); + } + } + let name = match o.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if n.trim().is_empty() => None, + Some(Value::String(n)) => Some(valid_name(n)?), + Some(v) => return Err(format!("`name` must be text, not {v}")), + }; + let description = match o.get("description") { + None | Some(Value::Null) => String::new(), + Some(Value::String(d)) => clean_text(d, DESCRIPTION_MAX), + Some(v) => return Err(format!("`description` must be text, not {v}")), + }; + Ok(Parsed { name, description, look: SlideLook::from_sections(&o)? }) +} + +/// `s` without control characters, trimmed, at most `max` characters. +fn clean_text(s: &str, max: usize) -> String { + s.chars().filter(|c| !c.is_control()).take(max).collect::<String>().trim().to_string() +} + +/// A template name as stored: trimmed, 1–[`NAME_MAX`] characters, no control characters. +pub fn valid_name(name: &str) -> Result<String, String> { + let n = name.trim(); + if n.is_empty() { + return Err("a template needs a name".into()); + } + if n.chars().any(char::is_control) { + return Err("a template name can't contain control characters (line breaks, tabs…)".into()); + } + if n.chars().count() > NAME_MAX { + return Err(format!("a template name has at most {NAME_MAX} characters")); + } + Ok(n.to_string()) +} + +/// The file stem a template called `name` is saved as: letters, digits, spaces, `-`, `_`, `(` +/// and `)` kept, anything else (path separators, dots, `:`, `*`…) `_`; trimmed; at most 64 +/// characters; Windows' reserved device names get a `_`. `None` when nothing usable is left. +pub fn file_stem(name: &str) -> Option<String> { + let s: String = + name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')') { c } else { '_' }).take(STEM_MAX).collect(); + let s = s.trim().to_string(); + if s.is_empty() || s.chars().all(|c| c == '_') { + return None; + } + let upper = s.to_ascii_uppercase(); + let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((upper.starts_with("COM") || upper.starts_with("LPT")) && upper.len() == 4 && upper.ends_with(|c: char| c.is_ascii_digit())); + Some(if reserved { format!("{s}_") } else { s }) +} + +/// The stem of a user template id, when it is one this module could have made (so it can't +/// name a file outside the templates folder). +fn user_stem(id: &str) -> Option<&str> { + let stem = id.strip_prefix(USER)?; + (file_stem(stem).as_deref() == Some(stem)).then_some(stem) +} + +/// The built-in templates: our own designs (no Lightroom template, name or design copied). +const BUILTINS: &[(&str, &str)] = &[ + ("plain-black", include_str!("slideshow_templates/plain-black.json")), + ("gallery-wall", include_str!("slideshow_templates/gallery-wall.json")), + ("edge-to-edge", include_str!("slideshow_templates/edge-to-edge.json")), + ("cinema-wide", include_str!("slideshow_templates/cinema-wide.json")), + ("shooting-data", include_str!("slideshow_templates/shooting-data.json")), +]; + +/// The built-in templates (one that doesn't parse — a bug the tests catch — is left out). +pub fn builtins() -> Vec<Template> { + BUILTINS + .iter() + .filter_map(|(slug, json)| { + let p = parse(json.as_bytes()).inspect_err(|e| eprintln!("lightcraft: built-in slideshow template {slug}: {e}")).ok()?; + Some(Template { + id: format!("{BUILTIN}{slug}"), + name: p.name.unwrap_or_else(|| (*slug).to_string()), + description: p.description, + builtin: true, + path: None, + look: p.look, + }) + }) + .collect() +} + +/// Every template, and what couldn't be read. +#[derive(Clone, Debug, Default)] +pub struct Listing { + /// Built-ins first (in their order), then user templates by name. + pub templates: Vec<Arc<Template>>, + /// Files skipped, each with why. + pub problems: Vec<String>, +} + +impl Listing { + pub fn get(&self, id: &str) -> Option<&Arc<Template>> { + self.templates.iter().find(|t| t.id == id) + } + + /// A template by name (case-insensitive); a user template before a built-in. + pub fn by_name(&self, name: &str) -> Option<&Arc<Template>> { + let n = name.trim().to_lowercase(); + let mut hits = self.templates.iter().filter(|t| t.name.to_lowercase() == n); + let first = hits.next()?; + Some(hits.find(|t| !t.builtin).unwrap_or(first)) + } +} + +/// When the templates folder last changed (the listing is read again when this does). +type Stamp = (Option<PathBuf>, Option<std::time::SystemTime>); + +/// The cached listing and the folder stamp it was read at. +pub type Cache = Option<(Stamp, Arc<Listing>)>; + +fn stamp(dir: Option<&Path>) -> Stamp { + (dir.map(Path::to_path_buf), dir.and_then(|d| std::fs::metadata(d).ok()).and_then(|m| m.modified().ok())) +} + +/// Read the user templates in `dir`: `*.json` regular files, at most [`MAX_TEMPLATES`], each +/// parsed and checked; anything else reported. +fn read_dir(dir: &Path, out: &mut Listing) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + out.problems.push(format!("{}: {e}", dir.display())); + return; + } + }; + let mut files: Vec<(String, PathBuf)> = Vec::new(); + for entry in entries.take(MAX_SCAN).flatten() { + let path = entry.path(); + let is_json = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json")); + if !is_json { + continue; + } + let file = entry.file_name().to_string_lossy().to_string(); + files.push((file, path)); + } + files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.0.cmp(&b.0))); + if files.len() > MAX_TEMPLATES { + out.problems.push(format!("{}: {} template files; only the first {MAX_TEMPLATES} are listed", dir.display(), files.len())); + files.truncate(MAX_TEMPLATES); + } + let mut user = Vec::new(); + for (file, path) in files { + match read_user(&file, &path) { + Ok(t) => user.push(Arc::new(t)), + Err(e) => out.problems.push(format!("{file}: {e}")), + } + } + user.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id))); + out.templates.extend(user); +} + +/// One user template file. +fn read_user(file: &str, path: &Path) -> Result<Template, String> { + let stem = file.get(..file.len().saturating_sub(5)).unwrap_or_default(); + if file_stem(stem).as_deref() != Some(stem) { + return Err("the file name isn't one LightCraft makes (letters, digits, spaces, - _ ( ) only); rename the file".into()); + } + let meta = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.file_type().is_file() { + return Err("not a regular file (a link or a folder)".into()); + } + if meta.len() > MAX_FILE { + return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", meta.len(), MAX_FILE / 1024)); + } + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let p = parse(&bytes)?; + Ok(Template { + id: format!("{USER}{stem}"), + name: p.name.unwrap_or_else(|| stem.to_string()), + description: p.description, + builtin: false, + path: Some(path.to_path_buf()), + look: p.look, + }) +} + +/// Read a template file the user picked (import): size-capped, then [`parse`]d. Its name is the +/// document's, else the file's. +pub fn read_file(path: &Path) -> Result<Parsed, String> { + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + if meta.len() > MAX_FILE { + return Err(format!("{}: {} bytes is too large for a slideshow template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024)); + } + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut p = parse(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if p.name.is_none() { + let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + p.name = valid_name(&clean_text(&stem, NAME_MAX)).ok(); + } + Ok(p) +} + +fn write_json(path: &Path, v: &Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + lightcraft_catalog::safe_file::write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +impl Session { + /// Keep user slideshow templates in `<config>/slideshow-templates` (hosts with a file system). + pub fn with_default_slide_templates(mut self) -> Self { + self.slide_templates_dir = crate::config::config_dir().map(|d| d.join(DIR_NAME)); + self + } + + /// Every template (built-in, then the user's), read again when the templates folder changed. + pub fn slide_templates(&mut self) -> Arc<Listing> { + let now = stamp(self.slide_templates_dir.as_deref()); + if let Some((st, l)) = &self.slide_templates_cache + && *st == now + { + return l.clone(); + } + let mut l = Listing { templates: builtins().into_iter().map(Arc::new).collect(), problems: Vec::new() }; + if let Some(dir) = self.slide_templates_dir.clone() { + read_dir(&dir, &mut l); + } + let l = Arc::new(l); + self.slide_templates_cache = Some((now, l.clone())); + l + } + + /// Forget the cached listing (after a write: a folder's time may not change within its + /// resolution). + fn templates_changed(&mut self) { + self.slide_templates_cache = None; + } + + /// The template `id` (or [`PREVIOUS`], the settings before the last apply). + pub fn slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> { + if id == PREVIOUS { + let look = self.slide_templates_previous.clone().ok_or("no previous settings (no template has been applied this session)")?; + return Ok(Arc::new(Template { + id: PREVIOUS.into(), + name: "Previous settings".into(), + description: String::new(), + builtin: false, + path: None, + look, + })); + } + if id.starts_with(USER) && user_stem(id).is_none() { + return Err(format!("`{id}` isn't a template id (user templates are `user:` and a file name LightCraft made)")); + } + self.slide_templates().get(id).cloned().ok_or_else(|| format!("no slideshow template `{id}`")) + } + + /// The template a command names: `id`, or `name` (case-insensitive). + pub fn slide_template_named(&mut self, id: Option<&str>, name: Option<&str>) -> Result<Arc<Template>, String> { + match (id.map(str::trim).filter(|s| !s.is_empty()), name.map(str::trim).filter(|s| !s.is_empty())) { + (Some(id), _) => self.slide_template(id), + (None, Some(name)) => self.slide_templates().by_name(name).cloned().ok_or_else(|| format!("no slideshow template named \"{name}\"")), + (None, None) => Err("name a template: `id` or `name`".into()), + } + } + + /// Replace the slideshow settings with `look` (prepare previews kept) and save them; the + /// settings it replaced become [`PREVIOUS`]. Returns the warnings for this library. + pub fn apply_slide_look(&mut self, look: &SlideLook) -> crate::Result<Vec<String>> { + let before = SlideLook::of(self); + let mut next = look.clone(); + next.playback.prepare_previews = self.slideshow.prepare_previews; + let next = SlideLook { + playback: next.playback.sanitized(), + layout: next.layout.sanitized(), + overlays: next.overlays.sanitized(), + titles: next.titles.sanitized(), + }; + let warnings = next.warnings(self); + if next != before { + (self.slideshow, self.slide_layout, self.slide_overlays, self.slide_titles) = (next.playback, next.layout, next.overlays, next.titles); + if let Err(e) = self.save_prefs() { + (self.slideshow, self.slide_layout, self.slide_overlays, self.slide_titles) = + (before.playback, before.layout, before.overlays, before.titles); + return Err(e); + } + self.slide_templates_previous = Some(before); + } + Ok(warnings) + } + + fn templates_dir(&self) -> Result<PathBuf, String> { + self.slide_templates_dir.clone().ok_or_else(|| { + "user templates need a settings folder, and this LightCraft has none (the web build, or LIGHTCRAFT_CONFIG_DIR is set empty)".into() + }) + } + + /// Is a user template file called `stem` there, or one whose name differs only in case? + fn stem_taken(&mut self, dir: &Path, stem: &str, except: Option<&str>) -> bool { + let lower = stem.to_lowercase(); + let other = |s: &str| except.is_none_or(|e| e.to_lowercase() != s.to_lowercase()); + (dir.join(format!("{stem}.json")).exists() && other(stem)) + || self.slide_templates().templates.iter().filter_map(|t| t.id.strip_prefix(USER)).any(|s| s.to_lowercase() == lower && other(s)) + } + + fn builtin_name(name: &str) -> bool { + builtins().iter().any(|b| b.name.to_lowercase() == name.to_lowercase()) + } + + /// Save the current settings as user template `name` (`replace`: over one of that name). + pub fn save_slide_template(&mut self, name: &str, replace: bool) -> Result<Arc<Template>, String> { + let name = valid_name(name)?; + if Self::builtin_name(&name) { + return Err(format!("\"{name}\" is the name of a built-in template; pick another name")); + } + let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?; + let dir = self.templates_dir()?; + if !replace && self.stem_taken(&dir, &stem, None) { + return Err(format!("a template named \"{name}\" (file {stem}.json) already exists: update it, or save under another name")); + } + let look = SlideLook::of(self); + write_json(&dir.join(format!("{stem}.json")), &doc(&name, "", &look))?; + self.templates_changed(); + self.slide_template(&format!("{USER}{stem}")) + } + + /// A user template (not a built-in). + fn user_template(&mut self, id: &str) -> Result<(Arc<Template>, PathBuf), String> { + if id.starts_with(BUILTIN) { + return Err(format!("`{id}` is a built-in template and can't be changed (save the settings as a template of your own)")); + } + if id == PREVIOUS { + return Err("the previous settings aren't a saved template".into()); + } + let t = self.slide_template(id)?; + let path = t.path.clone().ok_or_else(|| format!("`{id}` isn't a user template"))?; + Ok((t, path)) + } + + /// Replace user template `id`'s settings with the current ones (its name kept). + pub fn update_slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> { + let (t, path) = self.user_template(id)?; + write_json(&path, &doc(&t.name, &t.description, &SlideLook::of(self)))?; + self.templates_changed(); + self.slide_template(id) + } + + /// Rename user template `id` (its file follows the name). + pub fn rename_slide_template(&mut self, id: &str, name: &str) -> Result<Arc<Template>, String> { + let (t, path) = self.user_template(id)?; + let name = valid_name(name)?; + if Self::builtin_name(&name) { + return Err(format!("\"{name}\" is the name of a built-in template; pick another name")); + } + let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?; + let dir = self.templates_dir()?; + let old = id.strip_prefix(USER).unwrap_or_default().to_string(); + if self.stem_taken(&dir, &stem, Some(&old)) { + return Err(format!("a template named \"{name}\" (file {stem}.json) already exists")); + } + // the new name into the file, then the file to its new name (one rename: never two copies + // or none, and a change of case works on case-insensitive file systems too) + write_json(&path, &doc(&name, &t.description, &t.look))?; + let to = dir.join(format!("{stem}.json")); + if to != path { + std::fs::rename(&path, &to).map_err(|e| format!("{}: {e}", to.display()))?; + } + self.templates_changed(); + self.slide_template(&format!("{USER}{stem}")) + } + + /// Delete user template `id`'s file. + pub fn delete_slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> { + let (t, path) = self.user_template(id)?; + std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?; + self.templates_changed(); + Ok(t) + } + + /// Import the template file at `path` as a user template. A name already taken gets " (2)", + /// " (3)"…; returns the template and whether it was renamed. + pub fn import_slide_template(&mut self, path: &Path) -> Result<(Arc<Template>, bool), String> { + let p = read_file(path)?; + let dir = self.templates_dir()?; + let base = p.name.clone().ok_or_else(|| format!("{}: the template has no name, nor a file name to take one from", path.display()))?; + let mut name = base.clone(); + let mut n = 1; + loop { + let free = !Self::builtin_name(&name) && file_stem(&name).is_some_and(|stem| !self.stem_taken(&dir, &stem, None)); + if free { + break; + } + n += 1; + if n > 999 { + return Err(format!("too many templates named \"{base}\"")); + } + let suffix = format!(" ({n})"); + let keep: String = base.chars().take(NAME_MAX.saturating_sub(suffix.chars().count())).collect(); + name = format!("{}{suffix}", keep.trim_end()); + } + let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?; + write_json(&dir.join(format!("{stem}.json")), &doc(&name, &p.description, &p.look))?; + self.templates_changed(); + Ok((self.slide_template(&format!("{USER}{stem}"))?, name != base)) + } +} + +#[cfg(test)] +#[path = "tests_slide_template.rs"] +mod tests; diff --git a/crates/engine/src/slideshow_templates/cinema-wide.json b/crates/engine/src/slideshow_templates/cinema-wide.json new file mode 100644 index 000000000..d8660d604 --- /dev/null +++ b/crates/engine/src/slideshow_templates/cinema-wide.json @@ -0,0 +1,19 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Cinema Wide", + "description": "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.", + "playback": {"slideDuration": 6, "fadeDuration": 1.5, "fadeColor": "#000000", "randomOrder": false, "repeat": false}, + "layout": { + "aspect": "16:9", "linkMargins": false, "marginLeft": 0, "marginRight": 0, "marginTop": 0.09, "marginBottom": 0.09, + "zoomToFill": true, "backdropColor": "#000000" + }, + "overlays": { + "texts": [ + {"text": "{title}", "size": 0.03, "color": "#f0ece4", "opacity": 0.9, "anchor": "bottomLeft", "offsetX": 0.04, "offsetY": -0.025, "relativeTo": "slide", "shadow": false} + ] + }, + "titles": { + "ending": {"enabled": true, "color": "#000000", "content": "text", "text": "The End", "overrideColor": true, "textColor": "#e8e2d6", "scale": 0.07, "duration": 3} + } +} diff --git a/crates/engine/src/slideshow_templates/edge-to-edge.json b/crates/engine/src/slideshow_templates/edge-to-edge.json new file mode 100644 index 000000000..ba2943342 --- /dev/null +++ b/crates/engine/src/slideshow_templates/edge-to-edge.json @@ -0,0 +1,10 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Edge to Edge", + "description": "Every photo cropped to fill the whole screen, with slow crossfades.", + "playback": {"slideDuration": 6, "fadeDuration": 2, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": {"zoomToFill": true}, + "overlays": {}, + "titles": {} +} diff --git a/crates/engine/src/slideshow_templates/gallery-wall.json b/crates/engine/src/slideshow_templates/gallery-wall.json new file mode 100644 index 000000000..7703795a5 --- /dev/null +++ b/crates/engine/src/slideshow_templates/gallery-wall.json @@ -0,0 +1,20 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Gallery Wall", + "description": "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.", + "playback": {"slideDuration": 5, "fadeDuration": 1.2, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": { + "linkMargins": false, "marginLeft": 0.1, "marginRight": 0.1, "marginTop": 0.08, "marginBottom": 0.16, + "strokeBorder": true, "strokeColor": "#ffffff", "strokeWidth": 0.012, + "castShadow": true, "shadowOpacity": 0.35, "shadowOffset": 0.012, "shadowRadius": 0.02, "shadowAngle": -60, + "backdropColor": "#dcd9d4" + }, + "overlays": { + "ratingStars": {"enabled": true, "color": "#55524d", "size": 0.03, "anchor": "bottomRight", "offsetX": 0, "offsetY": 0.075, "relativeTo": "photo"}, + "texts": [ + {"text": "{caption}", "size": 0.032, "color": "#3c3a36", "opacity": 1, "anchor": "bottomLeft", "offsetX": 0, "offsetY": 0.08, "relativeTo": "photo", "shadow": false} + ] + }, + "titles": {} +} diff --git a/crates/engine/src/slideshow_templates/plain-black.json b/crates/engine/src/slideshow_templates/plain-black.json new file mode 100644 index 000000000..83085cedb --- /dev/null +++ b/crates/engine/src/slideshow_templates/plain-black.json @@ -0,0 +1,10 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Plain Black", + "description": "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.", + "playback": {"slideDuration": 4, "fadeDuration": 1, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": {}, + "overlays": {}, + "titles": {} +} diff --git a/crates/engine/src/slideshow_templates/shooting-data.json b/crates/engine/src/slideshow_templates/shooting-data.json new file mode 100644 index 000000000..4e95fcddf --- /dev/null +++ b/crates/engine/src/slideshow_templates/shooting-data.json @@ -0,0 +1,18 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Shooting Data", + "description": "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.", + "playback": {"slideDuration": 5, "fadeDuration": 0.8, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": { + "linkMargins": false, "marginLeft": 0.05, "marginRight": 0.05, "marginTop": 0.05, "marginBottom": 0.15, + "backdropColor": "#1d2126", "colorWash": true, "washColor": "#3a4550", "washOpacity": 0.7, "washAngle": 90 + }, + "overlays": { + "texts": [ + {"text": "{exposure} ISO {iso} {focal}", "size": 0.034, "color": "#e6e8ea", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.062, "relativeTo": "slide", "shadow": false}, + {"text": "{camera} {lens}", "size": 0.026, "color": "#9aa4ae", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.024, "relativeTo": "slide", "shadow": false} + ] + }, + "titles": {} +} diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/tests_slide_template.rs new file mode 100644 index 000000000..3adfcdcca --- /dev/null +++ b/crates/engine/src/tests_slide_template.rs @@ -0,0 +1,360 @@ +//! Slideshow templates: built-ins, user template files, validation, apply / previous. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::*; +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-sstpl-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A demo session whose user templates live in a fresh folder. +fn session(tag: &str) -> (Session, PathBuf) { + let dir = temp_dir(tag); + let mut s = Session::with_demo(); + s.slide_templates_dir = Some(dir.join(DIR_NAME)); + (s, dir) +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + match s.execute(id, &p) { + Ok(v) => panic!("{id} {p} should fail, got {v}"), + Err(e) => e.to_string(), + } +} + +fn ids(s: &mut Session) -> Vec<String> { + run(s, "slideshow.templates.list", json!({}))["templates"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap().to_string()).collect() +} + +/// A look different from the defaults in every section. +fn custom(s: &mut Session) { + run(s, "slideshow.playback", json!({"slideDuration": 7.5, "fadeDuration": 0.5, "fadeColor": "#102030", "randomOrder": true, "repeat": false})); + run( + s, + "slideshow.layout", + json!({"margins": 0.1, "strokeBorder": true, "strokeColor": "#ff0000", "castShadow": true, "aspect": "4:3", "backdropColor": "#334455"}), + ); + run( + s, + "slideshow.overlays", + json!({"ratingStars": {"enabled": true}, "addText": {"text": "{caption} — {exposure}", "anchor": "top"}, "showWatermark": true, "watermark": "© me"}), + ); + run(s, "slideshow.titles", json!({"intro": {"enabled": true, "content": "text", "text": "Hello", "duration": 3}})); +} + +#[test] +fn builtins_are_valid_distinct_and_apply_cleanly() { + let b = builtins(); + assert_eq!(b.len(), BUILTINS.len(), "every built-in parses"); + assert!((4..=6).contains(&b.len())); + let mut names: Vec<_> = b.iter().map(|t| t.name.to_lowercase()).collect(); + names.dedup(); + assert_eq!(names.len(), b.len(), "unique names"); + // our own names: none of Lightroom Classic's built-in slideshow template names + for lr in ["default", "simple", "caption and rating", "crop to fill", "exif metadata", "widescreen"] { + assert!(!names.contains(&lr.to_string()), "{lr}"); + } + for t in &b { + assert!(!t.description.is_empty(), "{}", t.id); + // no image or font files: nothing that needs an attribution or can go missing + assert!( + t.look.overlays.identity_plate.image.is_empty() && t.look.overlays.watermark.image.is_empty() && t.look.layout.background_image.is_none() + ); + // the document form reads back to the same look + let back = parse(&serde_json::to_vec(&t.to_doc()).unwrap()).unwrap(); + assert_eq!(back.look, t.look, "{}", t.id); + } + let looks: Vec<_> = b.iter().map(|t| &t.look).collect(); + for (i, a) in looks.iter().enumerate() { + assert!(looks.iter().skip(i + 1).all(|o| o != a), "each built-in looks different"); + } + let (mut s, dir) = session("builtins"); + let active = s.active().unwrap(); + for t in b { + let r = run(&mut s, "slideshow.templates.apply", json!({"id": t.id})); + assert_eq!(r["warnings"], json!([]), "{}: {r}", t.id); + assert_eq!( + SlideLook { playback: PlaybackSettings { prepare_previews: true, ..t.look.playback.clone() }, ..t.look.clone() }, + SlideLook::of(&s) + ); + let img = run(&mut s, "slideshow.preview", json!({"id": active.0, "width": 160})); + assert_eq!(img["width"], 160, "{}", t.id); + } + // by name, case-insensitive + run(&mut s, "slideshow.templates.apply", json!({"name": "edge TO edge"})); + assert!(s.slide_layout.zoom_to_fill); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn save_list_apply_restores_exactly_and_previous_goes_back() { + let (mut s, dir) = session("roundtrip"); + s.slideshow.prepare_previews = false; + custom(&mut s); + let saved = SlideLook::of(&s); + let r = run(&mut s, "slideshow.templates.save", json!({"name": "My Look"})); + assert_eq!(r["id"], "user:My Look"); + let path = PathBuf::from(r["path"].as_str().unwrap()); + assert_eq!(path, dir.join(DIR_NAME).join("My Look.json")); + let doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some(FORMAT), Some(VERSION), Some("My Look"))); + assert!(doc["playback"].get("preparePreviews").is_none(), "a machine preference, not part of the look"); + assert!(ids(&mut s).contains(&"user:My Look".to_string())); + + run(&mut s, "slideshow.templates.apply", json!({"id": "builtin:plain-black"})); + assert_ne!(SlideLook::of(&s), saved); + assert!(!s.slideshow.prepare_previews, "prepare previews is kept"); + let r = run(&mut s, "slideshow.templates.apply", json!({"id": "user:My Look"})); + assert_eq!(r["applied"], "user:My Look"); + assert_eq!(SlideLook::of(&s), saved, "applying restores every setting exactly"); + + // previous: back to Plain Black's, then forward again + let plain = builtins().into_iter().find(|t| t.id == "builtin:plain-black").unwrap(); + run(&mut s, "slideshow.templates.apply", json!({"id": PREVIOUS})); + assert_eq!(s.slide_layout, plain.look.layout); + run(&mut s, "slideshow.templates.apply", json!({"id": PREVIOUS})); + assert_eq!(SlideLook::of(&s), saved); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn previous_needs_an_apply_first() { + let (mut s, dir) = session("noprev"); + assert_eq!(run(&mut s, "slideshow.templates.list", json!({}))["previous"], false); + assert!(err(&mut s, "slideshow.templates.apply", json!({"id": "previous"})).contains("no previous settings")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn update_rename_delete_export_import() { + let (mut s, dir) = session("ops"); + run(&mut s, "slideshow.templates.save", json!({"name": "Mine"})); + // a taken name, case-insensitively, and a built-in's are refused; replace overwrites + assert!(err(&mut s, "slideshow.templates.save", json!({"name": "mine"})).contains("already exists")); + assert!(err(&mut s, "slideshow.templates.save", json!({"name": "Plain Black"})).contains("built-in")); + run(&mut s, "slideshow.layout", json!({"margins": 0.2})); + run(&mut s, "slideshow.templates.save", json!({"name": "Mine", "replace": true})); + assert_eq!(s.slide_template("user:Mine").unwrap().look.layout.margin_top, 0.2); + // update + run(&mut s, "slideshow.layout", json!({"margins": 0.05})); + run(&mut s, "slideshow.templates.update", json!({"id": "user:Mine"})); + assert_eq!(s.slide_template("user:Mine").unwrap().look.layout.margin_top, 0.05); + assert!(err(&mut s, "slideshow.templates.update", json!({"id": "builtin:plain-black"})).contains("built-in")); + // rename (also just the case) + let r = run(&mut s, "slideshow.templates.rename", json!({"id": "user:Mine", "name": "Ours"})); + assert_eq!(r["id"], "user:Ours"); + assert!(!dir.join(DIR_NAME).join("Mine.json").exists()); + let r = run(&mut s, "slideshow.templates.rename", json!({"id": "user:Ours", "name": "OURS"})); + assert_eq!(r["id"], "user:OURS"); + assert_eq!(ids(&mut s).iter().filter(|i| i.starts_with(USER)).count(), 1); + run(&mut s, "slideshow.templates.save", json!({"name": "Other"})); + assert!(err(&mut s, "slideshow.templates.rename", json!({"id": "user:Other", "name": "ours"})).contains("already exists")); + assert!(err(&mut s, "slideshow.templates.rename", json!({"id": "builtin:cinema-wide", "name": "X"})).contains("built-in")); + // export a user template and a built-in, import them back + let out = dir.join("out"); + let a = out.join("ours.json").to_string_lossy().to_string(); + run(&mut s, "slideshow.templates.export", json!({"id": "user:OURS", "path": a})); + let b = out.join("cinema.json").to_string_lossy().to_string(); + run(&mut s, "slideshow.templates.export", json!({"id": "builtin:cinema-wide", "path": b})); + let r = run(&mut s, "slideshow.templates.import", json!({"path": a})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("OURS (2)"), Some(true)), "{r}"); + assert_eq!(s.slide_template("user:OURS (2)").unwrap().look, s.slide_template("user:OURS").unwrap().look); + let r = run(&mut s, "slideshow.templates.import", json!({"path": b})); + assert_eq!(r["name"], "Cinema Wide (2)", "a built-in's name is taken too"); + let cinema = s.slide_template("builtin:cinema-wide").unwrap(); + assert_eq!(s.slide_template("user:Cinema Wide (2)").unwrap().look, cinema.look); + // delete + run(&mut s, "slideshow.templates.delete", json!({"id": "user:OURS"})); + assert!(!ids(&mut s).contains(&"user:OURS".to_string())); + assert!(err(&mut s, "slideshow.templates.delete", json!({"id": "user:OURS"})).contains("no slideshow template")); + assert!(err(&mut s, "slideshow.templates.delete", json!({"id": "builtin:plain-black"})).contains("built-in")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_names_are_sanitised_and_ids_never_leave_the_folder() { + assert_eq!(file_stem("../../etc/passwd").as_deref(), Some("______etc_passwd")); + assert_eq!(file_stem(r"C:\x\y").as_deref(), Some("C__x_y")); + assert_eq!(file_stem("a:b*c?d\"e<f>g|h").as_deref(), Some("a_b_c_d_e_f_g_h")); + assert_eq!(file_stem(" Holiday (2024) ").as_deref(), Some("Holiday (2024)")); + assert_eq!(file_stem("Été à Kyōto 京都").as_deref(), Some("Été à Kyōto 京都")); + assert_eq!(file_stem("con").as_deref(), Some("con_")); + assert_eq!(file_stem("LPT1").as_deref(), Some("LPT1_")); + assert_eq!(file_stem("..."), None); + assert_eq!(file_stem("///"), None); + assert_eq!(file_stem(&"x".repeat(500)).map(|s| s.chars().count()), Some(64)); + let (mut s, dir) = session("escape"); + let folder = dir.join(DIR_NAME); + let r = run(&mut s, "slideshow.templates.save", json!({"name": "../../escape"})); + assert_eq!(r["id"], "user:______escape"); + assert!(folder.join("______escape.json").exists()); + assert!(!dir.join("escape.json").exists()); + for name in ["", " ", "...", "/", "a\nb", &"y".repeat(81)] { + err(&mut s, "slideshow.templates.save", json!({"name": name})); + } + // an id can't point outside the folder (or at a file LightCraft wouldn't name) + std::fs::write(dir.join("victim.json"), b"{}").unwrap(); + for id in ["user:../victim", "user:..", "user:/etc/passwd", "user:a/b", "user:", "user:x.json"] { + let e = err(&mut s, "slideshow.templates.delete", json!({"id": id})); + assert!(e.contains("isn't a template id"), "{id}: {e}"); + } + assert!(dir.join("victim.json").exists()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn bad_files_are_reported_and_skipped() { + let (mut s, dir) = session("bad"); + let folder = dir.join(DIR_NAME); + std::fs::create_dir_all(&folder).unwrap(); + run(&mut s, "slideshow.templates.save", json!({"name": "Good"})); + let doc = |extra: Value| { + let mut d = json!({"format": FORMAT, "version": 1, "name": "X"}); + for (k, v) in extra.as_object().unwrap() { + d[k] = v.clone(); + } + serde_json::to_vec(&d).unwrap() + }; + let files: Vec<(&str, Vec<u8>, &str)> = vec![ + ("truncated.json", br#"{"format": "lightcraft.slideshowTemplate", "vers"#.to_vec(), "not a slideshow template"), + ("binary.json", vec![0xff, 0xfe, 0x00, 0x01], "not UTF-8"), + ("array.json", b"[1, 2]".to_vec(), "not a JSON object"), + ("preset.json", br#"{"format": "lightcraft.preset", "version": 1}"#.to_vec(), "format `lightcraft.preset`"), + ("newer.json", doc(json!({"version": 2})), "newer LightCraft"), + ("noversion.json", br#"{"format": "lightcraft.slideshowTemplate"}"#.to_vec(), "no `version`"), + ("badversion.json", doc(json!({"version": -1})), "`version`"), + ("unknown.json", doc(json!({"music": {}})), "unknown key `music`"), + ("nan.json", doc(json!({"layout": {"margins": "wide"}})), "layout"), + ("badkey.json", doc(json!({"overlays": {"fireworks": true}})), "overlays"), + ("badtype.json", doc(json!({"titles": [1]})), "`titles` must be an object"), + ("toomanytexts.json", doc(json!({"overlays": {"texts": vec![json!({}); 40]}})), "at most"), + ("badname.json", doc(json!({"name": 5})), "`name`"), + ("huge.json", vec![b' '; MAX_FILE as usize + 1], "too large"), + ("bad name!.json", doc(json!({})), "file name"), + ]; + for (f, bytes, _) in &files { + std::fs::write(folder.join(f), bytes).unwrap(); + } + std::fs::create_dir_all(folder.join("folder.json")).unwrap(); + std::fs::write(folder.join("notes.txt"), b"ignored").unwrap(); + let r = run(&mut s, "slideshow.templates.list", json!({})); + let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect(); + assert_eq!(user.len(), 1, "only the good one: {r}"); + let problems: Vec<String> = r["problems"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + for (f, _, why) in &files { + assert!(problems.iter().any(|p| p.starts_with(f) && p.contains(why)), "{f}: {why} in {problems:?}"); + } + assert!(problems.iter().any(|p| p.starts_with("folder.json") && p.contains("not a regular file")), "{problems:?}"); + assert_eq!(problems.len(), files.len() + 1, "{problems:?}"); + // the same refusals on import + for (f, _, why) in files.iter().filter(|(f, _, _)| *f != "bad name!.json") { + let e = err(&mut s, "slideshow.templates.import", json!({"path": folder.join(f)})); + assert!(e.contains(why), "{f}: {e}"); + } + // out-of-range numbers in a hand-edited file are clamped, never applied as they are + std::fs::write(folder.join("wild.json"), doc(json!({"layout": {"margins": 9, "shadowAngle": 1e300}, "playback": {"slideDuration": 1e9}}))) + .unwrap(); + let t = s.slide_template("user:wild").unwrap(); + assert_eq!(t.look.layout.margin_left, lightcraft_slideshow::MARGIN_MAX); + assert_eq!(t.look.playback.slide_duration, crate::slideshow::SLIDE_MAX); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn thousands_of_templates_are_capped_not_fatal() { + let (mut s, dir) = session("many"); + let folder = dir.join(DIR_NAME); + std::fs::create_dir_all(&folder).unwrap(); + let body = |i: usize| format!(r#"{{"format": "{FORMAT}", "version": 1, "name": "T{i}"}}"#); + for i in 0..MAX_TEMPLATES + 50 { + std::fs::write(folder.join(format!("T{i:05}.json")), body(i)).unwrap(); + } + let r = run(&mut s, "slideshow.templates.list", json!({})); + let user = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).count(); + assert_eq!(user, MAX_TEMPLATES); + assert!(r["problems"][0].as_str().unwrap().contains("only the first"), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_files_apply_with_a_warning() { + let (mut s, dir) = session("missing"); + let folder = dir.join(DIR_NAME); + std::fs::create_dir_all(&folder).unwrap(); + let d = json!({ + "format": FORMAT, "version": 1, "name": "Logo", + "layout": {"backgroundImage": 987654}, + "overlays": {"identityPlate": {"enabled": true, "image": "/no/such/logo.png"}, "showWatermark": true, "watermark": {"text": "", "image": "/no/such/mark.png"}} + }); + std::fs::write(folder.join("Logo.json"), serde_json::to_vec(&d).unwrap()).unwrap(); + let r = run(&mut s, "slideshow.templates.apply", json!({"id": "user:Logo"})); + let w = r["warnings"].to_string(); + assert!(w.contains("background image") && w.contains("logo.png") && w.contains("mark.png"), "{w}"); + assert_eq!(s.slide_overlays.identity_plate.image, "/no/such/logo.png", "applied anyway"); + // the slide still renders (the backdrop colour, no plate) + let id = s.active().unwrap(); + run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 64})); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preview_of_a_template_leaves_the_settings_alone() { + let (mut s, dir) = session("preview"); + custom(&mut s); + let before = SlideLook::of(&s); + let id = s.active().unwrap(); + let mine = run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 200, "height": 120})); + let r = run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 200, "height": 120, "template": "builtin:edge-to-edge"})); + assert_eq!(r["template"], "builtin:edge-to-edge"); + assert_eq!(r["photo"], json!([0.0, 0.0, 200.0, 120.0]), "zoom to fill, no margins: {r}"); + assert_ne!(r["photo"], mine["photo"]); + assert_eq!(r["overlays"], json!([]), "the template's overlays (none), not the library's"); + let t = run(&mut s, "slideshow.preview", json!({"title": "ending", "template": "builtin:cinema-wide", "width": 160})); + assert_eq!((t["enabled"].as_bool(), t["height"].as_u64()), (Some(true), Some(90)), "the template's ending, at its 16:9 aspect: {t}"); + assert_eq!(SlideLook::of(&s), before, "nothing changed"); + assert!(s.slide_templates_previous.is_none()); + assert!(err(&mut s, "slideshow.preview", json!({"template": "builtin:nope"})).contains("no slideshow template")); + assert!(err(&mut s, "slideshow.preview", json!({"template": 3})).contains("template id")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn without_a_settings_folder_only_builtins() { + let mut s = Session::with_demo(); + assert!(s.slide_templates_dir.is_none()); + let r = run(&mut s, "slideshow.templates.list", json!({})); + assert_eq!(r["templates"].as_array().unwrap().len(), BUILTINS.len()); + assert!(err(&mut s, "slideshow.templates.save", json!({"name": "X"})).contains("settings folder")); + run(&mut s, "slideshow.templates.apply", json!({"id": "builtin:gallery-wall"})); + assert!(s.slide_overlays.rating_stars.enabled); +} + +#[test] +fn a_saved_template_reads_back_through_its_section_validation() { + let (mut s, dir) = session("sections"); + custom(&mut s); + let look = SlideLook::of(&s); + let parsed = parse(&serde_json::to_vec(&doc("N", "d", &look)).unwrap()).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("N")); + assert_eq!(parsed.description, "d"); + assert_eq!(parsed.look.layout, look.layout); + assert_eq!(parsed.look.overlays, look.overlays); + assert_eq!(parsed.look.titles, look.titles); + assert_eq!(PlaybackSettings { prepare_previews: look.playback.prepare_previews, ..parsed.look.playback }, look.playback); + // a BOM and a missing name are fine + let mut bytes = "\u{feff}".as_bytes().to_vec(); + bytes.extend(br#"{"format": "lightcraft.slideshowTemplate", "version": 1}"#); + assert_eq!(parse(&bytes).unwrap().name, None); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index acbecb60b..d939002b9 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -32,7 +32,9 @@ impl Drop for Headless { impl Default for Headless { fn default() -> Self { - Self::new(Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) + Self::new( + Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(), + ) } } @@ -43,7 +45,14 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { - Self::new(Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) + Self::new( + Session::with_demo() + .with_fs() + .with_default_denoise_models() + .with_system_clock() + .with_default_face_models() + .with_default_slide_templates(), + ) } fn photo_or_active(&self, p: &Value) -> Result<PhotoId, String> { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 671da17d4..ed7549c32 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -228,5 +228,14 @@ "+{n} more": "+{n} weitere", "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", - "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen" + "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", + "Applied {name}": "{name} angewendet", + "Delete “{name}”?": "„{name}“ löschen?", + "Updated {name}": "{name} aktualisiert", + "Exported to {path}": "Exportiert nach {path}", + "Saved template {name}": "Vorlage {name} gespeichert", + "Imported {name}": "{name} importiert", + "Skipped {problem}": "Übersprungen: {problem}", + "…and {more} more": "…und {more} weitere", + "Template preview: {name}": "Vorlagenvorschau: {name}" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c98bd2f41..78b215d7e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2093,5 +2093,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Das Intro läuft einmal vor dem ersten Foto, der Abschluss nach dem letzten Foto, wenn Wiederholen aus ist. Die Erkennungstafel ist die unter Überlagerungen eingestellte. Titel werden wie Dias überblendet, und die zufällige Reihenfolge verschiebt sie nie.", "This screen is off: turn it on under Titles to play it.": "Dieser Bildschirm ist aus: Schalten Sie ihn unter Titel ein, um ihn abzuspielen.", "The intro screen, before the first photo.": "Der Intro-Bildschirm, vor dem ersten Foto.", - "The ending screen, after the last photo.": "Der Abschlussbildschirm, nach dem letzten Foto." + "The ending screen, after the last photo.": "Der Abschlussbildschirm, nach dem letzten Foto.", + "Templates": "Vorlagen", + "Built-in": "Integriert", + "Your templates": "Eigene Vorlagen", + "None yet: save the current settings as a template below.": "Noch keine: Speichern Sie die aktuellen Einstellungen unten als Vorlage.", + "Before the last template": "Vor der letzten Vorlage", + "Previous settings": "Vorherige Einstellungen", + "New template name": "Name der neuen Vorlage", + "Save as Template": "Als Vorlage speichern", + "Import…": "Importieren…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Eigene Vorlagen brauchen einen Einstellungsordner, den diese Version nicht hat; die integrierten funktionieren.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Klicken Sie auf eine Vorlage, um sie anzuwenden; zeigen Sie auf eine, um sie am ausgewählten Foto in der Vorschau zu sehen. Rechtsklicken Sie auf eigene, um sie zu aktualisieren, umzubenennen, zu exportieren oder zu löschen.", + "Import Slideshow Templates": "Diashow-Vorlagen importieren", + "Export Slideshow Template": "Diashow-Vorlage exportieren", + "Slideshow Templates": "Diashow-Vorlagen", + "Plain Black": "Schlicht schwarz", + "Gallery Wall": "Galeriewand", + "Edge to Edge": "Randlos", + "Cinema Wide": "Kino-Breitbild", + "Shooting Data": "Aufnahmedaten", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Jedes Foto so groß wie möglich auf Schwarz, mit kurzer Überblendung. Nichts darüber gezeichnet.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 9ba1c8f10..167288981 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -228,5 +228,14 @@ "+{n} more": "+{n} más", "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", - "{what} merge cancelled": "Combinación {what} cancelada" + "{what} merge cancelled": "Combinación {what} cancelada", + "Applied {name}": "Se aplicó {name}", + "Delete “{name}”?": "¿Eliminar «{name}»?", + "Updated {name}": "Se actualizó {name}", + "Exported to {path}": "Exportado a {path}", + "Saved template {name}": "Plantilla {name} guardada", + "Imported {name}": "Se importó {name}", + "Skipped {problem}": "Omitido: {problem}", + "…and {more} more": "…y {more} más", + "Template preview: {name}": "Vista previa de la plantilla: {name}" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e18046491..e826a8393 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1938,5 +1938,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "La introducción se reproduce una vez antes de la primera foto; el final, después de la última foto cuando Repetir está desactivado. La placa de identidad es la definida en Superposiciones. Los títulos se funden como las diapositivas y el orden aleatorio nunca los mueve.", "This screen is off: turn it on under Titles to play it.": "Esta pantalla está desactivada: actívela en Títulos para reproducirla.", "The intro screen, before the first photo.": "La pantalla de introducción, antes de la primera foto.", - "The ending screen, after the last photo.": "La pantalla final, después de la última foto." + "The ending screen, after the last photo.": "La pantalla final, después de la última foto.", + "Templates": "Plantillas", + "Built-in": "Integradas", + "Your templates": "Tus plantillas", + "None yet: save the current settings as a template below.": "Aún no hay ninguna: guarda los ajustes actuales como plantilla abajo.", + "Before the last template": "Antes de la última plantilla", + "Previous settings": "Ajustes anteriores", + "New template name": "Nombre de la nueva plantilla", + "Save as Template": "Guardar como plantilla", + "Import…": "Importar…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Tus propias plantillas necesitan una carpeta de ajustes, que esta versión no tiene; las integradas funcionan.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Haz clic en una plantilla para aplicarla; señala una para previsualizarla en la foto seleccionada. Haz clic derecho en las tuyas para actualizarlas, cambiarles el nombre, exportarlas o eliminarlas.", + "Import Slideshow Templates": "Importar plantillas de presentación", + "Export Slideshow Template": "Exportar plantilla de presentación", + "Slideshow Templates": "Plantillas de presentación", + "Plain Black": "Negro liso", + "Gallery Wall": "Pared de galería", + "Edge to Edge": "De borde a borde", + "Cinema Wide": "Cine panorámico", + "Shooting Data": "Datos de toma", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Cada foto lo más grande posible, sobre negro, con un fundido cruzado breve. Nada encima.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una pared gris claro con paspartú blanco y sombra suave; el pie de foto y la valoración debajo de la foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para llenar toda la pantalla, con fundidos cruzados lentos.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un encuadre 16:9 con franjas negras arriba y abajo, el título en la esquina, fundidos a negro y un cartel final.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fondo pizarra oscuro con la exposición, el ISO y la distancia focal, y luego la cámara y el objetivo, bajo cada foto." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 7ccaf5a38..f5c7823fa 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -228,5 +228,14 @@ "+{n} more": "+{n} autres", "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", - "{what} merge cancelled": "Fusion {what} annulée" + "{what} merge cancelled": "Fusion {what} annulée", + "Applied {name}": "{name} appliqué", + "Delete “{name}”?": "Supprimer « {name} » ?", + "Updated {name}": "{name} mis à jour", + "Exported to {path}": "Exporté vers {path}", + "Saved template {name}": "Modèle {name} enregistré", + "Imported {name}": "{name} importé", + "Skipped {problem}": "Ignoré : {problem}", + "…and {more} more": "…et {more} de plus", + "Template preview: {name}": "Aperçu du modèle : {name}" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 5eab00c00..dece0397c 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1938,5 +1938,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'introduction est lue une fois avant la première photo ; l'écran de fin après la dernière photo lorsque Répéter est désactivé. La plaque d'identification est celle définie sous Incrustations. Les titres apparaissent en fondu comme les diapositives et l'ordre aléatoire ne les déplace jamais.", "This screen is off: turn it on under Titles to play it.": "Cet écran est désactivé : activez-le sous Titres pour le lire.", "The intro screen, before the first photo.": "L'écran d'introduction, avant la première photo.", - "The ending screen, after the last photo.": "L'écran de fin, après la dernière photo." + "The ending screen, after the last photo.": "L'écran de fin, après la dernière photo.", + "Templates": "Modèles", + "Built-in": "Intégrés", + "Your templates": "Vos modèles", + "None yet: save the current settings as a template below.": "Aucun pour l'instant : enregistrez les réglages actuels comme modèle ci-dessous.", + "Before the last template": "Avant le dernier modèle", + "Previous settings": "Réglages précédents", + "New template name": "Nom du nouveau modèle", + "Save as Template": "Enregistrer comme modèle", + "Import…": "Importer…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Vos propres modèles nécessitent un dossier de réglages, absent de cette version ; les modèles intégrés fonctionnent.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Cliquez sur un modèle pour l'appliquer ; survolez-en un pour l'afficher sur la photo sélectionnée. Clic droit sur les vôtres pour les mettre à jour, les renommer, les exporter ou les supprimer.", + "Import Slideshow Templates": "Importer des modèles de diaporama", + "Export Slideshow Template": "Exporter le modèle de diaporama", + "Slideshow Templates": "Modèles de diaporama", + "Plain Black": "Noir uni", + "Gallery Wall": "Mur de galerie", + "Edge to Edge": "Bord à bord", + "Cinema Wide": "Cinéma large", + "Shooting Data": "Données de prise de vue", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Chaque photo aussi grande que possible, sur noir, avec un court fondu enchaîné. Rien par-dessus.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Un mur gris clair avec un passe-partout blanc et une ombre douce ; la légende et la note sous la photo.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Chaque photo recadrée pour remplir tout l'écran, avec des fondus enchaînés lents.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un cadre 16:9 avec des bandes noires en haut et en bas, le titre dans le coin, des fondus au noir et un carton de fin.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fond ardoise sombre avec l'exposition, l'ISO et la focale, puis le boîtier et l'objectif, sous chaque photo." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index f1c06351c..e40560762 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -228,5 +228,14 @@ "+{n} more": "ほか{n}件", "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", - "{what} merge cancelled": "{what}合成をキャンセルしました" + "{what} merge cancelled": "{what}合成をキャンセルしました", + "Applied {name}": "{name} を適用しました", + "Delete “{name}”?": "「{name}」を削除しますか?", + "Updated {name}": "{name} を更新しました", + "Exported to {path}": "{path} に書き出しました", + "Saved template {name}": "テンプレート {name} を保存しました", + "Imported {name}": "{name} を読み込みました", + "Skipped {problem}": "スキップ: {problem}", + "…and {more} more": "…ほか {more} 件", + "Template preview: {name}": "テンプレートのプレビュー: {name}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 81c57d3e1..62ce87d27 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1940,5 +1940,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "イントロは最初の写真の前に 1 回再生され、エンディングは繰り返しがオフのとき最後の写真の後に再生されます。ID プレートはオーバーレイで設定したものです。タイトルはスライドと同様にフェードし、ランダム順でも位置は変わりません。", "This screen is off: turn it on under Titles to play it.": "この画面はオフです。再生するにはタイトルでオンにしてください。", "The intro screen, before the first photo.": "イントロ画面(最初の写真の前)。", - "The ending screen, after the last photo.": "エンディング画面(最後の写真の後)。" + "The ending screen, after the last photo.": "エンディング画面(最後の写真の後)。", + "Templates": "テンプレート", + "Built-in": "組み込み", + "Your templates": "自分のテンプレート", + "None yet: save the current settings as a template below.": "まだありません。下で現在の設定をテンプレートとして保存できます。", + "Before the last template": "最後のテンプレートの前", + "Previous settings": "以前の設定", + "New template name": "新しいテンプレート名", + "Save as Template": "テンプレートとして保存", + "Import…": "読み込み…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "自分のテンプレートには設定フォルダーが必要ですが、このビルドにはありません。組み込みテンプレートは使えます。", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "テンプレートをクリックすると適用、ポインターを合わせると選択中の写真でプレビューします。自分のテンプレートは右クリックで更新・名前変更・書き出し・削除できます。", + "Import Slideshow Templates": "スライドショーテンプレートを読み込み", + "Export Slideshow Template": "スライドショーテンプレートを書き出し", + "Slideshow Templates": "スライドショーテンプレート", + "Plain Black": "シンプルブラック", + "Gallery Wall": "ギャラリーの壁", + "Edge to Edge": "全面表示", + "Cinema Wide": "シネマワイド", + "Shooting Data": "撮影データ", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "各写真を黒地にできるだけ大きく表示し、短いクロスフェードでつなぎます。上には何も描きません。", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "明るいグレーの壁に白いマットと柔らかな影。写真の下にキャプションとレーティング。", + "Every photo cropped to fill the whole screen, with slow crossfades.": "各写真を画面全体に合わせて切り抜き、ゆっくりクロスフェードします。", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "上下に黒帯のある16:9の画面、隅にタイトル、黒を経由するフェード、エンディングカード。", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "暗いスレート色の背景。各写真の下に露出・ISO・焦点距離、その下にカメラとレンズ。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 4a9d1168a..6b5b32988 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -228,5 +228,14 @@ "+{n} more": "+{n} mais", "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", - "{what} merge cancelled": "{what} mesclagem cancelada" + "{what} merge cancelled": "{what} mesclagem cancelada", + "Applied {name}": "{name} aplicado", + "Delete “{name}”?": "Excluir “{name}”?", + "Updated {name}": "{name} atualizado", + "Exported to {path}": "Exportado para {path}", + "Saved template {name}": "Modelo {name} salvo", + "Imported {name}": "{name} importado", + "Skipped {problem}": "Ignorado: {problem}", + "…and {more} more": "…e mais {more}", + "Template preview: {name}": "Prévia do modelo: {name}" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9be14b0d2..9329b2d23 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1938,5 +1938,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "A abertura é exibida uma vez antes da primeira foto; o encerramento, após a última foto quando Repetir está desativado. A placa de identidade é a definida em Sobreposições. Os títulos fazem transição como os slides e a ordem aleatória nunca os move.", "This screen is off: turn it on under Titles to play it.": "Esta tela está desativada: ative-a em Títulos para exibi-la.", "The intro screen, before the first photo.": "A tela de abertura, antes da primeira foto.", - "The ending screen, after the last photo.": "A tela de encerramento, após a última foto." + "The ending screen, after the last photo.": "A tela de encerramento, após a última foto.", + "Templates": "Modelos", + "Built-in": "Integrados", + "Your templates": "Seus modelos", + "None yet: save the current settings as a template below.": "Nenhum ainda: salve as configurações atuais como modelo abaixo.", + "Before the last template": "Antes do último modelo", + "Previous settings": "Configurações anteriores", + "New template name": "Nome do novo modelo", + "Save as Template": "Salvar como modelo", + "Import…": "Importar…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Seus próprios modelos precisam de uma pasta de configurações, que esta versão não tem; os integrados funcionam.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Clique em um modelo para aplicá-lo; aponte para um para prévia na foto selecionada. Clique com o botão direito nos seus para atualizar, renomear, exportar ou excluir.", + "Import Slideshow Templates": "Importar modelos de apresentação", + "Export Slideshow Template": "Exportar modelo de apresentação", + "Slideshow Templates": "Modelos de apresentação", + "Plain Black": "Preto simples", + "Gallery Wall": "Parede de galeria", + "Edge to Edge": "De ponta a ponta", + "Cinema Wide": "Cinema widescreen", + "Shooting Data": "Dados da captura", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Cada foto o maior possível, sobre preto, com uma transição curta. Nada desenhado por cima.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Uma parede cinza-clara com passe-partout branco e sombra suave; a legenda e a classificação abaixo da foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para preencher a tela inteira, com transições lentas.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Um quadro 16:9 com faixas pretas acima e abaixo, o título no canto, transições pelo preto e uma cartela final.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Um fundo ardósia escuro com a exposição, ISO e distância focal, e depois a câmera e a lente, abaixo de cada foto." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index ba3560ad0..177857dc4 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -228,5 +228,14 @@ "+{n} more": "ещё {n}", "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", - "{what} merge cancelled": "{what}: слияние отменено" + "{what} merge cancelled": "{what}: слияние отменено", + "Applied {name}": "Применено: {name}", + "Delete “{name}”?": "Удалить «{name}»?", + "Updated {name}": "Обновлено: {name}", + "Exported to {path}": "Экспортировано в {path}", + "Saved template {name}": "Шаблон {name} сохранён", + "Imported {name}": "Импортировано: {name}", + "Skipped {problem}": "Пропущено: {problem}", + "…and {more} more": "…и ещё {more}", + "Template preview: {name}": "Просмотр шаблона: {name}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 34cb5f33c..2a0b609eb 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1653,5 +1653,30 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступление показывается один раз перед первым фото, финал — после последнего фото, если повтор выключен. Фирменная табличка — та, что задана в разделе «Наложения». Титры появляются и исчезают как слайды, а случайный порядок их не перемещает.", "This screen is off: turn it on under Titles to play it.": "Этот экран выключен: включите его в разделе «Титры», чтобы показывать.", "The intro screen, before the first photo.": "Вступительный экран, перед первым фото.", - "The ending screen, after the last photo.": "Заключительный экран, после последнего фото." + "The ending screen, after the last photo.": "Заключительный экран, после последнего фото.", + "Templates": "Шаблоны", + "Built-in": "Встроенные", + "Your templates": "Ваши шаблоны", + "None yet: save the current settings as a template below.": "Пока нет: сохраните текущие настройки как шаблон ниже.", + "Before the last template": "До последнего шаблона", + "Previous settings": "Предыдущие настройки", + "New template name": "Имя нового шаблона", + "Save as Template": "Сохранить как шаблон", + "Import…": "Импорт…", + "Apply": "Применить", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Для своих шаблонов нужна папка настроек, которой в этой сборке нет; встроенные работают.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Щёлкните шаблон, чтобы применить его; наведите указатель, чтобы увидеть его на выбранном фото. Щёлкните свой шаблон правой кнопкой, чтобы обновить, переименовать, экспортировать или удалить его.", + "Import Slideshow Templates": "Импорт шаблонов слайд-шоу", + "Export Slideshow Template": "Экспорт шаблона слайд-шоу", + "Slideshow Templates": "Шаблоны слайд-шоу", + "Plain Black": "Просто чёрный", + "Gallery Wall": "Стена галереи", + "Edge to Edge": "От края до края", + "Cinema Wide": "Широкий экран", + "Shooting Data": "Данные съёмки", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Каждое фото как можно крупнее на чёрном, с короткой сменой наплывом. Поверх ничего.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Светло-серая стена с белым паспарту и мягкой тенью; подпись и рейтинг под фото.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Каждое фото обрезано на весь экран, с медленной сменой наплывом.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 с чёрными полосами сверху и снизу, название в углу, затемнения через чёрный и финальная карточка.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Тёмный грифельный фон с экспозицией, ISO и фокусным расстоянием, а ниже камерой и объективом под каждым фото." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index c33dd0cb0..d334f2649 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -228,5 +228,14 @@ "+{n} more": "+{n} ще", "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", - "{what} merge cancelled": "Об’єднання {what} скасовано" + "{what} merge cancelled": "Об’єднання {what} скасовано", + "Applied {name}": "Застосовано: {name}", + "Delete “{name}”?": "Видалити «{name}»?", + "Updated {name}": "Оновлено: {name}", + "Exported to {path}": "Експортовано до {path}", + "Saved template {name}": "Шаблон {name} збережено", + "Imported {name}": "Імпортовано: {name}", + "Skipped {problem}": "Пропущено: {problem}", + "…and {more} more": "…і ще {more}", + "Template preview: {name}": "Перегляд шаблону: {name}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 2b41d5769..e7b3687bf 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2132,5 +2132,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступ показується один раз перед першим фото, фінал — після останнього фото, якщо повтор вимкнено. Фірмова табличка — та, що задана в розділі «Накладання». Титри з'являються й зникають як слайди, а випадковий порядок їх не переміщує.", "This screen is off: turn it on under Titles to play it.": "Цей екран вимкнено: увімкніть його в розділі «Титри», щоб показувати.", "The intro screen, before the first photo.": "Вступний екран, перед першим фото.", - "The ending screen, after the last photo.": "Завершальний екран, після останнього фото." + "The ending screen, after the last photo.": "Завершальний екран, після останнього фото.", + "Templates": "Шаблони", + "Built-in": "Вбудовані", + "Your templates": "Ваші шаблони", + "None yet: save the current settings as a template below.": "Поки немає: збережіть поточні налаштування як шаблон нижче.", + "Before the last template": "До останнього шаблону", + "Previous settings": "Попередні налаштування", + "New template name": "Назва нового шаблону", + "Save as Template": "Зберегти як шаблон", + "Import…": "Імпорт…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Для власних шаблонів потрібна тека налаштувань, якої в цій збірці немає; вбудовані працюють.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Клацніть шаблон, щоб застосувати його; наведіть вказівник, щоб побачити його на вибраному фото. Клацніть власний шаблон правою кнопкою, щоб оновити, перейменувати, експортувати чи видалити його.", + "Import Slideshow Templates": "Імпорт шаблонів слайд-шоу", + "Export Slideshow Template": "Експорт шаблону слайд-шоу", + "Slideshow Templates": "Шаблони слайд-шоу", + "Plain Black": "Просто чорний", + "Gallery Wall": "Стіна галереї", + "Edge to Edge": "Від краю до краю", + "Cinema Wide": "Широкий екран", + "Shooting Data": "Дані зйомки", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Кожне фото якомога більше на чорному, з короткою зміною накладанням. Поверх нічого.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Світло-сіра стіна з білим паспарту та м'якою тінню; підпис і рейтинг під фото.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Кожне фото обрізане на весь екран, з повільною зміною накладанням.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 з чорними смугами згори й знизу, назва в куті, затемнення через чорний і фінальна картка.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Темне грифельне тло з експозицією, ISO та фокусною відстанню, а нижче камерою й об'єктивом під кожним фото." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index eb429af42..0d6aa11ef 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -228,5 +228,14 @@ "+{n} more": "还有 {n} 项", "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", - "{what} merge cancelled": "已取消 {what} 合并" + "{what} merge cancelled": "已取消 {what} 合并", + "Applied {name}": "已应用 {name}", + "Delete “{name}”?": "删除“{name}”?", + "Updated {name}": "已更新 {name}", + "Exported to {path}": "已导出到 {path}", + "Saved template {name}": "已保存模板 {name}", + "Imported {name}": "已导入 {name}", + "Skipped {problem}": "已跳过:{problem}", + "…and {more} more": "…还有 {more} 个", + "Template preview: {name}": "模板预览:{name}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 9dfa236e0..0780ecb42 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1938,5 +1938,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片头在第一张照片之前播放一次;关闭“重复”时,片尾在最后一张照片之后播放。身份标识即“叠加”中设置的那个。标题与幻灯片一样淡入淡出,随机顺序不会移动它们。", "This screen is off: turn it on under Titles to play it.": "此画面已关闭:在“标题”中打开即可播放。", "The intro screen, before the first photo.": "片头画面,在第一张照片之前。", - "The ending screen, after the last photo.": "片尾画面,在最后一张照片之后。" + "The ending screen, after the last photo.": "片尾画面,在最后一张照片之后。", + "Templates": "模板", + "Built-in": "内置", + "Your templates": "你的模板", + "None yet: save the current settings as a template below.": "还没有:在下方将当前设置保存为模板。", + "Before the last template": "应用上一个模板之前", + "Previous settings": "之前的设置", + "New template name": "新模板名称", + "Save as Template": "存储为模板", + "Import…": "导入…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "你自己的模板需要设置文件夹,而此版本没有;内置模板可以使用。", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "单击模板即可应用;指向模板可在所选照片上预览。右键单击你自己的模板可更新、重命名、导出或删除。", + "Import Slideshow Templates": "导入幻灯片放映模板", + "Export Slideshow Template": "导出幻灯片放映模板", + "Slideshow Templates": "幻灯片放映模板", + "Plain Black": "纯黑", + "Gallery Wall": "画廊墙", + "Edge to Edge": "满屏", + "Cinema Wide": "宽银幕", + "Shooting Data": "拍摄数据", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "每张照片尽可能大地显示在黑色背景上,带短暂交叉淡化。上面不绘制任何内容。", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "浅灰色墙面、白色卡纸和柔和阴影;照片下方显示说明和星级。", + "Every photo cropped to fill the whole screen, with slow crossfades.": "每张照片裁剪以填满整个屏幕,带缓慢交叉淡化。", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 画面,上下黑边,标题在角落,经黑色淡入淡出,并有结尾卡。", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每张照片下方显示曝光、ISO 和焦距,再下面是相机和镜头。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 0b2ec7657..e3e30a19b 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -228,5 +228,14 @@ "+{n} more": "還有 {n} 項", "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", - "{what} merge cancelled": "已取消{what}合併" + "{what} merge cancelled": "已取消{what}合併", + "Applied {name}": "已套用 {name}", + "Delete “{name}”?": "刪除「{name}」?", + "Updated {name}": "已更新 {name}", + "Exported to {path}": "已匯出到 {path}", + "Saved template {name}": "已儲存範本 {name}", + "Imported {name}": "已匯入 {name}", + "Skipped {problem}": "已略過:{problem}", + "…and {more} more": "…還有 {more} 個", + "Template preview: {name}": "範本預覽:{name}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index c217a16d6..66e802ef9 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1938,5 +1938,29 @@ "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片頭在第一張照片之前播放一次;關閉「重複」時,片尾在最後一張照片之後播放。身分標識即「疊加」中設定的那個。標題與幻燈片一樣淡入淡出,隨機順序不會移動它們。", "This screen is off: turn it on under Titles to play it.": "此畫面已關閉:在「標題」中開啟即可播放。", "The intro screen, before the first photo.": "片頭畫面,在第一張照片之前。", - "The ending screen, after the last photo.": "片尾畫面,在最後一張照片之後。" + "The ending screen, after the last photo.": "片尾畫面,在最後一張照片之後。", + "Templates": "範本", + "Built-in": "內建", + "Your templates": "你的範本", + "None yet: save the current settings as a template below.": "還沒有:在下方將目前設定儲存為範本。", + "Before the last template": "套用上一個範本之前", + "Previous settings": "之前的設定", + "New template name": "新範本名稱", + "Save as Template": "儲存為範本", + "Import…": "匯入…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "你自己的範本需要設定資料夾,而此版本沒有;內建範本可以使用。", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "按一下範本即可套用;指向範本可在所選照片上預覽。在你自己的範本上按右鍵可更新、重新命名、匯出或刪除。", + "Import Slideshow Templates": "匯入幻燈片秀範本", + "Export Slideshow Template": "匯出幻燈片秀範本", + "Slideshow Templates": "幻燈片秀範本", + "Plain Black": "純黑", + "Gallery Wall": "畫廊牆", + "Edge to Edge": "滿版", + "Cinema Wide": "寬銀幕", + "Shooting Data": "拍攝資料", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "每張照片盡可能大地顯示在黑色背景上,帶短暫交叉淡化。上面不繪製任何內容。", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "淺灰色牆面、白色卡紙和柔和陰影;照片下方顯示說明和星級。", + "Every photo cropped to fill the whole screen, with slow crossfades.": "每張照片裁切以填滿整個螢幕,帶緩慢交叉淡化。", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 畫面,上下黑邊,標題在角落,經黑色淡入淡出,並有結尾卡。", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每張照片下方顯示曝光、ISO 和焦距,再下面是相機和鏡頭。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65057d7e8..fec622092 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -154,6 +154,10 @@ pub struct Services { pub pick_curve_preset_files: Option<PickFiles>, /// Save dialog for an exported `.lccurve` file. pub save_curve_preset_file: Option<SaveFile>, + /// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…). + pub pick_slideshow_templates: Option<PickFiles>, + /// Save dialog for an exported slideshow template (`.json`). + pub save_slideshow_template: Option<SaveFile>, pub write: Option<WriteFn>, /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). pub write_shared: Option<SharedWrite>, diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 363c53651..f205da3d7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -29,6 +29,7 @@ pub mod second; pub mod settings; pub mod slide_layout; pub mod slide_overlays; +pub mod slide_templates; pub mod slide_titles; pub mod slideshow; pub mod strip; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ce87d94cb..3f59df629 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -654,6 +654,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| { ui.set_width(SLIDESHOW_COLUMN_W); egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| { + super::slide_templates::section(app, ui, t); + ui.add_space(6.0); playback_section(app, ui, t); ui.add_space(6.0); super::slide_layout::sections(app, ui, t); diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs index 4a5bde3fa..62f7626c3 100644 --- a/crates/ui-egui/src/panels/slide_layout.rs +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -10,6 +10,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use lightcraft_engine::slide::{Aspect, SlideSettings}; +use lightcraft_engine::slide_template::SlideLook; use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; use serde_json::{Value, json}; @@ -164,7 +165,11 @@ fn screen_aspect(ctx: &egui::Context) -> f64 { /// The small composed slide of the active photo, with the margin guides when they are on. pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, "Preview"); - let s = app.session.slide_layout.clone(); + // the template the pointer is over in the Template Browser, else the settings + let template = super::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); + let look = template.as_ref().map_or_else(|| SlideLook::of(&app.session), |t| t.look.clone()); + let s = look.layout.clone(); + let overlays = look.overlays.clone(); let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32; let h = (PREVIEW_W / ratio).clamp(80.0, 320.0); let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover()); @@ -172,8 +177,15 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let p = ui.painter_at(rect.expand(1.0)); p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); super::slide_titles::preview_choice(ui); + if let Some(tpl) = &template { + let name = if tpl.builtin { crate::i18n::tr(&tpl.name).to_string() } else { tpl.name.clone() }; + ui.label(egui::RichText::new(crate::i18n::tr_format!("Template preview: {name}", name = name)).size(11.0).color(t.accent)); + if !tpl.description.is_empty() { + hint(ui, t, &tpl.description); + } + } if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { - title_preview(app, ui, t, rect, title); + title_preview(app, ui, t, rect, title, &look); return; } let Some(id) = app.session.active() else { @@ -184,15 +196,15 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let ppp = ui.ctx().pixels_per_point(); let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); // the overlays and what they show of the photo (its rating, its metadata) change it too - let shown = app.session.catalog.photo(id).filter(|_| app.session.slide_overlays.any()).map_or(0, |p| { + let shown = app.session.catalog.photo(id).filter(|_| overlays.any()).map_or(0, |p| { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); p.rating.hash(&mut h); serde_json::to_string(&p.meta).unwrap_or_default().hash(&mut h); h.finish() }); - let overlays = if app.session.slide_overlays.any() { app.session.slide_overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 }; - let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlays.rotate_left(29); + let overlay_key = if overlays.any() { overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 }; + let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlay_key.rotate_left(29); let now = ui.input(|i| i.time); let mem = egui::Id::new("settingsSlideshowPreviewKey"); let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); @@ -205,7 +217,7 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if current.is_none() || still >= DEBOUNCE { let side = super::detail::texture_side(ui.ctx()); let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side); - if let Some(job) = app.session.slide_job_with(&s, id, pw, ph, Some(edge), false) { + if let Some(job) = app.session.slide_job_look(&s, &overlays, id, pw, ph, Some(edge), false) { app.renderer.request(Slot::SlidePreview, job, 90); } } else { @@ -238,10 +250,10 @@ pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { /// The preview showing title screen `title` (composed off the UI thread like the photo's slide, /// once its settings have been still for [`DEBOUNCE`] seconds). -fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: Rect, title: lightcraft_engine::slideshow::Title) { +fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: Rect, title: lightcraft_engine::slideshow::Title, look: &SlideLook) { let ppp = ui.ctx().pixels_per_point(); let (pw, ph) = (((rect.width() * ppp).round() as usize).max(16), ((rect.height() * ppp).round() as usize).max(16)); - let compose = app.session.title_compose(title, pw, ph); + let compose = std::sync::Arc::new(lightcraft_engine::slide_title::TitleCompose::new(&look.titles, &look.overlays, title, pw, ph)); let key = compose.key(); let now = ui.input(|i| i.time); let mem = egui::Id::new("settingsSlideshowTitlePreviewKey"); @@ -264,12 +276,12 @@ fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: R p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { - let c = app.session.slide_titles.screen(title).color; + let c = look.titles.screen(title).color; p.rect_filled(rect, 0.0, Color32::from_rgb(c[0], c[1], c[2])); p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); } } - let hint_text = match (title, app.session.slide_titles.screen(title).enabled) { + let hint_text = match (title, look.titles.screen(title).enabled) { (_, false) => "This screen is off: turn it on under Titles to play it.", (lightcraft_engine::slideshow::Title::Intro, true) => "The intro screen, before the first photo.", (lightcraft_engine::slideshow::Title::Ending, true) => "The ending screen, after the last photo.", diff --git a/crates/ui-egui/src/panels/slide_templates.rs b/crates/ui-egui/src/panels/slide_templates.rs new file mode 100644 index 000000000..591e3c82a --- /dev/null +++ b/crates/ui-egui/src/panels/slide_templates.rs @@ -0,0 +1,274 @@ +//! Settings ▸ Slideshow ▸ Templates: the Template Browser (Lightroom Classic's, at the top of the +//! Slideshow tab until the Slideshow module view exists). Built-in templates (our own designs) and +//! the user's (files in `<settings>/slideshow-templates`), and "Previous settings" once one has been +//! applied (library preferences have no undo). +//! +//! Clicking a template applies it (`slideshow.templates.apply`); hovering one shows it on the +//! selected photo in the live preview beside it ([`hovered`], read by +//! [`super::slide_layout::preview`]) without changing anything. Right-click a user template to +//! update it with the current settings, rename, export or delete it; a built-in to export it. +//! Every action goes through a `slideshow.templates.*` command. + +use egui::{RichText, Sense, Stroke, vec2}; +use lightcraft_engine::slide_template::{PREVIOUS, SlideLook, Template}; +use serde_json::json; + +use super::settings::{heading, hint}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +/// The user templates list is at most this tall (it scrolls past it). +const USER_LIST_H: f32 = 180.0; + +fn hovered_key() -> egui::Id { + egui::Id::new("settingsSlideshowTemplateHovered") +} + +/// The template the pointer is over in the browser (its id), which the preview shows. +pub fn hovered(ctx: &egui::Context) -> Option<String> { + ctx.data(|d| d.get_temp::<Option<String>>(hovered_key())).flatten() +} + +fn set_hovered(ctx: &egui::Context, id: Option<String>) { + ctx.data_mut(|d| d.insert_temp(hovered_key(), id)); +} + +/// What a row is doing: being renamed (with the text so far), or waiting for a delete to be confirmed. +#[derive(Clone, Debug, PartialEq)] +enum Pending { + Rename(String, String), + Delete(String), +} + +fn pending_key() -> egui::Id { + egui::Id::new("settingsSlideshowTemplatePending") +} + +fn pending(ctx: &egui::Context) -> Option<Pending> { + ctx.data(|d| d.get_temp::<Option<Pending>>(pending_key())).flatten() +} + +fn set_pending(ctx: &egui::Context, p: Option<Pending>) { + ctx.data_mut(|d| d.insert_temp(pending_key(), p)); +} + +/// The name shown for a template: a built-in's in the interface language. +fn shown_name(t: &Template) -> String { + if t.builtin || t.id == PREVIOUS { crate::i18n::tr(&t.name).to_string() } else { t.name.clone() } +} + +/// Is `look` what the settings are now (prepare previews aside: templates don't set it)? +fn is_current(look: &SlideLook, now: &SlideLook) -> bool { + look.layout == now.layout && look.overlays == now.overlays && look.titles == now.titles && { + let mut p = look.playback.clone(); + p.prepare_previews = now.playback.prepare_previews; + p == now.playback + } +} + +/// Run a template command; tell the user how it went. +fn act(app: &mut LightcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value, done: impl FnOnce(&serde_json::Value) -> String) { + match app.run(cmd, p) { + Ok(r) => { + let mut msg = done(&r); + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(|w| w.as_str()).collect()).unwrap_or_default(); + if !warnings.is_empty() { + msg = format!("{msg} · {}", warnings.join(" · ")); + } + if !msg.is_empty() { + app.toast(ctx, msg); + } + } + Err(e) => app.toast(ctx, e), + } +} + +fn apply(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, name: &str) { + let name = name.to_string(); + act(app, ctx, "slideshow.templates.apply", json!({"id": id}), |_| crate::i18n::tr_format!("Applied {name}", name = name)); +} + +/// One template row (`template:{id}`): click applies, hover previews, right-click for its menu. +fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Template, current: bool, hovered: &mut Option<String>) { + let ctx = ui.ctx().clone(); + let name = shown_name(tpl); + if let Some(Pending::Rename(id, mut text)) = pending(&ctx).filter(|p| matches!(p, Pending::Rename(id, _) if *id == tpl.id)) { + let r = TextField::singleline("field:slideshowTemplateRename", &mut text) + .width(ui.available_width().clamp(80.0, 260.0)) + .select_on_focus(true) + .show(ui); + if !r.editing && r.ending.is_none() { + r.response.request_focus(); + } + if r.committed() { + set_pending(&ctx, None); + if text.trim() != tpl.name { + act(app, &ctx, "slideshow.templates.rename", json!({"id": id, "name": text}), |_| String::new()); + } + } else if r.cancelled() { + set_pending(&ctx, None); + } else { + set_pending(&ctx, Some(Pending::Rename(id, text))); + } + return; + } + if pending(&ctx) == Some(Pending::Delete(tpl.id.clone())) { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr_format!("Delete “{name}”?", name = name)).color(t.caution)); + if crate::widgets::text_button(ui, "slideshowTemplateDeleteConfirm", "Delete", false).clicked() { + set_pending(&ctx, None); + act(app, &ctx, "slideshow.templates.delete", json!({"id": tpl.id}), |_| String::new()); + } + if crate::widgets::text_button(ui, "slideshowTemplateDeleteCancel", "Cancel", false).clicked() { + set_pending(&ctx, None); + } + }); + return; + } + let w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + register(&ctx, format!("template:{}", tpl.id), rect); + if resp.hovered() || resp.context_menu_opened() { + *hovered = Some(tpl.id.clone()); + } + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + if current { + p.rect_stroke(rect.shrink(0.5), 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + let colour = if current { t.text } else { t.text_label }; + p.text(rect.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, &name, t.font(12.0), colour); + if resp.clicked() { + apply(app, &ctx, &tpl.id, &name); + } + resp.context_menu(|ui| menu(app, ui, tpl, &name)); +} + +/// A menu entry registered as `button:{id}`. +fn item(ui: &mut egui::Ui, id: &str, label: &str, enabled: bool) -> bool { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label))); + register(ui.ctx(), format!("button:{id}"), r.rect); + r.clicked() +} + +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, tpl: &Template, name: &str) { + let ctx = ui.ctx().clone(); + if item(ui, "slideshowTemplateApply", "Apply", true) { + apply(app, &ctx, &tpl.id, name); + ui.close(); + } + let user = !tpl.builtin && tpl.id != PREVIOUS; + if user { + if item(ui, "slideshowTemplateUpdate", "Update with Current Settings", true) { + let n = name.to_string(); + act(app, &ctx, "slideshow.templates.update", json!({"id": tpl.id}), |_| crate::i18n::tr_format!("Updated {name}", name = n)); + ui.close(); + } + if item(ui, "slideshowTemplateRename", "Rename…", true) { + set_pending(&ctx, Some(Pending::Rename(tpl.id.clone(), tpl.name.clone()))); + ui.close(); + } + } + if tpl.id != PREVIOUS && item(ui, "slideshowTemplateExport", "Export…", app.services.save_slideshow_template.is_some()) { + let file = format!("{}.json", lightcraft_engine::slide_template::file_stem(&tpl.name).unwrap_or_else(|| "Slideshow Template".into())); + if let Some(path) = app.services.save_slideshow_template.as_mut().and_then(|save| save(&file)) { + act(app, &ctx, "slideshow.templates.export", json!({"id": tpl.id, "path": path}), |r| { + crate::i18n::tr_format!("Exported to {path}", path = r["path"].as_str().unwrap_or_default()) + }); + } + ui.close(); + } + if user { + ui.separator(); + if item(ui, "slideshowTemplateDelete", "Delete", true) { + set_pending(&ctx, Some(Pending::Delete(tpl.id.clone()))); + ui.close(); + } + } +} + +/// A group heading inside the browser. +fn group(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.add_space(2.0); + ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); +} + +/// The Templates section. +pub fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + heading(ui, t, "Templates"); + let listing = app.session.slide_templates(); + let now = SlideLook::of(&app.session); + let mut over: Option<String> = None; + group(ui, t, "Built-in"); + for tpl in listing.templates.iter().filter(|t| t.builtin) { + template_row(app, ui, t, tpl, is_current(&tpl.look, &now), &mut over); + } + group(ui, t, "Your templates"); + let user: Vec<_> = listing.templates.iter().filter(|t| !t.builtin).cloned().collect(); + if user.is_empty() { + hint(ui, t, "None yet: save the current settings as a template below."); + } else { + egui::ScrollArea::vertical().id_salt("settingsSlideshowTemplatesUser").max_height(USER_LIST_H).auto_shrink([false, true]).show(ui, |ui| { + for tpl in &user { + template_row(app, ui, t, tpl, is_current(&tpl.look, &now), &mut over); + } + }); + } + if let Ok(prev) = app.session.slide_template(PREVIOUS) { + group(ui, t, "Before the last template"); + template_row(app, ui, t, &prev, false, &mut over); + } + set_hovered(&ctx, over); + + // save the current settings as a new template + ui.add_space(4.0); + ui.horizontal(|ui| { + let mem = egui::Id::new("settingsSlideshowTemplateNewName"); + let mut name: String = ui.data(|d| d.get_temp(mem)).unwrap_or_default(); + let r = TextField::singleline("field:slideshowTemplateName", &mut name).hint(crate::i18n::tr("New template name")).width(180.0).show(ui); + let save = crate::widgets::text_button(ui, "slideshowTemplateSave", "Save as Template", false).clicked() + || (r.ending == Some(crate::text_field::Ending::Return) && !name.trim().is_empty()); + if save { + let n = name.trim().to_string(); + match app.run("slideshow.templates.save", json!({"name": n})) { + Ok(_) => { + app.toast(&ctx, crate::i18n::tr_format!("Saved template {name}", name = n)); + name.clear(); + } + Err(e) => app.toast(&ctx, e), + } + } + ui.data_mut(|d| d.insert_temp(mem, name)); + if app.services.pick_slideshow_templates.is_some() && crate::widgets::text_button(ui, "slideshowTemplateImport", "Import…", false).clicked() + { + let paths = app.services.pick_slideshow_templates.as_mut().map(|pick| pick()).unwrap_or_default(); + for path in paths { + act(app, &ctx, "slideshow.templates.import", json!({"path": path}), |r| { + crate::i18n::tr_format!("Imported {name}", name = r["name"].as_str().unwrap_or_default()) + }); + } + } + }); + if app.session.slide_templates_dir.is_none() { + hint(ui, t, "Your own templates need a settings folder, which this build doesn't have; the built-in ones work."); + } else { + hint( + ui, + t, + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.", + ); + } + // files that couldn't be read + for p in listing.problems.iter().take(4) { + ui.label(RichText::new(crate::i18n::tr_format!("Skipped {problem}", problem = p)).size(11.0).color(t.caution)); + } + if listing.problems.len() > 4 { + let more = listing.problems.len() - 4; + ui.label(RichText::new(crate::i18n::tr_format!("…and {more} more", more = more)).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs index e11d33158..24aa0d728 100644 --- a/crates/ui-egui/src/tests_slideshow.rs +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -233,6 +233,8 @@ fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { run(&mut h, "view.slideshowOptions", json!({})); h.step(); assert!(h.app.widgets.iter().any(|(w, _)| w == "check:settings.slideshowRandom")); + // (below the Templates at the top of the tab) + reveal(&mut h, "check:settings.slideshowRandom"); let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); @@ -609,3 +611,115 @@ fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); h.settle(SETTLE); } + +/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo +/// without changing anything, a click applies, and the user's own templates are saved, updated, +/// renamed and deleted from the browser. +#[test] +fn template_browser_previews_on_hover_applies_and_manages_user_templates() { + use lightcraft_engine::slide_template::SlideLook; + let mut h = grid(); + let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + h.app.session.slide_templates_dir = Some(dir.clone()); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + let rect = |h: &Headless, w: &str| h.app.widgets.iter().find(|(id, _)| id == w).map(|(_, r)| *r).unwrap(); + for w in [ + "template:builtin:plain-black", + "template:builtin:gallery-wall", + "template:builtin:edge-to-edge", + "template:builtin:cinema-wide", + "template:builtin:shooting-data", + "field:slideshowTemplateName", + "button:slideshowTemplateSave", + ] { + assert!(has(&h, w), "{w}"); + } + // hover: the preview shows Gallery Wall (its light grey wall in the margin), nothing changes + let before = SlideLook::of(&h.app.session); + let r = h.request("ui.hoverWidget", json!({"id": "template:builtin:gallery-wall"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(crate::panels::slide_templates::hovered(&h.view.ctx).as_deref(), Some("builtin:gallery-wall")); + let grey = |h: &Headless| { + let r = rect(h, "slideshow:preview"); + let img = h.paint(); + let c = painted(&img, (r.left() + 3.0) as usize, r.center().y as usize); + c.iter().all(|v| (200..=230).contains(v)) + }; + assert!(h.step_until(SETTLE, grey), "the hovered template on the selected photo"); + assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); + assert!(h.app.session.slide_templates_previous.is_none()); + // a click applies it + let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); + assert!(has(&h, "template:previous"), "Previous settings appear"); + // save as a template of our own + let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(dir.join("Holiday _ 2026.json").exists(), "the separator never makes a folder"); + h.step(); + assert!(has(&h, "template:user:Holiday _ 2026")); + // update it from its menu with changed settings + run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); + let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + for w in ["button:slideshowTemplateUpdate", "button:slideshowTemplateRename", "button:slideshowTemplateExport", "button:slideshowTemplateDelete"] + { + assert!(has(&h, w), "{w}"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateUpdate"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); + // rename it in place + let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateRename"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(has(&h, "field:slideshowTemplateRename")); + let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.text", json!({"text": "Summer"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.key", json!({"key": "enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::<Vec<_>>()); + // delete it (after confirming) + let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateDelete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(dir.join("Summer.json").exists(), "not before it is confirmed"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateDeleteConfirm"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!dir.join("Summer.json").exists()); + // back to the settings before Gallery Wall + let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(SlideLook::of(&h.app.session).overlays, before.overlays); + h.settle(SETTLE); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index efe960c43..bee1fa1d9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -80,7 +80,11 @@ filmstrip is visible. | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | | `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` | | `engine.execute {command: "slideshow.titles"}` | `{intro?: screen, ending?: screen}`, a screen being `{enabled?, color?, content?: "none"\|"identityPlate"\|"text", text?, overrideColor?, textColor?, scale?, duration?: seconds\|null}` | The intro and ending screens, saved with the library: a background colour and, centred, the Overlays identity plate or the screen's own text, in the plate's colour or `textColor` when `overrideColor` is on, `scale` = its height as a fraction of the slide's shorter side; `duration` null = the slide duration. The intro plays once before the first photo, the ending after the last when repeat is off. Replies with the settings and `warnings` | -| `engine.execute {command: "slideshow.preview"}` | `{id?, title?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it (with `title: "intro"\|"ending"`, that title screen instead → `{title, enabled, width, height, plate}`): a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | +| `engine.execute {command: "slideshow.preview"}` | `{id?, title?, template?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it (with `template: id`, with that template's settings instead, leaving the library's alone: the Template Browser's hover preview) (with `title: "intro"\|"ending"`, that title screen instead → `{title, enabled, width, height, plate}`): a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | +| `engine.execute {command: "slideshow.templates.list"}` | `{}` | The Template Browser: `{templates: [{id, name, builtin, group, description?, path?}], previous, folder, problems}` — built-ins (`builtin:…`, our own designs) then user templates (`user:<file>`, files in `<settings>/slideshow-templates`, at most 1000); `problems` lists files skipped and why | +| `engine.execute {command: "slideshow.templates.apply"}` | `{id}` or `{name}`; `{id: "previous"}` | Replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's, saved with the library. No undo: the replaced settings become `previous` for the session → `{applied, name, warnings, previous, settings}` (`warnings`: a background photo not in this library, an unreadable plate or watermark image…) | +| `engine.execute {command: "slideshow.templates.save"}` / `update` / `rename` / `delete` | `{name, replace?}` / `{id}` / `{id, name}` / `{id}` | Save the current settings as a user template (`<name>.json`; path separators and reserved characters become `_`, a taken or built-in name is refused unless `replace`), update one with the current settings, rename (its file follows), delete. Built-ins can't change | +| `engine.execute {command: "slideshow.templates.import"}` / `export` | `{path}` / `{id, path}` | Import a template `.json` (at most 256 KB; format, version and every setting checked; a taken name gets " (2)") → `{id, name, renamed, warnings}`; export any template as `.json` | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index 3cb3586a8..b3b50f808 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -205,7 +205,11 @@ overlays drawn on every slide (identity plate text or image, rating stars, text `{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. `cmd_slideshow_titles` sets the intro and ending screens (colour, the identity plate or a line of text, scale, duration), and `cmd_slideshow_preview {title: "intro"}` -renders one. +renders one. Templates bundle all four: `cmd_slideshow_templates_list` lists the built-in and user templates (and files +it skipped), `cmd_slideshow_templates_apply {id}` replaces the settings with one (the replaced ones stay available as +`{id: "previous"}` for the session), `cmd_slideshow_preview {template: id}` previews one without applying it, and +`cmd_slideshow_templates_save` / `update` / `rename` / `delete` / `import` / `export` manage the user's own (JSON files +in `<settings>/slideshow-templates`). ## Resources diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f9e4678cc..fd417806b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 19 | 29 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 53 | 67 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 20 | 28 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 54 | 66 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.0%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 49.7% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.1%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 50.0% of 163. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -727,7 +727,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-BOOK-BG | Book backgrounds | P2 | ⬜ | | | | LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | | | LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | | -| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | +| LRC-SS-TEMPLATES | Slideshow templates | P2 | 🟡 | `cmd:slideshow.templates.list`, `cmd:slideshow.templates.apply`, `cmd:slideshow.templates.save`, `cmd:slideshow.templates.update`, `cmd:slideshow.templates.rename`, `cmd:slideshow.templates.delete`, `cmd:slideshow.templates.import`, `cmd:slideshow.templates.export`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_template.rs`, `path:crates/ui-egui/src/panels/slide_templates.rs` | Template Browser at the top of Settings ▸ Slideshow: a template is the Playback (not "prepare previews"), Layout / Options / Backdrop, Overlays and Titles settings as one versioned JSON document. Five built-ins of our own (Plain Black, Gallery Wall, Edge to Edge, Cinema Wide, Shooting Data; no Lightroom names or designs, no image or font files) and the user's own, saved as files in `<settings>/slideshow-templates` (machine-wide, like Classic's): save current settings, Update with Current Settings, rename, delete (confirmed), import / export `.json`. Click applies (one step, saved with the library); pointing at one previews it on the selected photo (or the intro / ending) in the live preview without changing anything (`slideshow.preview {template}`). No undo for library preferences: the settings a template replaced stay as "Previous settings" for the session (applying it again goes forward). Every section is read through its settings' own validation (out-of-range values clamped, wrong types or unknown keys refused), files are capped (256 KB, 1000 listed), a newer format version is refused with a message, a bad file is listed as skipped, names are sanitised into file names that can't leave the folder, a missing plate / watermark image or background photo applies with a warning. Missing: the Slideshow module's left-panel browser (it lives in Settings until the module view exists), template folders (New Folder, moving templates between folders), saving a slideshow with a collection, a preview thumbnail inside the browser itself | | LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | | LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 07d178b25..2dfb22884 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,26 @@ # What's new in LightCraft +## 2026-10-11 + +### Slideshow templates +- Settings ▸ Slideshow now opens with a template browser, like Lightroom Classic's Template Browser. A template holds + everything about how a slideshow looks and plays: the playback timing and fades, the layout, options and backdrop, + the overlays and the intro and ending screens. Click one to apply it; point at one to see it on the selected photo in + the preview beside it without changing anything. +- Five built-in templates of our own: Plain Black (the photo on black), Gallery Wall (a light wall, a white mat and a + soft shadow, with the caption and rating under the photo), Edge to Edge (photos cropped to fill the screen), Cinema + Wide (16:9 with black bars, the title in the corner, fades through black and a closing card) and Shooting Data (the + exposure, ISO, focal length, camera and lens under each photo on a dark backdrop). +- Save the current settings as a template of your own; right-click it to update it with the current settings, rename, + export or delete it, and import templates others share with you. Your templates are `.json` files in the settings + folder's `slideshow-templates` folder, shared by all your libraries. +- Applying a template can't be undone with Edit ▸ Undo (slideshow settings are library preferences), so the settings it + replaced are listed as "Previous settings" until you quit: click that to go back. +- A template file that can't be used (damaged, from a newer LightCraft, too large) is listed as skipped instead of + stopping the others, and a template whose identity plate image or background photo isn't there still applies, with a + note saying what is missing. Agents use `slideshow.templates.*`, and `slideshow.preview {template}` renders a template + without applying it. + ## 2026-10-10 ### Slideshow intro and ending screens From b3913070093da4b701090303bd671d87ae9e69d5 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:35:09 +0200 Subject: [PATCH 007/315] LRC-SS-EXPORT: slideshow export to PDF, JPEG sequence and AV1 video View > Export Slideshow... (and Export PDF / JPEG / Video in Settings > Slideshow) writes the photos in view as their composed slides, between the intro and ending screens that are on: - PDF: a page per slide (1024/1920/3840 px long side, JPEG quality), /Dur and /Trans fades per page, optional /PageMode /FullScreen. The contact sheet's writer moves to engine/src/pdf.rs and streams to disk. - JPEG: <name>-001.jpg... in a folder. - Video: AV1 (rav1e, speed 10, low latency) in MP4 or WebM written by our own muxers (codecs/src/video.rs); fades rendered as blended frames, held slides one key-frame sample with a long duration; 720p/1080p, 24/30 fps; no audio; capped at 2 hours. slideshow.export runs in the background with a row in the activity stack (activity.cancel stops it); outputs are written as .part and renamed when complete, so cancel or failure leaves nothing behind. A photo that can't be rendered is skipped with a warning. slideshow.exportStatus reports the result. --- crates/codecs/src/lib.rs | 2 + crates/codecs/src/video.rs | 1158 +++++++++++++++++++ crates/engine/src/cmd/slideshow.rs | 85 +- crates/engine/src/contact_sheet.rs | 72 +- crates/engine/src/lib.rs | 7 + crates/engine/src/pdf.rs | 110 ++ crates/engine/src/slideshow_export.rs | 760 ++++++++++++ crates/engine/src/tests_slideshow_export.rs | 334 ++++++ crates/ui-egui/locales/de-formats.json | 8 +- crates/ui-egui/locales/de.json | 17 +- crates/ui-egui/locales/es-formats.json | 8 +- crates/ui-egui/locales/es.json | 17 +- crates/ui-egui/locales/fr-formats.json | 8 +- crates/ui-egui/locales/fr.json | 17 +- crates/ui-egui/locales/ja-formats.json | 8 +- crates/ui-egui/locales/ja.json | 17 +- crates/ui-egui/locales/pt-br-formats.json | 8 +- crates/ui-egui/locales/pt-br.json | 17 +- crates/ui-egui/locales/ru-formats.json | 8 +- crates/ui-egui/locales/ru.json | 17 +- crates/ui-egui/locales/uk-formats.json | 8 +- crates/ui-egui/locales/uk.json | 17 +- crates/ui-egui/locales/zh-hans-formats.json | 8 +- crates/ui-egui/locales/zh-hans.json | 17 +- crates/ui-egui/locales/zh-hant-formats.json | 8 +- crates/ui-egui/locales/zh-hant.json | 17 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 15 + crates/ui-egui/src/panels/dialogs.rs | 59 + crates/ui-egui/src/panels/settings.rs | 22 + crates/ui-egui/src/slideshow_export.rs | 166 +++ crates/ui-egui/src/state.rs | 4 + docs/background-tasks.md | 1 + docs/control-protocol.md | 3 + docs/mcp.md | 6 + docs/parity-checklist.md | 10 +- docs/slideshow.md | 64 + docs/whats-new.md | 16 + 38 files changed, 3034 insertions(+), 90 deletions(-) create mode 100644 crates/codecs/src/video.rs create mode 100644 crates/engine/src/pdf.rs create mode 100644 crates/engine/src/slideshow_export.rs create mode 100644 crates/engine/src/tests_slideshow_export.rs create mode 100644 crates/ui-egui/src/slideshow_export.rs create mode 100644 docs/slideshow.md diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 657987189..8076e0c6c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -11,6 +11,7 @@ //! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode). //! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding //! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. +//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM. //! //! No decoder panics on malformed input (property-tested). #![forbid(unsafe_code)] @@ -33,6 +34,7 @@ mod psd; mod sniff; pub mod space; mod tiff_codec; +pub mod video; mod webp; pub use encode::{ diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs new file mode 100644 index 000000000..3991269d8 --- /dev/null +++ b/crates/codecs/src/video.rs @@ -0,0 +1,1158 @@ +//! Video: AV1 encoding of RGBA frames (`rav1e`, native targets with the `avif` feature) and two +//! small muxers of our own, written from the public specifications only: +//! +//! - **MP4** (ISO/IEC 14496-12 ISO base media file format + the AOM "AV1 Codec ISO Media File +//! Format Binding"): `ftyp`, one `mdat` (64-bit size, patched at the end) holding the samples, +//! then `moov` with one video track whose sample entry is `av01` + `av1C` + `colr` (`nclx`). +//! - **WebM** (Matroska / EBML as restricted by WebM, codec mapping `V_AV1`): EBML header, +//! Segment (size patched at the end) with SeekHead, Info, Tracks, Clusters of SimpleBlocks (the +//! last frame a BlockGroup with its duration) and Cues. +//! +//! Frames may last several frame periods: a picture held on screen is one sample with a long +//! duration (variable frame rate), so a still slide costs one encoded frame however long it +//! stays up. Durations are counted in frame periods of the nominal rate. +//! +//! Samples are AV1 temporal units without temporal delimiter OBUs (both bindings ask for that); +//! the configuration record carries the sequence header OBU. No audio track. + +use std::io::{Seek, SeekFrom, Write}; + +use crate::{Error, Result}; + +/// The container a video is written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Container { + Mp4, + WebM, +} + +impl Container { + pub fn id(self) -> &'static str { + match self { + Container::Mp4 => "mp4", + Container::WebM => "webm", + } + } + + pub fn parse(s: &str) -> Option<Container> { + match s.trim().to_ascii_lowercase().as_str() { + "mp4" => Some(Container::Mp4), + "webm" => Some(Container::WebM), + _ => None, + } + } +} + +/// One encoded picture: an AV1 temporal unit (no temporal delimiter), whether it is a key frame +/// (a sync sample), and how many frame periods it stays on screen (≥ 1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sample { + pub data: Vec<u8>, + pub key: bool, + pub duration: u64, +} + +/// The longest video the muxers write, in frame periods (keeps every timestamp in range). +pub const MAX_PERIODS: u64 = 60 * 60 * 24 * 120; + +fn io(e: std::io::Error) -> Error { + Error::Encode(format!("video: {e}")) +} + +fn bad(msg: impl Into<String>) -> Error { + Error::Encode(format!("video: {}", msg.into())) +} + +// ---------------------------------------------------------------- AV1 OBUs + +/// OBU types (AV1 specification §6.2.2). +pub const OBU_SEQUENCE_HEADER: u8 = 1; +pub const OBU_TEMPORAL_DELIMITER: u8 = 2; +pub const OBU_PADDING: u8 = 15; + +/// Read a LEB128 value at `pos` (at most 8 bytes); returns (value, bytes read). +fn leb128(data: &[u8], pos: usize) -> Result<(u64, usize)> { + let mut v = 0u64; + for i in 0..8 { + let b = *data.get(pos + i).ok_or_else(|| bad("truncated OBU size"))?; + v |= u64::from(b & 0x7f) << (7 * i); + if b & 0x80 == 0 { + return Ok((v, i + 1)); + } + } + Err(bad("OBU size longer than 8 bytes")) +} + +/// The OBUs of a temporal unit: (type, the whole OBU's byte range). Every OBU but the last must +/// carry its size (as the low-overhead bitstream format requires). +pub fn obus(data: &[u8]) -> Result<Vec<(u8, std::ops::Range<usize>)>> { + let mut out = Vec::new(); + let mut pos = 0usize; + while let Some(&header) = data.get(pos) { + if header & 0x80 != 0 { + return Err(bad("OBU forbidden bit set")); + } + let kind = (header >> 3) & 0x0f; + let ext = header & 0x04 != 0; + let has_size = header & 0x02 != 0; + let mut p = pos + 1 + usize::from(ext); + let end = if has_size { + let (size, n) = leb128(data, p)?; + p += n; + let size = usize::try_from(size).map_err(|_| bad("OBU too large"))?; + p.checked_add(size).filter(|e| *e <= data.len()).ok_or_else(|| bad("OBU runs past the end of the packet"))? + } else { + data.len() + }; + if p > end { + return Err(bad("truncated OBU header")); + } + out.push((kind, pos..end)); + pos = end; + } + Ok(out) +} + +/// A temporal unit as a sample: temporal delimiters and padding dropped. Also returns its +/// sequence header OBU, if it has one. +pub fn to_sample_data(tu: &[u8]) -> Result<(Vec<u8>, Option<Vec<u8>>)> { + let mut data = Vec::with_capacity(tu.len()); + let mut seq = None; + for (kind, r) in obus(tu)? { + let Some(bytes) = tu.get(r) else { continue }; + match kind { + OBU_TEMPORAL_DELIMITER | OBU_PADDING => {} + _ => { + if kind == OBU_SEQUENCE_HEADER && seq.is_none() { + seq = Some(bytes.to_vec()); + } + data.extend_from_slice(bytes); + } + } + } + Ok((data, seq)) +} + +/// The `av1C` configuration record (AV1-ISOBMFF §2.3) for 8-bit 4:2:0 main profile, followed by +/// `seq_header` (the sequence header OBU, may be empty). Matroska's `CodecPrivate` is the same. +pub fn av1_config(seq_header: &[u8]) -> Vec<u8> { + // marker 1, version 1 | profile 0, level 31 (unconstrained) | tier 0, 8-bit, not mono, + // subsampling x and y, chroma position 0 | no initial presentation delay + let mut v = vec![0x81, 31, 0b0000_1100, 0]; + v.extend_from_slice(seq_header); + v +} + +// ---------------------------------------------------------------- MP4 + +fn mp4_box(kind: &[u8; 4], body: &[u8]) -> Result<Vec<u8>> { + let size = u32::try_from(body.len().checked_add(8).ok_or_else(|| bad("box too large"))?).map_err(|_| bad("box too large"))?; + let mut v = Vec::with_capacity(body.len() + 8); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(kind); + v.extend_from_slice(body); + Ok(v) +} + +fn full_box(kind: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Result<Vec<u8>> { + let mut b = Vec::with_capacity(body.len() + 4); + b.push(version); + b.extend_from_slice(&flags.to_be_bytes()[1..]); + b.extend_from_slice(body); + mp4_box(kind, &b) +} + +/// The unity transformation matrix of `mvhd` / `tkhd`. +fn matrix() -> Vec<u8> { + let mut m = Vec::with_capacity(36); + for v in [0x0001_0000u32, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000] { + m.extend_from_slice(&v.to_be_bytes()); + } + m +} + +/// An MP4 writer: [`Mp4Writer::add`] streams each sample into `mdat`; [`Mp4Writer::finish`] +/// patches its size and appends `moov`. +pub struct Mp4Writer<W: Write + Seek> { + w: W, + width: u16, + height: u16, + fps: u32, + /// Where `mdat`'s 64-bit size is, and where its data starts. + mdat_size_at: u64, + data_start: u64, + written: u64, + sizes: Vec<u32>, + durations: Vec<u64>, + keys: Vec<u32>, + config: Option<Vec<u8>>, +} + +/// Track ticks per frame period (ffmpeg's habit: 512 per frame). +const TICKS: u64 = 512; + +impl<W: Write + Seek> Mp4Writer<W> { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result<Self> { + let (width, height) = (u16::try_from(width).map_err(|_| bad("too wide"))?, u16::try_from(height).map_err(|_| bad("too tall"))?); + if width == 0 || height == 0 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut ftyp = Vec::new(); + ftyp.extend_from_slice(b"isom"); + ftyp.extend_from_slice(&0x200u32.to_be_bytes()); + for brand in [b"isom", b"iso6", b"av01", b"mp41"] { + ftyp.extend_from_slice(brand); + } + w.write_all(&mp4_box(b"ftyp", &ftyp)?).map_err(io)?; + let start = w.stream_position().map_err(io)?; + // size 1 = a 64-bit size follows (patched in finish) + w.write_all(&1u32.to_be_bytes()).map_err(io)?; + w.write_all(b"mdat").map_err(io)?; + w.write_all(&0u64.to_be_bytes()).map_err(io)?; + Ok(Mp4Writer { + w, + width, + height, + fps, + mdat_size_at: start + 8, + data_start: start + 16, + written: 0, + sizes: Vec::new(), + durations: Vec::new(), + keys: Vec::new(), + config: None, + }) + } + + /// The `av1C` record ([`av1_config`]); needed before [`Self::finish`]. + pub fn set_config(&mut self, config: Vec<u8>) { + self.config = Some(config); + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + let size = u32::try_from(s.data.len()).map_err(|_| bad("sample too large"))?; + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.sizes.is_empty() && !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.w.write_all(&s.data).map_err(io)?; + self.written = self.written.saturating_add(u64::from(size)); + self.sizes.push(size); + self.durations.push(s.duration); + if s.key { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + self.keys.push(n); + } + Ok(()) + } + + /// Frame periods written so far. + pub fn periods(&self) -> u64 { + self.durations.iter().sum() + } + + pub fn finish(mut self) -> Result<W> { + if self.sizes.is_empty() { + return Err(bad("no frames")); + } + let config = self.config.take().ok_or_else(|| bad("no AV1 configuration"))?; + let periods = self.periods(); + if periods > MAX_PERIODS { + return Err(bad("video too long")); + } + let end = self.w.stream_position().map_err(io)?; + self.w.seek(SeekFrom::Start(self.mdat_size_at)).map_err(io)?; + self.w.write_all(&(end - (self.mdat_size_at - 8)).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + let moov = self.moov(&config, periods)?; + self.w.write_all(&moov).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } + + fn moov(&self, config: &[u8], periods: u64) -> Result<Vec<u8>> { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + let timescale = u32::try_from(u64::from(self.fps) * TICKS).map_err(|_| bad("bad frame rate"))?; + let ticks = periods.checked_mul(TICKS).ok_or_else(|| bad("video too long"))?; + // the movie's own timescale: milliseconds + let movie_ms = periods.saturating_mul(1000) / u64::from(self.fps); + let u32_of = |v: u64| u32::try_from(v).map_err(|_| bad("video too long")); + let (track_ticks, movie_ms) = (u32_of(ticks)?, u32_of(movie_ms)?); + + let mut mvhd = Vec::new(); + mvhd.extend_from_slice(&[0; 8]); // creation, modification + mvhd.extend_from_slice(&1000u32.to_be_bytes()); + mvhd.extend_from_slice(&movie_ms.to_be_bytes()); + mvhd.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0 + mvhd.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0 + mvhd.extend_from_slice(&[0; 10]); + mvhd.extend_from_slice(&matrix()); + mvhd.extend_from_slice(&[0; 24]); // pre_defined + mvhd.extend_from_slice(&2u32.to_be_bytes()); // next track id + + let mut tkhd = Vec::new(); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&1u32.to_be_bytes()); // track id + tkhd.extend_from_slice(&[0; 4]); + tkhd.extend_from_slice(&movie_ms.to_be_bytes()); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&[0; 2]); // layer + tkhd.extend_from_slice(&[0; 2]); // alternate group + tkhd.extend_from_slice(&[0; 2]); // volume (video: 0) + tkhd.extend_from_slice(&[0; 2]); + tkhd.extend_from_slice(&matrix()); + tkhd.extend_from_slice(&(u32::from(self.width) << 16).to_be_bytes()); + tkhd.extend_from_slice(&(u32::from(self.height) << 16).to_be_bytes()); + + let mut mdhd = Vec::new(); + mdhd.extend_from_slice(&[0; 8]); + mdhd.extend_from_slice(×cale.to_be_bytes()); + mdhd.extend_from_slice(&track_ticks.to_be_bytes()); + mdhd.extend_from_slice(&0x55c4u16.to_be_bytes()); // language "und" + mdhd.extend_from_slice(&[0; 2]); + + let mut hdlr = Vec::new(); + hdlr.extend_from_slice(&[0; 4]); + hdlr.extend_from_slice(b"vide"); + hdlr.extend_from_slice(&[0; 12]); + hdlr.extend_from_slice(b"LightCraft video\0"); + + let mut vmhd = Vec::new(); + vmhd.extend_from_slice(&[0; 8]); // graphics mode, opcolor + let url = full_box(b"url ", 0, 1, &[])?; + let mut dref = 1u32.to_be_bytes().to_vec(); + dref.extend_from_slice(&url); + let dinf = mp4_box(b"dinf", &full_box(b"dref", 0, 0, &dref)?)?; + + // av01 visual sample entry + let mut av01 = Vec::new(); + av01.extend_from_slice(&[0; 6]); + av01.extend_from_slice(&1u16.to_be_bytes()); // data reference index + av01.extend_from_slice(&[0; 16]); // pre_defined, reserved + av01.extend_from_slice(&self.width.to_be_bytes()); + av01.extend_from_slice(&self.height.to_be_bytes()); + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // 72 dpi + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); + av01.extend_from_slice(&[0; 4]); + av01.extend_from_slice(&1u16.to_be_bytes()); // frame count + let mut name = [0u8; 32]; + let label = b"AOM Coding"; + name[0] = label.len() as u8; + name[1..=label.len()].copy_from_slice(label); + av01.extend_from_slice(&name); + av01.extend_from_slice(&0x0018u16.to_be_bytes()); // depth + av01.extend_from_slice(&(-1i16).to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"av1C", config)?); + // BT.709 primaries, transfer and matrix, limited range + let mut colr = b"nclx".to_vec(); + colr.extend_from_slice(&[0, 1, 0, 1, 0, 1, 0]); + av01.extend_from_slice(&mp4_box(b"colr", &colr)?); + let mut pasp = 1u32.to_be_bytes().to_vec(); + pasp.extend_from_slice(&1u32.to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"pasp", &pasp)?); + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend_from_slice(&mp4_box(b"av01", &av01)?); + + // decoding times: runs of equal durations + let mut runs: Vec<(u32, u64)> = Vec::new(); + for d in &self.durations { + match runs.last_mut() { + Some((count, delta)) if *delta == *d => *count += 1, + _ => runs.push((1, *d)), + } + } + let mut stts = u32::try_from(runs.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for (count, delta) in runs { + stts.extend_from_slice(&count.to_be_bytes()); + stts.extend_from_slice(&u32_of(delta.saturating_mul(TICKS))?.to_be_bytes()); + } + let mut stsc = 1u32.to_be_bytes().to_vec(); + for v in [1u32, n, 1] { + stsc.extend_from_slice(&v.to_be_bytes()); + } + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend_from_slice(&n.to_be_bytes()); + for s in &self.sizes { + stsz.extend_from_slice(&s.to_be_bytes()); + } + // one chunk holding every sample, at the start of mdat's data + let chunk_offset = match u32::try_from(self.data_start) { + Ok(o) => full_box(b"stco", 0, 0, &[1u32.to_be_bytes(), o.to_be_bytes()].concat())?, + Err(_) => { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&self.data_start.to_be_bytes()); + full_box(b"co64", 0, 0, &b)? + } + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&full_box(b"stsd", 0, 0, &stsd)?); + stbl.extend_from_slice(&full_box(b"stts", 0, 0, &stts)?); + if self.keys.len() < self.sizes.len() { + let mut stss = u32::try_from(self.keys.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for k in &self.keys { + stss.extend_from_slice(&k.to_be_bytes()); + } + stbl.extend_from_slice(&full_box(b"stss", 0, 0, &stss)?); + } + stbl.extend_from_slice(&full_box(b"stsc", 0, 0, &stsc)?); + stbl.extend_from_slice(&full_box(b"stsz", 0, 0, &stsz)?); + stbl.extend_from_slice(&chunk_offset); + + let mut minf = full_box(b"vmhd", 0, 1, &vmhd)?; + minf.extend_from_slice(&dinf); + minf.extend_from_slice(&mp4_box(b"stbl", &stbl)?); + let mut mdia = full_box(b"mdhd", 0, 0, &mdhd)?; + mdia.extend_from_slice(&full_box(b"hdlr", 0, 0, &hdlr)?); + mdia.extend_from_slice(&mp4_box(b"minf", &minf)?); + let mut trak = full_box(b"tkhd", 0, 3, &tkhd)?; + trak.extend_from_slice(&mp4_box(b"mdia", &mdia)?); + let mut moov = full_box(b"mvhd", 0, 0, &mvhd)?; + moov.extend_from_slice(&mp4_box(b"trak", &trak)?); + mp4_box(b"moov", &moov) + } +} + +// ---------------------------------------------------------------- WebM + +/// An EBML element id with its length marker, as written (e.g. `0x1A45DFA3`). +fn ebml_id(id: u32) -> Vec<u8> { + let b = id.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(3); + b[skip..].to_vec() +} + +/// An EBML size, in the fewest bytes (all-ones values are reserved for "unknown"). +fn ebml_size(n: u64) -> Vec<u8> { + for len in 1..=8u32 { + if n < (1u64 << (7 * len)) - 1 { + let mut v = Vec::with_capacity(len as usize); + let marked = n | (1u64 << (7 * len)); + let bytes = marked.to_be_bytes(); + v.extend_from_slice(&bytes[(8 - len as usize)..]); + return v; + } + } + vec![0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe] +} + +/// An 8-byte EBML size (patchable in place). +fn ebml_size8(n: u64) -> [u8; 8] { + let mut b = (n & 0x00ff_ffff_ffff_ffff).to_be_bytes(); + b[0] = 0x01; + b +} + +fn el(id: u32, body: &[u8]) -> Vec<u8> { + let mut v = ebml_id(id); + v.extend_from_slice(&ebml_size(body.len() as u64)); + v.extend_from_slice(body); + v +} + +fn el_uint(id: u32, n: u64) -> Vec<u8> { + let b = n.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(7); + el(id, &b[skip..]) +} + +fn el_float(id: u32, f: f64) -> Vec<u8> { + el(id, &f.to_be_bytes()) +} + +const EBML: u32 = 0x1A45_DFA3; +const SEGMENT: u32 = 0x1853_8067; +const SEEK_HEAD: u32 = 0x114D_9B74; +const SEEK: u32 = 0x4DBB; +const SEEK_ID: u32 = 0x53AB; +const SEEK_POSITION: u32 = 0x53AC; +const VOID: u32 = 0xEC; +const INFO: u32 = 0x1549_A966; +const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; +const DURATION: u32 = 0x4489; +const TRACKS: u32 = 0x1654_AE6B; +const TRACK_ENTRY: u32 = 0xAE; +const CLUSTER: u32 = 0x1F43_B675; +const TIMESTAMP: u32 = 0xE7; +const SIMPLE_BLOCK: u32 = 0xA3; +const BLOCK_GROUP: u32 = 0xA0; +const BLOCK: u32 = 0xA1; +const BLOCK_DURATION: u32 = 0x9B; +const REFERENCE_BLOCK: u32 = 0xFB; +const CUES: u32 = 0x1C53_BB6B; + +/// Room kept after the Segment header for the SeekHead written at the end. +const SEEK_ROOM: usize = 96; + +/// A WebM writer (see the module docs). Timestamps are in milliseconds. +pub struct WebmWriter<W: Write + Seek> { + w: W, + width: u32, + height: u32, + fps: u32, + segment_size_at: u64, + segment_start: u64, + duration_at: u64, + info_at: u64, + tracks_at: Option<u64>, + config: Option<Vec<u8>>, + /// Frame periods before the pending sample. + periods: u64, + /// The sample not written yet (the last one becomes a BlockGroup with its duration). + pending: Option<(Sample, u64)>, + cluster: Vec<u8>, + cluster_ts: u64, + last_block_ts: u64, + cues: Vec<(u64, u64)>, +} + +impl<W: Write + Seek> WebmWriter<W> { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result<Self> { + if width == 0 || height == 0 || width > 65_535 || height > 65_535 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut header = Vec::new(); + header.extend_from_slice(&el_uint(0x4286, 1)); // EBMLVersion + header.extend_from_slice(&el_uint(0x42F7, 1)); // EBMLReadVersion + header.extend_from_slice(&el_uint(0x42F2, 4)); // EBMLMaxIDLength + header.extend_from_slice(&el_uint(0x42F3, 8)); // EBMLMaxSizeLength + header.extend_from_slice(&el(0x4282, b"webm")); // DocType + header.extend_from_slice(&el_uint(0x4287, 4)); // DocTypeVersion + header.extend_from_slice(&el_uint(0x4285, 2)); // DocTypeReadVersion + w.write_all(&el(EBML, &header)).map_err(io)?; + w.write_all(&ebml_id(SEGMENT)).map_err(io)?; + let segment_size_at = w.stream_position().map_err(io)?; + w.write_all(&ebml_size8(0)).map_err(io)?; + let segment_start = w.stream_position().map_err(io)?; + // a Void the SeekHead replaces at the end + w.write_all(&el(VOID, &[0; SEEK_ROOM - 2])).map_err(io)?; + let info_at = w.stream_position().map_err(io)? - segment_start; + let mut info = el_uint(TIMESTAMP_SCALE, 1_000_000); + let duration_offset = info.len() + 3; // after Duration's id (2 bytes) and size (1 byte) + info.extend_from_slice(&el_float(DURATION, 0.0)); + info.extend_from_slice(&el(0x4D80, b"LightCraft")); // MuxingApp + info.extend_from_slice(&el(0x5741, b"LightCraft")); // WritingApp + let info_el = el(INFO, &info); + let duration_at = segment_start + info_at + (info_el.len() - info.len()) as u64 + duration_offset as u64; + w.write_all(&info_el).map_err(io)?; + Ok(WebmWriter { + w, + width, + height, + fps, + segment_size_at, + segment_start, + duration_at, + info_at, + tracks_at: None, + config: None, + periods: 0, + pending: None, + cluster: Vec::new(), + cluster_ts: 0, + last_block_ts: 0, + cues: Vec::new(), + }) + } + + /// The `CodecPrivate` (the `av1C` record, [`av1_config`]); needed before the first sample. + pub fn set_config(&mut self, config: Vec<u8>) { + self.config = Some(config); + } + + fn ms(&self, periods: u64) -> u64 { + periods.saturating_mul(1000) / u64::from(self.fps) + } + + fn write_tracks(&mut self) -> Result<()> { + let config = self.config.clone().ok_or_else(|| bad("no AV1 configuration"))?; + let mut video = el_uint(0xB0, u64::from(self.width)); // PixelWidth + video.extend_from_slice(&el_uint(0xBA, u64::from(self.height))); // PixelHeight + let mut entry = el_uint(0xD7, 1); // TrackNumber + entry.extend_from_slice(&el_uint(0x73C5, 1)); // TrackUID + entry.extend_from_slice(&el_uint(0x83, 1)); // TrackType video + entry.extend_from_slice(&el_uint(0x9C, 0)); // FlagLacing + entry.extend_from_slice(&el(0x86, b"V_AV1")); // CodecID + entry.extend_from_slice(&el(0x63A2, &config)); // CodecPrivate + entry.extend_from_slice(&el_uint(0x23E383, 1_000_000_000 / u64::from(self.fps))); // DefaultDuration (ns) + entry.extend_from_slice(&el(0xE0, &video)); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(TRACKS, &el(TRACK_ENTRY, &entry))).map_err(io)?; + self.tracks_at = Some(at); + Ok(()) + } + + fn flush_cluster(&mut self) -> Result<()> { + if self.cluster.is_empty() { + return Ok(()); + } + let mut body = el_uint(TIMESTAMP, self.cluster_ts); + body.extend_from_slice(&self.cluster); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(CLUSTER, &body)).map_err(io)?; + self.cues.push((self.cluster_ts, at)); + self.cluster.clear(); + Ok(()) + } + + /// The block header: track 1, timestamp relative to the cluster, flags. + fn block(&self, data: &[u8], ts: u64, flags: u8) -> Result<Vec<u8>> { + let rel = i16::try_from(ts.saturating_sub(self.cluster_ts)).map_err(|_| bad("block timestamp out of range"))?; + let mut b = Vec::with_capacity(data.len() + 4); + b.push(0x81); + b.extend_from_slice(&rel.to_be_bytes()); + b.push(flags); + b.extend_from_slice(data); + Ok(b) + } + + /// Start a new cluster at a key frame or before the relative timestamp could overflow. + fn place(&mut self, key: bool, ts: u64) -> Result<()> { + if !self.cluster.is_empty() && (key || ts.saturating_sub(self.cluster_ts) > 30_000) { + self.flush_cluster()?; + } + if self.cluster.is_empty() { + self.cluster_ts = ts; + } + Ok(()) + } + + fn write_simple(&mut self, s: &Sample, ts: u64) -> Result<()> { + self.place(s.key, ts)?; + let b = self.block(&s.data, ts, if s.key { 0x80 } else { 0 })?; + self.cluster.extend_from_slice(&el(SIMPLE_BLOCK, &b)); + self.last_block_ts = ts; + Ok(()) + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.tracks_at.is_none() { + if !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.write_tracks()?; + } + if let Some((p, start)) = self.pending.take() { + let ts = self.ms(start); + self.write_simple(&p, ts)?; + } + let start = self.periods; + self.periods = self.periods.saturating_add(s.duration); + if self.periods > MAX_PERIODS { + return Err(bad("video too long")); + } + self.pending = Some((s.clone(), start)); + Ok(()) + } + + pub fn periods(&self) -> u64 { + self.periods + } + + pub fn finish(mut self) -> Result<W> { + let (last, start) = self.pending.take().ok_or_else(|| bad("no frames"))?; + let ts = self.ms(start); + self.place(last.key, ts)?; + let mut group = el(BLOCK, &self.block(&last.data, ts, 0)?); + group.extend_from_slice(&el_uint(BLOCK_DURATION, self.ms(self.periods).saturating_sub(ts).max(1))); + if !last.key { + // the previous block, as a (negative) offset from this one + let back = i64::try_from(ts.saturating_sub(self.last_block_ts)).unwrap_or(i64::MAX).max(1); + el_int_into(&mut group, REFERENCE_BLOCK, -back); + } + self.cluster.extend_from_slice(&el(BLOCK_GROUP, &group)); + self.flush_cluster()?; + // cues: one per cluster (each starts with a key frame or carries on from one) + let cues_at = self.w.stream_position().map_err(io)? - self.segment_start; + let mut cues = Vec::new(); + for (t, pos) in &self.cues { + let mut positions = el_uint(0xF7, 1); // CueTrack + positions.extend_from_slice(&el_uint(0xF1, *pos)); // CueClusterPosition + let mut point = el_uint(0xB3, *t); // CueTime + point.extend_from_slice(&el(0xB7, &positions)); // CueTrackPositions + cues.extend_from_slice(&el(0xBB, &point)); // CuePoint + } + self.w.write_all(&el(CUES, &cues)).map_err(io)?; + let end = self.w.stream_position().map_err(io)?; + // SeekHead in the room kept for it, the rest stays a Void + let tracks_at = self.tracks_at.ok_or_else(|| bad("no tracks"))?; + let mut seeks = Vec::new(); + for (id, at) in [(INFO, self.info_at), (TRACKS, tracks_at), (CUES, cues_at)] { + let mut seek = el(SEEK_ID, &ebml_id(id)); + seek.extend_from_slice(&el(SEEK_POSITION, &at.to_be_bytes())); + seeks.extend_from_slice(&el(SEEK, &seek)); + } + let head = el(SEEK_HEAD, &seeks); + let rest = SEEK_ROOM.checked_sub(head.len()).filter(|r| *r >= 2).ok_or_else(|| bad("seek head too large"))?; + let mut room = head; + room.extend_from_slice(&el(VOID, &vec![0; rest - 2])); + self.w.seek(SeekFrom::Start(self.segment_start)).map_err(io)?; + self.w.write_all(&room).map_err(io)?; + self.w.seek(SeekFrom::Start(self.duration_at)).map_err(io)?; + self.w.write_all(&(self.ms(self.periods) as f64).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(self.segment_size_at)).map_err(io)?; + self.w.write_all(&ebml_size8(end - self.segment_start)).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} + +fn el_int_into(out: &mut Vec<u8>, id: u32, v: i64) { + let b = v.to_be_bytes(); + // the fewest bytes that keep the sign + let mut skip = 0; + while skip < 7 && ((b[skip] == 0 && b[skip + 1] & 0x80 == 0) || (b[skip] == 0xff && b[skip + 1] & 0x80 != 0)) { + skip += 1; + } + out.extend_from_slice(&el(id, &b[skip..])); +} + +// ---------------------------------------------------------------- either container + +/// An MP4 or WebM writer. +pub enum VideoWriter<W: Write + Seek> { + Mp4(Mp4Writer<W>), + WebM(WebmWriter<W>), +} + +impl<W: Write + Seek> VideoWriter<W> { + pub fn new(w: W, container: Container, width: u32, height: u32, fps: u32) -> Result<Self> { + Ok(match container { + Container::Mp4 => VideoWriter::Mp4(Mp4Writer::new(w, width, height, fps)?), + Container::WebM => VideoWriter::WebM(WebmWriter::new(w, width, height, fps)?), + }) + } + + pub fn set_config(&mut self, config: Vec<u8>) { + match self { + VideoWriter::Mp4(m) => m.set_config(config), + VideoWriter::WebM(m) => m.set_config(config), + } + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + match self { + VideoWriter::Mp4(m) => m.add(s), + VideoWriter::WebM(m) => m.add(s), + } + } + + pub fn periods(&self) -> u64 { + match self { + VideoWriter::Mp4(m) => m.periods(), + VideoWriter::WebM(m) => m.periods(), + } + } + + pub fn finish(self) -> Result<W> { + match self { + VideoWriter::Mp4(m) => m.finish(), + VideoWriter::WebM(m) => m.finish(), + } + } +} + +// ---------------------------------------------------------------- AV1 encoder + +/// How to encode: frame size (even numbers; odd ones are rounded down), nominal frame rate, +/// quality 1–100 and rav1e speed 1 (slow) – 10 (fast). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VideoConfig { + pub width: u32, + pub height: u32, + pub fps: u32, + pub quality: u8, + pub speed: u8, +} + +/// Is AV1 video encoding in this build (native, `avif` feature)? +pub const AV1_AVAILABLE: bool = cfg!(all(feature = "avif", not(target_arch = "wasm32"))); + +/// BT.709 limited-range 4:2:0 planes of an sRGB RGBA image (alpha ignored), `w × h` (even). +pub fn yuv420(rgba: &[[u8; 4]], w: usize, h: usize) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>)> { + if w == 0 || h == 0 || !w.is_multiple_of(2) || !h.is_multiple_of(2) || w.checked_mul(h).is_none_or(|n| rgba.len() < n) { + return Err(bad("frame size")); + } + const KR: f32 = 0.2126; + const KB: f32 = 0.0722; + const KG: f32 = 1.0 - KR - KB; + let mut y = vec![0u8; w * h]; + let (cw, ch) = (w / 2, h / 2); + let mut u = vec![0u8; cw * ch]; + let mut v = vec![0u8; cw * ch]; + let luma = |p: [u8; 4]| KR * f32::from(p[0]) + KG * f32::from(p[1]) + KB * f32::from(p[2]); + for (i, out) in y.iter_mut().enumerate() { + let p = rgba.get(i).copied().unwrap_or([0; 4]); + *out = (16.0 + luma(p) * (219.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + for cy in 0..ch { + for cx in 0..cw { + let mut acc = [0f32; 3]; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let p = rgba.get((cy * 2 + dy) * w + cx * 2 + dx).copied().unwrap_or([0; 4]); + for (a, c) in acc.iter_mut().zip(p) { + *a += f32::from(c) / 4.0; + } + } + let l = KR * acc[0] + KG * acc[1] + KB * acc[2]; + let cb = (acc[2] - l) / (2.0 * (1.0 - KB)); + let cr = (acc[0] - l) / (2.0 * (1.0 - KR)); + let i = cy * cw + cx; + if let Some(o) = u.get_mut(i) { + *o = (128.0 + cb * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + if let Some(o) = v.get_mut(i) { + *o = (128.0 + cr * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Ok((y, u, v)) +} + +/// The quantizer for `quality` (ravif's curve, so qualities match the AVIF export). +pub fn quantizer(quality: u8) -> usize { + let q = f32::from(quality.clamp(1, 100)) / 100.0; + let x = if q >= 0.82 { + (1.0 - q) * 2.6 + } else if q > 0.25 { + 1.0 - 0.125 - q * 0.5 + } else { + 1.0 - q + }; + (x * 255.0).round().clamp(0.0, 255.0) as usize +} + +/// An AV1 encoder fed RGBA frames, each with how many frame periods it stays up; it hands back +/// the samples as they come out (in order: no frame reordering). +pub struct Av1Encoder { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + ctx: rav1e::Context<u8>, + /// Each frame sent so far: its duration (by input frame number). + #[cfg_attr(not(all(feature = "avif", not(target_arch = "wasm32"))), allow(dead_code))] + durations: Vec<u64>, + width: usize, + height: usize, + seq_header: Option<Vec<u8>>, +} + +impl Av1Encoder { + pub fn new(c: &VideoConfig) -> Result<Self> { + let (w, h) = ((c.width & !1) as usize, (c.height & !1) as usize); + if w < 16 || h < 16 || w > 8192 || h > 8192 || !(1..=240).contains(&c.fps) { + return Err(bad("unsupported video size or frame rate")); + } + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + use rav1e::prelude::*; + let mut ec = EncoderConfig::with_speed_preset(c.speed.clamp(1, 10)); + ec.width = w; + ec.height = h; + ec.time_base = Rational::new(1, u64::from(c.fps)); + ec.bit_depth = 8; + ec.chroma_sampling = ChromaSampling::Cs420; + ec.pixel_range = PixelRange::Limited; + ec.color_description = Some(ColorDescription { + color_primaries: ColorPrimaries::BT709, + transfer_characteristics: TransferCharacteristics::BT709, + matrix_coefficients: MatrixCoefficients::BT709, + }); + // in order, one shown frame per packet: samples map 1:1 to the frames sent + ec.low_latency = true; + let q = quantizer(c.quality); + ec.quantizer = q; + ec.min_quantizer = q.min(255) as u8; + ec.min_key_frame_interval = 1; + ec.max_key_frame_interval = 120; + // tiles are what rav1e encodes in parallel (16 measured best for 1080p on 32 threads) + ec.tiles = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(2, 16); + let ctx: Context<u8> = Config::new().with_encoder_config(ec).new_context().map_err(|e| bad(format!("AV1: {e}")))?; + Ok(Av1Encoder { ctx, durations: Vec::new(), width: w, height: h, seq_header: None }) + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (w, h); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// The frame size actually encoded (even numbers). + pub fn size(&self) -> (usize, usize) { + (self.width, self.height) + } + + /// The configuration record for the container ([`av1_config`]), once the first sample (with + /// the sequence header) has come out. + pub fn config_record(&self) -> Option<Vec<u8>> { + self.seq_header.as_ref().map(|s| av1_config(s)) + } + + /// Encode `rgba` (`width × height` of [`Self::size`]) shown for `periods` frame periods; + /// `key` forces a key frame (a picture that doesn't lean on earlier ones: a held slide is + /// then clean of the slide before it, and seeking lands on it). + pub fn push(&mut self, rgba: &[[u8; 4]], periods: u64, key: bool) -> Result<Vec<Sample>> { + let (y, u, v) = yuv420(rgba, self.width, self.height)?; + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + let mut frame = self.ctx.new_frame(); + for (plane, (data, w)) in frame.planes.iter_mut().zip([(&y, self.width), (&u, self.width / 2), (&v, self.width / 2)]) { + plane.copy_from_raw_u8(data, w, 1); + } + let params = rav1e::prelude::FrameParameters { + frame_type_override: if key { rav1e::prelude::FrameTypeOverride::Key } else { rav1e::prelude::FrameTypeOverride::No }, + ..Default::default() + }; + self.ctx.send_frame((frame, params)).map_err(|e| bad(format!("AV1: {e}")))?; + self.durations.push(periods.max(1)); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (y, u, v, periods, key); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// Flush the encoder: the samples still inside. + pub fn finish(&mut self) -> Result<Vec<Sample>> { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + self.ctx.flush(); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + Err(bad("AV1 video encoding is not available in this build")) + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + fn drain(&mut self) -> Result<Vec<Sample>> { + use rav1e::prelude::*; + let mut out = Vec::new(); + loop { + match self.ctx.receive_packet() { + Ok(p) => { + let (data, seq) = to_sample_data(&p.data)?; + if self.seq_header.is_none() { + self.seq_header = seq; + } + let duration = usize::try_from(p.input_frameno).ok().and_then(|i| self.durations.get(i)).copied().unwrap_or(1); + out.push(Sample { data, key: p.frame_type == FrameType::KEY, duration }); + } + Err(EncoderStatus::Encoded) => {} + Err(EncoderStatus::NeedMoreData | EncoderStatus::LimitReached) => break, + Err(e) => return Err(bad(format!("AV1: {e}"))), + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// The boxes of an MP4 byte range: (type, body range). + pub fn boxes(d: &[u8], range: std::ops::Range<usize>) -> Vec<([u8; 4], std::ops::Range<usize>)> { + let mut out = Vec::new(); + let mut p = range.start; + while p + 8 <= range.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= range.end, "box {} overruns", String::from_utf8_lossy(&kind)); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, range.end, "trailing bytes"); + out + } + + fn find(d: &[u8], r: std::ops::Range<usize>, path: &[&[u8; 4]]) -> std::ops::Range<usize> { + let mut r = r; + for k in path { + r = boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap_or_else(|| panic!("no {}", String::from_utf8_lossy(*k))).1; + } + r + } + + fn samples() -> Vec<Sample> { + // fake AV1 data: an OBU_FRAME (type 6) with a size field + let frame = |b: u8| vec![0x32, 2, b, b]; + vec![ + Sample { data: frame(1), key: true, duration: 30 }, + Sample { data: frame(2), key: false, duration: 1 }, + Sample { data: frame(3), key: false, duration: 1 }, + Sample { data: frame(4), key: true, duration: 58 }, + ] + } + + #[test] + fn obus_split_and_temporal_delimiters_go() { + // TD, sequence header (3 bytes), frame (2 bytes) + let tu = [0x12, 0, 0x0a, 3, 1, 2, 3, 0x32, 2, 9, 9]; + let o = obus(&tu).unwrap(); + assert_eq!(o.iter().map(|(k, _)| *k).collect::<Vec<_>>(), vec![2, 1, 6]); + let (data, seq) = to_sample_data(&tu).unwrap(); + assert_eq!(data, &tu[2..]); + assert_eq!(seq.unwrap(), &tu[2..7]); + // hostile: sizes past the end, forbidden bit, endless LEB128 + assert!(obus(&[0x32, 9, 1]).is_err()); + assert!(obus(&[0x80]).is_err()); + assert!(obus(&[0x32, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).is_err()); + assert!(obus(&[0x36]).is_err(), "extension byte missing"); + assert_eq!(av1_config(&[1, 2])[..], [0x81, 31, 0x0c, 0, 1, 2]); + } + + #[test] + fn mp4_box_tree_samples_and_durations() { + let mut m = Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![1], key: false, duration: 1 }).is_err(), "must start on a key frame"); + for s in samples() { + m.add(&s).unwrap(); + } + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap().finish().is_err(), "no frames"); + m.set_config(av1_config(&[0x0a, 1, 0])); + let d = m.finish().unwrap().into_inner(); + let top = boxes(&d, 0..d.len()); + assert_eq!(top.iter().map(|(k, _)| *k).collect::<Vec<_>>(), vec![*b"ftyp", *b"mdat", *b"moov"]); + assert_eq!(&d[top[1].1.clone()], &[0x32, 2, 1, 1, 0x32, 2, 2, 2, 0x32, 2, 3, 3, 0x32, 2, 4, 4]); + let stbl = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let kinds: Vec<_> = boxes(&d, stbl.clone()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(kinds, vec![*b"stsd", *b"stts", *b"stss", *b"stsc", *b"stsz", *b"stco"]); + let be = |r: usize| u32::from_be_bytes(d[r..r + 4].try_into().unwrap()); + let stsz = find(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 4, "sample count"); + let stts = find(&d, stbl.clone(), &[b"stts"]); + assert_eq!(be(stts.start + 4), 3, "runs: 30, 1×2, 58"); + let total: u32 = (0..3).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + assert_eq!(total, 90 * 512); + let stss = find(&d, stbl.clone(), &[b"stss"]); + assert_eq!((be(stss.start + 4), be(stss.start + 8), be(stss.start + 12)), (2, 1, 4)); + let stco = find(&d, stbl.clone(), &[b"stco"]); + assert_eq!(be(stco.start + 8) as usize, top[1].1.start, "chunk offset = mdat data"); + let stsd = find(&d, stbl, &[b"stsd"]); + let av01 = boxes(&d, stsd.start + 8..stsd.end); + assert_eq!(av01[0].0, *b"av01"); + let entry = av01[0].1.clone(); + assert_eq!(u16::from_be_bytes([d[entry.start + 24], d[entry.start + 25]]), 64); + let inner: Vec<_> = boxes(&d, entry.start + 78..entry.end).into_iter().map(|(k, _)| k).collect(); + assert_eq!(inner, vec![*b"av1C", *b"colr", *b"pasp"]); + let mdhd = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!((be(mdhd.start + 12), be(mdhd.start + 16)), (30 * 512, 90 * 512)); + let mvhd = find(&d, 0..d.len(), &[b"moov", b"mvhd"]); + assert_eq!(be(mvhd.start + 16), 3000, "3 s in ms"); + } + + /// The EBML elements of a byte range: (id, body range). + fn elements(d: &[u8], range: std::ops::Range<usize>) -> Vec<(u32, std::ops::Range<usize>)> { + let vint = |p: usize| -> (u64, usize) { + let len = d[p].leading_zeros() as usize + 1; + let mut v = u64::from(d[p]) & (0xff >> len); + for i in 1..len { + v = (v << 8) | u64::from(d[p + i]); + } + (v, len) + }; + let mut out = Vec::new(); + let mut p = range.start; + while p < range.end { + let idlen = d[p].leading_zeros() as usize + 1; + let id = d[p..p + idlen].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + let (size, n) = vint(p + idlen); + let start = p + idlen + n; + let end = start + size as usize; + assert!(end <= range.end, "element {id:x} overruns"); + out.push((id, start..end)); + p = end; + } + out + } + + #[test] + fn webm_element_tree_blocks_and_duration() { + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![0x32, 0], key: true, duration: 1 }).is_err(), "configuration first"); + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + m.set_config(av1_config(&[0x0a, 1, 0])); + for s in samples() { + m.add(&s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + let top = elements(&d, 0..d.len()); + assert_eq!(top.iter().map(|(i, _)| *i).collect::<Vec<_>>(), vec![EBML, SEGMENT]); + let seg = elements(&d, top[1].1.clone()); + let ids: Vec<_> = seg.iter().map(|(i, _)| *i).collect(); + assert_eq!(ids, vec![SEEK_HEAD, VOID, INFO, TRACKS, CLUSTER, CLUSTER, CUES]); + let info = elements(&d, seg[2].1.clone()); + let dur = info.iter().find(|(i, _)| *i == DURATION).unwrap().1.clone(); + assert_eq!(f64::from_be_bytes(d[dur].try_into().unwrap()), 3000.0); + let track = elements(&d, elements(&d, seg[3].1.clone())[0].1.clone()); + let codec = track.iter().find(|(i, _)| *i == 0x86).unwrap().1.clone(); + assert_eq!(&d[codec], b"V_AV1"); + let first: Vec<_> = elements(&d, seg[4].1.clone()).into_iter().map(|(i, _)| i).collect(); + assert_eq!(first, vec![TIMESTAMP, SIMPLE_BLOCK, SIMPLE_BLOCK, SIMPLE_BLOCK]); + let second = elements(&d, seg[5].1.clone()); + assert_eq!(d[second[0].1.clone()], [0x04, 0x2a], "the second cluster starts at 1066 ms (32 periods)"); + let group = elements(&d, second[1].1.clone()); + assert_eq!(group.iter().map(|(i, _)| *i).collect::<Vec<_>>(), vec![BLOCK, BLOCK_DURATION]); + // the SeekHead points at Info, Tracks and Cues + let seg_start = top[1].1.start; + for seek in elements(&d, seg[0].1.clone()) { + let e = elements(&d, seek.1); + let pos = d[e[1].1.clone()].iter().fold(0usize, |a, b| (a << 8) | usize::from(*b)); + let id = d[e[0].1.clone()].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + assert!(seg.iter().any(|(i, r)| *i == id && r.start > seg_start + pos), "{id:x}"); + } + let mut neg = Vec::new(); + el_int_into(&mut neg, REFERENCE_BLOCK, -33); + assert_eq!(neg, vec![0xfb, 0x81, 0xdf]); + assert_eq!(ebml_size(126), vec![0xfe]); + assert_eq!(ebml_size(127), vec![0x40, 0x7f]); + } + + #[test] + fn hostile_sizes_are_refused() { + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 0, 10, 30).is_err()); + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 70_000, 10, 30).is_err()); + assert!(WebmWriter::new(Cursor::new(Vec::new()), 10, 10, 0).is_err()); + assert!(yuv420(&[[0; 4]; 3], 2, 2).is_err()); + assert!(yuv420(&[[0; 4]; 9], 3, 3).is_err()); + let (y, u, v) = yuv420(&[[255; 4]; 4], 2, 2).unwrap(); + assert_eq!((y, u, v), (vec![235; 4], vec![128], vec![128])); + assert!(quantizer(100) < quantizer(50) && quantizer(50) < quantizer(1)); + assert!(Av1Encoder::new(&VideoConfig { width: 8, height: 8, fps: 30, quality: 50, speed: 10 }).is_err()); + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + #[test] + fn encodes_av1_into_a_playable_mp4() { + let c = VideoConfig { width: 64, height: 48, fps: 24, quality: 60, speed: 10 }; + let mut e = Av1Encoder::new(&c).unwrap(); + let mut out = Vec::new(); + for (i, periods) in [(0u8, 24u64), (80, 1), (160, 1), (240, 30)] { + let img = vec![[i, 255 - i, 128, 255]; 64 * 48]; + out.extend(e.push(&img, periods, periods > 1).unwrap()); + } + out.extend(e.finish().unwrap()); + assert_eq!(out.len(), 4, "one sample per frame sent"); + assert!(out[0].key); + assert_eq!(out.iter().map(|s| s.duration).sum::<u64>(), 56); + for s in &out { + assert!(obus(&s.data).unwrap().iter().all(|(k, _)| *k != OBU_TEMPORAL_DELIMITER)); + } + let config = e.config_record().unwrap(); + assert_eq!(obus(&config[4..]).unwrap()[0].0, OBU_SEQUENCE_HEADER); + let mut m = VideoWriter::new(Cursor::new(Vec::new()), Container::Mp4, 64, 48, 24).unwrap(); + m.set_config(config); + for s in &out { + m.add(s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + assert_eq!(boxes(&d, 0..d.len()).len(), 3); + } +} diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs index 92c1a325c..33ff76ec0 100644 --- a/crates/engine/src/cmd/slideshow.rs +++ b/crates/engine/src/cmd/slideshow.rs @@ -8,7 +8,9 @@ //! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes //! one slide, or a title screen, as a PNG (with the library's settings, or a template's). //! `slideshow.templates.*`: the Template Browser (built-in and user templates; see -//! [`crate::slide_template`]). +//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an +//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how +//! it is going. use lightcraft_catalog::PhotoId; use serde_json::{Value, json}; @@ -307,6 +309,69 @@ fn tpl_export(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({"id": t.id, "name": t.name, "path": path})) } +/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`). +fn export(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.export"; + if cfg!(target_arch = "wasm32") { + return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli")); + } + if s.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)")); + } + let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone())); + let total = prepared.total(); + task.progress(0, total); + task.detail(&prepared.request.path); + let settings = &prepared.request.settings; + let run = std::sync::Arc::new(crate::slideshow_export::ExportRun { + task_id: task.id(), + format: settings.format.id().into(), + path: prepared.request.path.clone(), + total: total.into(), + cancel, + ..Default::default() + }); + s.slideshow_export = Some(run.clone()); + let started = json!({ + "taskId": task.id(), + "background": !wait, + "format": settings.format.id(), + "path": prepared.request.path, + "slides": prepared.len(), + "seconds": prepared.seconds(), + "total": total, + "width": prepared.width, + "height": prepared.height, + "warnings": prepared.warnings, + }); + if wait { + crate::slideshow_export::run_to_end(prepared, &run, &task); + drop(task); + return match run.result() { + Some(Ok(v)) => Ok(v), + Some(Err(e)) => Err(bad(C, e)), + None => Err(bad(C, "the export did not finish")), + }; + } + #[cfg(not(target_arch = "wasm32"))] + { + // the row goes when the worker ends, however it ends + let r = run.clone(); + std::thread::Builder::new() + .name("lc-slideshow-export".into()) + .spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task)) + .map_err(|e| bad(C, format!("could not start: {e}")))?; + } + Ok(started) +} + +fn export_status(s: &mut Session, _: &Value) -> Result<Value> { + Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json())) +} + pub fn specs() -> Vec<CommandSpec> { vec![ cmd!( @@ -426,5 +491,23 @@ pub fn specs() -> Vec<CommandSpec> { always, preview ), + cmd!( + query "slideshow.export", + "Export Slideshow", + [], + None, + "{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: <name>-001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)", + always, + export + ), + cmd!( + query "slideshow.exportStatus", + "Slideshow Export Status", + [], + None, + "{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none", + always, + export_status + ), ] } diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index 663d6f826..d6dbf158a 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -1,4 +1,5 @@ -//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF. +//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF +//! ([`crate::pdf`]). use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image}; use crate::{Session, media::RenderJob}; use lightcraft_pipeline::{OutputDepth, OutputSpace}; @@ -114,7 +115,7 @@ impl Prepared { let capacity = self.options.columns * self.options.rows; let photos = self.items.len(); let pages = photos.div_ceil(capacity); - let mut pdf = Pdf::new(pages, l.points); + let mut pdf = crate::pdf::Pdf::new(Vec::new())?; let mut page = Rgba8::filled(l.width, l.height, [255; 4]); for (i, (name, job)) in self.items.into_iter().enumerate() { if !progress(i, &name) { @@ -148,17 +149,17 @@ impl Prepared { } if slot + 1 == capacity || i + 1 == photos { let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; - if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { + if pdf.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { return Err("contact sheet exceeds 256 MiB; select fewer photos".into()); } - pdf.page(i / capacity, &jpg, l.width, l.height); + pdf.image_page(&jpg, l.width, l.height, l.points, "")?; page.data.fill([255; 4]); } } if !progress(photos, "PDF") { return Err("Contact sheet cancelled".into()); } - Ok(Document { bytes: pdf.finish(), pages, photos }) + Ok(Document { bytes: pdf.finish("")?, pages, photos }) } } @@ -195,67 +196,6 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> Ok(()) } -// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -struct Pdf { - bytes: Vec<u8>, - offsets: Vec<usize>, - points: (f64, f64), -} - -impl Pdf { - fn new(pages: usize, points: (f64, f64)) -> Self { - let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; - p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); - let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" "); - p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes()); - p - } - - fn object(&mut self, id: usize, body: &[u8]) { - self.offsets.push(self.bytes.len()); - self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - self.bytes.extend_from_slice(body); - self.bytes.extend_from_slice(b"\nendobj\n"); - } - - fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) { - let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); - body.extend_from_slice(bytes); - body.extend_from_slice(b"\nendstream"); - self.object(id, &body); - } - - fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { - let id = 3 + index * 3; - let (w, h) = self.points; - self.object( - id, - format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>", - id + 2, - id + 1 - ) - .as_bytes(), - ); - self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes()); - self.stream( - id + 2, - &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), - jpg, - ); - } - - fn finish(mut self) -> Vec<u8> { - let start = self.bytes.len(); - self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); - for offset in self.offsets.iter().skip(1) { - self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); - self.bytes - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5cb09fd87..74b6f5dbb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,6 +49,7 @@ pub mod memory; pub mod merge; mod model_download; pub mod originals; +mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; @@ -60,6 +61,7 @@ pub mod slide_overlay; pub mod slide_template; pub mod slide_title; pub mod slideshow; +pub mod slideshow_export; pub mod smart; pub mod sync; pub mod text; @@ -271,6 +273,8 @@ pub struct Session { /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and /// `activity.list` / `activity.cancel`. pub activity: activity::Activity, + /// The last (or running) slideshow export (`slideshow.export`). + pub slideshow_export: Option<std::sync::Arc<slideshow_export::ExportRun>>, /// The last (or running) Build Previews. pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>, /// Develop defaults applied on import (persisted in prefs.json). @@ -376,6 +380,7 @@ impl Session { folder_changes: None, activity: Default::default(), preview_build: None, + slideshow_export: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, slideshow: slideshow::PlaybackSettings::default(), @@ -941,6 +946,8 @@ mod tests_settings_files; #[cfg(test)] mod tests_slide; #[cfg(test)] +mod tests_slideshow_export; +#[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs new file mode 100644 index 000000000..963535f5a --- /dev/null +++ b/crates/engine/src/pdf.rs @@ -0,0 +1,110 @@ +//! A minimal PDF 1.4 writer for image pages: one JPEG XObject per page, explicit byte lengths and +//! a classic cross-reference table. Used by the contact sheet ([`crate::contact_sheet`]) and the +//! slideshow PDF export ([`crate::slideshow_export`]). It streams into any writer, so a long +//! slideshow is never held in memory; the page tree and catalog are written last, once the +//! number of pages is known. + +use std::io::Write; + +/// Objects 1 and 2 are the catalog and the page tree; pages take three objects each from 3. +const FIRST_PAGE: usize = 3; + +pub(crate) struct Pdf<W: Write> { + w: W, + /// Bytes written so far. + pos: usize, + /// Each object's offset, by object number (index 0 unused). + offsets: Vec<usize>, + pages: Vec<usize>, +} + +fn io(e: std::io::Error) -> String { + format!("PDF: {e}") +} + +impl<W: Write> Pdf<W> { + pub(crate) fn new(w: W) -> Result<Self, String> { + let mut p = Pdf { w, pos: 0, offsets: vec![0; FIRST_PAGE], pages: Vec::new() }; + p.put(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?; + Ok(p) + } + + /// Bytes written so far. + pub(crate) fn len(&self) -> usize { + self.pos + } + + /// Pages added so far. + pub(crate) fn pages(&self) -> usize { + self.pages.len() + } + + fn put(&mut self, b: &[u8]) -> Result<(), String> { + self.w.write_all(b).map_err(io)?; + self.pos = self.pos.saturating_add(b.len()); + Ok(()) + } + + fn object(&mut self, id: usize, body: &[u8]) -> Result<(), String> { + if self.offsets.len() <= id { + self.offsets.resize(id + 1, 0); + } + if let Some(o) = self.offsets.get_mut(id) { + *o = self.pos; + } + self.put(format!("{id} 0 obj\n").as_bytes())?; + self.put(body)?; + self.put(b"\nendobj\n") + } + + fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) -> Result<(), String> { + let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\nendstream"); + self.object(id, &body) + } + + /// A page of `points` (width, height) filled by `jpg` (`width × height` pixels, RGB). + /// `extra` goes into the page dictionary (e.g. a display duration and transition). + pub(crate) fn image_page(&mut self, jpg: &[u8], width: usize, height: usize, points: (f64, f64), extra: &str) -> Result<(), String> { + let id = FIRST_PAGE + self.pages.len() * 3; + let (w, h) = points; + self.object( + id, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R{extra} >>", + id + 2, + id + 1 + ) + .as_bytes(), + )?; + self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes())?; + self.stream( + id + 2, + &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), + jpg, + )?; + self.pages.push(id); + Ok(()) + } + + /// Write the page tree, the catalog (with `catalog_extra` in its dictionary, e.g. a page + /// mode), the cross-reference table and the trailer. + pub(crate) fn finish(mut self, catalog_extra: &str) -> Result<W, String> { + if self.pages.is_empty() { + return Err("PDF: no pages".into()); + } + let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" "); + self.object(2, format!("<< /Type /Pages /Count {} /Kids [{kids}] >>", self.pages.len()).as_bytes())?; + self.object(1, format!("<< /Type /Catalog /Pages 2 0 R{catalog_extra} >>").as_bytes())?; + let start = self.pos; + let mut xref = format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).into_bytes(); + for offset in self.offsets.iter().skip(1) { + xref.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + xref.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); + self.put(&xref)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} diff --git a/crates/engine/src/slideshow_export.rs b/crates/engine/src/slideshow_export.rs new file mode 100644 index 000000000..e91c1ed34 --- /dev/null +++ b/crates/engine/src/slideshow_export.rs @@ -0,0 +1,760 @@ +//! Slideshow export (Lightroom Classic's Export PDF / Export JPEG / Export Video): the photos in +//! view as their composed slides (layout, overlays: [`crate::slide`]), between the intro and +//! ending screens that are on ([`crate::slide_title`]), written as +//! +//! - **PDF**: one page per slide, the page the slide's size in points (1 px = 1 pt), its picture a +//! JPEG; each page carries the slide duration and a fade transition for viewers' presentation +//! mode, and "full screen" asks the viewer to open in it (`/PageMode /FullScreen`); +//! - **JPEG sequence**: `<name>-001.jpg`, `<name>-002.jpg`… in a folder; +//! - **video**: AV1 in MP4 or WebM ([`lightcraft_codecs::video`], our own muxers), the timeline +//! sampled at 24/25/30/60 fps: each slide held for its duration, fades rendered as blended +//! frames (crossfade, or through the fade colour). No audio track. +//! +//! The slides play once in view order (random order and repeat are playback choices, not +//! export ones). The slide aspect comes from Layout's aspect preview; "screen" exports 16:9. +//! +//! [`prepare`] reads the session (cheap: render jobs, no pixels) and [`Prepared::run`] does the +//! work on any thread, with progress and a cancel flag. Files are written as `<file>.part` and +//! renamed when everything is written, so a cancelled or failed export leaves nothing behind. A +//! slide whose photo can't be rendered (file gone, unreadable) is skipped with a warning. + +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use lightcraft_catalog::PhotoId; +use lightcraft_codecs::video::{Av1Encoder, Container, VideoConfig, VideoWriter}; +use lightcraft_pipeline::{OutputDepth, OutputSpace}; +use lightcraft_raster::Rgba8; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::media::RenderJob; +use crate::originals::OriginalGuard; +use crate::slide_title::TitleCompose; +use crate::slideshow::{SLIDE_MAX, SLIDE_MIN, Title}; +use crate::{Session, export::ExportFormat, export::ExportOptions}; + +/// The longest video exported (seconds): 2 hours. +pub const MAX_SECONDS: f64 = 2.0 * 3600.0; +/// The most slides an export takes. +pub const MAX_SLIDES: usize = 10_000; +/// Long side of PDF pages and JPEGs, in pixels. +pub const SIZE_MIN: usize = 64; +pub const SIZE_MAX: usize = lightcraft_slideshow::MAX_SIDE; +/// Video frame height, in pixels. +pub const VIDEO_MIN: usize = 64; +pub const VIDEO_MAX: usize = 2160; +/// Frame rates offered. +pub const FPS: [u32; 4] = [24, 25, 30, 60]; + +/// What an export writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { + Pdf, + Jpeg, + Video, +} + +impl Format { + pub fn id(self) -> &'static str { + match self { + Format::Pdf => "pdf", + Format::Jpeg => "jpeg", + Format::Video => "video", + } + } +} + +/// The export's settings (the dialog's state, and `slideshow.export`'s params without `path`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExportSettings { + pub format: Format, + /// PDF / JPEG: the long side in pixels; video: the frame height (720 = 720p). + pub size: usize, + /// JPEG quality (PDF pages, JPEG files) or video quality, 1–100. + pub quality: u8, + /// Video frames per second. + pub fps: u32, + /// Video container: "mp4" or "webm". + pub container: String, + /// PDF: ask the viewer to open in full screen. + pub full_screen: bool, + /// Video: rav1e speed preset, 1 (slow, smaller) – 10 (fast). + pub speed: u8, +} + +impl Default for ExportSettings { + fn default() -> Self { + ExportSettings { format: Format::Pdf, size: 1920, quality: 90, fps: 30, container: "mp4".into(), full_screen: true, speed: 10 } + } +} + +impl ExportSettings { + /// The defaults for `format` (sizes: PDF/JPEG 1920 px, video 720p; video quality 75). + pub fn for_format(format: Format) -> Self { + match format { + Format::Video => ExportSettings { format, size: 720, quality: 75, ..Default::default() }, + _ => ExportSettings { format, ..Default::default() }, + } + } + + /// The file extension the export's path takes (none: a JPEG sequence goes in a folder). + pub fn extension(&self) -> Option<&'static str> { + match self.format { + Format::Pdf => Some("pdf"), + Format::Jpeg => None, + Format::Video => Some(if self.container == "webm" { "webm" } else { "mp4" }), + } + } +} + +/// `slideshow.export`'s params, checked: settings, path, file name prefix and photos. +#[derive(Clone, Debug, PartialEq)] +pub struct Request { + pub settings: ExportSettings, + pub path: String, + /// JPEG sequence: the files' name before `-001` (default: the folder's name). + pub name: Option<String>, + pub ids: Option<Vec<PhotoId>>, +} + +const KEYS: &str = "format, path, size, quality, fps, container, fullScreen, speed, name, ids, wait"; + +fn uint(k: &str, v: &Value, lo: u64, hi: u64) -> Result<u64, String> { + match v.as_u64() { + Some(n) if (lo..=hi).contains(&n) => Ok(n), + _ => Err(format!("`{k}` must be a whole number from {lo} to {hi}, not {v}")), + } +} + +impl Request { + pub fn parse(p: &Value) -> Result<Request, String> { + let o: &Map<String, Value> = p.as_object().ok_or("params must be an object")?; + if let Some(k) = o.keys().find(|k| !KEYS.split(", ").any(|x| x == k.as_str())) { + return Err(format!("unknown key `{k}` (known: {KEYS})")); + } + let format = match o.get("format").and_then(Value::as_str).map(str::trim) { + Some("pdf") => Format::Pdf, + Some("jpeg" | "jpg") => Format::Jpeg, + Some("video") => Format::Video, + _ => return Err(format!("`format` must be \"pdf\", \"jpeg\" or \"video\", not {}", o.get("format").unwrap_or(&Value::Null))), + }; + let mut s = ExportSettings::for_format(format); + let path = o.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or("missing `path`")?.to_string(); + if let Some(v) = o.get("size").filter(|v| !v.is_null()) { + // "720p" / "1080p" for video + let v = match v.as_str().map(|x| x.trim().trim_end_matches(['p', 'P'])) { + Some(x) => x.parse::<u64>().map(Value::from).map_err(|_| format!("`size` must be a number of pixels, not {v}"))?, + None => v.clone(), + }; + s.size = match format { + Format::Video => uint("size", &v, VIDEO_MIN as u64, VIDEO_MAX as u64)? as usize, + _ => uint("size", &v, SIZE_MIN as u64, SIZE_MAX as u64)? as usize, + }; + } + if let Some(v) = o.get("quality").filter(|v| !v.is_null()) { + s.quality = uint("quality", v, 1, 100)? as u8; + } + if let Some(v) = o.get("fps").filter(|v| !v.is_null()) { + let f = + v.as_u64().filter(|f| FPS.iter().any(|x| u64::from(*x) == *f)).ok_or_else(|| format!("`fps` must be 24, 25, 30 or 60, not {v}"))?; + s.fps = f as u32; + } + if let Some(v) = o.get("container").filter(|v| !v.is_null()) { + let c = v.as_str().and_then(Container::parse).ok_or_else(|| format!("`container` must be \"mp4\" or \"webm\", not {v}"))?; + s.container = c.id().into(); + } + if let Some(v) = o.get("fullScreen").filter(|v| !v.is_null()) { + s.full_screen = v.as_bool().ok_or_else(|| format!("`fullScreen` must be true or false, not {v}"))?; + } + if let Some(v) = o.get("speed").filter(|v| !v.is_null()) { + s.speed = uint("speed", v, 1, 10)? as u8; + } + if let Some(v) = o.get("wait").filter(|v| !v.is_null() && !v.is_boolean()) { + return Err(format!("`wait` must be true or false, not {v}")); + } + let name = match o.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() + || n.chars().count() > 120 + || n.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')) + { + return Err(format!("`name` must be 1–120 characters without path separators or reserved characters, not {n:?}")); + } + Some(n.to_string()) + } + Some(v) => return Err(format!("`name` must be text, not {v}")), + }; + if name.is_some() && format != Format::Jpeg { + return Err("`name` is for the JPEG sequence only".into()); + } + let ids = match o.get("ids") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => { + let ids: Option<Vec<PhotoId>> = a.iter().map(|v| v.as_u64().map(PhotoId)).collect(); + Some(ids.ok_or("`ids` must be an array of photo ids")?) + } + Some(v) => return Err(format!("`ids` must be an array of photo ids, not {v}")), + }; + Ok(Request { settings: s, path, name, ids }) + } +} + +/// What a slide shows. +enum Source { + Photo { name: String, job: Box<RenderJob> }, + Title { title: Title, compose: Arc<TitleCompose> }, +} + +struct Slide { + source: Source, + /// Seconds it stays up, its fade in included. + duration: f64, + /// Seconds the transition into it takes (none for the first). + fade: f64, +} + +impl Slide { + fn label(&self) -> String { + match &self.source { + Source::Photo { name, .. } => name.clone(), + Source::Title { title, .. } => title.id().to_string(), + } + } + + /// The slide's pixels, `w × h`. + fn render(self, w: usize, h: usize) -> Result<Rgba8, String> { + let img = match self.source { + Source::Photo { job, .. } => job.run().rendered?.image, + Source::Title { compose, .. } => compose.compose(), + }; + Ok(fit(img, w, h)) + } +} + +/// `img` at exactly `w × h`: as it is when it already is (the composer's output), else centred +/// on black (a slide bigger than the composer draws, or a surprise from a renderer). +fn fit(img: Rgba8, w: usize, h: usize) -> Rgba8 { + if img.width == w && img.height == h && img.data.len() == w * h { + return img; + } + let mut out = Rgba8::filled(w, h, [0, 0, 0, 255]); + let (cw, ch) = (img.width.min(w), img.height.min(h)); + let (dx, dy) = ((w - cw) / 2, (h - ch) / 2); + let (sx, sy) = ((img.width - cw) / 2, (img.height - ch) / 2); + for y in 0..ch { + let (Some(src), Some(dst)) = + (img.data.get((sy + y) * img.width + sx..(sy + y) * img.width + sx + cw), out.data.get_mut((dy + y) * w + dx..(dy + y) * w + dx + cw)) + else { + continue; + }; + dst.copy_from_slice(src); + } + out +} + +/// An export ready to run: everything it needs from the session. +pub struct Prepared { + pub request: Request, + slides: Vec<Slide>, + pub width: usize, + pub height: usize, + fade_color: Option<[u8; 3]>, + guard: OriginalGuard, + /// Problems found while preparing (photos not in the library). + pub warnings: Vec<String>, +} + +/// The size of a slide whose long side is `long` at `ratio` (width / height). +fn sized(ratio: f64, long: usize) -> (usize, usize) { + let short = |l: usize, r: f64| ((l as f64 / r).round() as usize).max(1); + if ratio >= 1.0 { (long, short(long, ratio)) } else { (short(long, 1.0 / ratio), long) } +} + +/// Read the session: the slides, their render jobs and durations, the output size. +pub fn prepare(s: &mut Session, p: &Value) -> Result<Prepared, String> { + let request = Request::parse(p)?; + let settings = &request.settings; + let ratio = s.slide_layout.aspect.ratio(16.0 / 9.0); + let (width, height) = match settings.format { + Format::Video => { + let h = settings.size & !1; + ((((h as f64 * ratio).round() as usize) & !1).max(16), h) + } + _ => sized(ratio, settings.size), + }; + let ids = match &request.ids { + Some(ids) => ids.clone(), + None => s.visible_cloned(), + }; + if ids.is_empty() { + return Err("no photos in view to export".into()); + } + if ids.len() > MAX_SLIDES { + return Err(format!("a slideshow export takes at most {MAX_SLIDES} photos; this view has {}", ids.len())); + } + let play = s.slideshow.clone().sanitized(); + let (intro, ending) = s.slide_titles.durations(play.slide_duration); + let clamp = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { play.slide_duration }; + let layout = s.slide_layout.clone().sanitized(); + let mut warnings = Vec::new(); + let mut slides = Vec::with_capacity(ids.len() + 2); + let title = |s: &Session, t: Title, d: f64| Slide { + source: Source::Title { title: t, compose: s.title_compose(t, width, height) }, + duration: clamp(d), + fade: 0.0, + }; + if let Some(d) = intro { + slides.push(title(s, Title::Intro, d)); + } + for id in ids { + let Some(photo) = s.catalog.photo(id).cloned() else { + warnings.push(format!("photo {} is not in the library; skipped", id.0)); + continue; + }; + let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); + let (pw, ph) = lightcraft_slideshow::photo_size(&layout, aspect, width, height); + let job = match s.export_job(id, pw.max(8), ph.max(8), OutputSpace::Srgb, OutputDepth::U8) { + Ok(job) => job, + Err(e) => { + warnings.push(format!("{}: {e}; skipped", photo.file_name)); + continue; + } + }; + let compose = s.slide_compose(&layout, width, height, Some(id)); + slides.push(Slide { + source: Source::Photo { name: photo.file_name.clone(), job: Box::new(job.with_slide(compose)) }, + duration: play.slide_duration, + fade: 0.0, + }); + } + if !slides.iter().any(|x| matches!(x.source, Source::Photo { .. })) { + return Err("none of the photos can be exported".into()); + } + if let Some(d) = ending { + slides.push(title(s, Title::Ending, d)); + } + // the transition into each slide after the first: the fade, never longer than the slide + for x in slides.iter_mut().skip(1) { + x.fade = play.fade_duration.min(x.duration).max(0.0); + } + if settings.format == Format::Video { + let total: f64 = slides.iter().map(|x| x.duration).sum(); + if total > MAX_SECONDS { + return Err(format!( + "the slideshow lasts {} min; video export is limited to {} min (shorten the slides or export fewer photos)", + (total / 60.0).ceil(), + MAX_SECONDS / 60.0 + )); + } + if !lightcraft_codecs::video::AV1_AVAILABLE { + return Err("video export (AV1) is not available in this build".into()); + } + } + s.check_write_target(&request.path)?; + Ok(Prepared { request, slides, width, height, fade_color: play.fade_color, guard: s.original_guard(), warnings }) +} + +/// What an export wrote. +#[derive(Clone, Debug, PartialEq)] +pub struct Outcome { + pub json: Value, +} + +/// Progress: (done, total, the slide being worked on). `total` is slides (PDF, JPEG) or frames +/// at the nominal rate (video). +pub type Progress<'a> = &'a dyn Fn(u64, u64, &str); + +const CANCELLED: &str = "Slideshow export cancelled"; + +/// A file written as `<path>.part` and renamed over `path` when complete; dropped before that, +/// the part file is removed. +struct PartFile { + part: PathBuf, + target: PathBuf, + done: bool, +} + +impl PartFile { + fn new(target: &Path) -> Result<(PartFile, std::fs::File), String> { + if let Some(dir) = target.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let mut name = target.file_name().ok_or_else(|| format!("{}: not a file name", target.display()))?.to_os_string(); + name.push(".part"); + let part = target.with_file_name(name); + let f = std::fs::File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?; + Ok((PartFile { part, target: target.to_path_buf(), done: false }, f)) + } + + fn commit(mut self, guard: &OriginalGuard) -> Result<(), String> { + guard.check(&self.target)?; + std::fs::rename(&self.part, &self.target).map_err(|e| format!("{}: {e}", self.target.display()))?; + self.done = true; + Ok(()) + } +} + +impl Drop for PartFile { + fn drop(&mut self) { + if !self.done { + let _ = std::fs::remove_file(&self.part); + } + } +} + +/// Blend `a` → `b` at `k` (0 = a, 1 = b), or through `colour` (out to it, then in from it). +fn blend(a: &Rgba8, b: &Rgba8, k: f32, colour: Option<[u8; 3]>) -> Vec<[u8; 4]> { + let mix = |x: u8, y: u8, t: f32| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8; + match colour { + Some(c) => { + let (img, veil) = if k < 0.5 { (a, k * 2.0) } else { (b, 2.0 - k * 2.0) }; + img.data.iter().map(|p| [mix(p[0], c[0], veil), mix(p[1], c[1], veil), mix(p[2], c[2], veil), 255]).collect() + } + None => a.data.iter().zip(&b.data).map(|(p, q)| [mix(p[0], q[0], k), mix(p[1], q[1], k), mix(p[2], q[2], k), 255]).collect(), + } +} + +impl Prepared { + /// Slides to export (titles included). + pub fn len(&self) -> usize { + self.slides.len() + } + + pub fn is_empty(&self) -> bool { + self.slides.is_empty() + } + + /// Seconds the slideshow lasts. + pub fn seconds(&self) -> f64 { + self.slides.iter().map(|x| x.duration).sum() + } + + /// Units of progress: slides, or frames at the nominal rate for video. + pub fn total(&self) -> u64 { + match self.request.settings.format { + Format::Video => (self.seconds() * f64::from(self.request.settings.fps)).round() as u64, + _ => self.slides.len() as u64, + } + } + + /// Write the export. `cancel` is checked between slides and frames. + pub fn run(self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + match self.request.settings.format { + Format::Pdf => self.run_pdf(progress, cancel), + Format::Jpeg => self.run_jpeg(progress, cancel), + Format::Video => self.run_video(progress, cancel), + } + } + + fn jpeg(&self, img: &Rgba8) -> Result<Vec<u8>, String> { + crate::export::encode_image(img, &ExportOptions { format: ExportFormat::Jpeg, quality: self.request.settings.quality, ..Default::default() }) + } + + fn summary(&self, format: Format, slides: usize, skipped: usize, warnings: Vec<String>) -> Value { + json!({ + "format": format.id(), + "path": self.request.path, + "width": self.width, + "height": self.height, + "slides": slides, + "skipped": skipped, + "warnings": warnings, + }) + } + + fn run_pdf(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + let target = PathBuf::from(&self.request.path); + let (part, file) = PartFile::new(&target)?; + let mut pdf = crate::pdf::Pdf::new(BufWriter::new(file))?; + let (w, h) = (self.width, self.height); + let total = self.slides.len() as u64; + let mut warnings = std::mem::take(&mut self.warnings); + let mut skipped = 0; + let slides = std::mem::take(&mut self.slides); + for (i, slide) in slides.into_iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let label = slide.label(); + progress(i as u64, total, &label); + let (duration, fade) = (slide.duration, slide.fade); + let img = match slide.render(w, h) { + Ok(img) => img, + Err(e) => { + warnings.push(format!("{label}: {e}; skipped")); + skipped += 1; + continue; + } + }; + let jpg = self.jpeg(&img)?; + // presentation mode: how long the page stays, and the fade into it + let mut extra = format!(" /Dur {duration:.2}"); + if fade > 0.0 { + extra += &format!(" /Trans << /S /Fade /D {fade:.2} >>"); + } + pdf.image_page(&jpg, w, h, (w as f64, h as f64), &extra)?; + } + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let pages = pdf.pages(); + if pages == 0 { + return Err("no slide could be rendered".into()); + } + let bytes = pdf.len(); + let catalog = if self.request.settings.full_screen { " /PageMode /FullScreen" } else { "" }; + pdf.finish(catalog)?.into_inner().map_err(|e| format!("PDF: {}", e.error()))?.sync_all().map_err(|e| format!("PDF: {e}"))?; + part.commit(&self.guard)?; + progress(total, total, ""); + let mut out = self.summary(Format::Pdf, pages, skipped, warnings); + out["pages"] = json!(pages); + out["bytes"] = json!(bytes); + out["fullScreen"] = json!(self.request.settings.full_screen); + Ok(Outcome { json: out }) + } + + fn run_jpeg(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + let dir = PathBuf::from(&self.request.path); + let prefix = self + .request + .name + .clone() + .or_else(|| dir.file_name().map(|n| n.to_string_lossy().trim().to_string()).filter(|n| !n.is_empty())) + .unwrap_or_else(|| "Slideshow".into()); + let digits = self.slides.len().to_string().len().max(3); + let (w, h) = (self.width, self.height); + let total = self.slides.len() as u64; + let mut warnings = std::mem::take(&mut self.warnings); + let mut skipped = 0; + let mut parts = Vec::new(); + let mut bytes = 0usize; + let slides = std::mem::take(&mut self.slides); + for (i, slide) in slides.into_iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let label = slide.label(); + progress(i as u64, total, &label); + let img = match slide.render(w, h) { + Ok(img) => img, + Err(e) => { + warnings.push(format!("{label}: {e}; skipped")); + skipped += 1; + continue; + } + }; + let jpg = self.jpeg(&img)?; + let target = dir.join(format!("{prefix}-{:0digits$}.jpg", parts.len() + 1)); + self.guard.check(&target)?; + let (part, mut f) = PartFile::new(&target)?; + f.write_all(&jpg).map_err(|e| format!("{}: {e}", part.part.display()))?; + f.sync_all().map_err(|e| format!("{}: {e}", part.part.display()))?; + bytes = bytes.saturating_add(jpg.len()); + parts.push(part); + } + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + if parts.is_empty() { + return Err("no slide could be rendered".into()); + } + let files: Vec<String> = parts.iter().map(|p| p.target.to_string_lossy().to_string()).collect(); + // all written: now they take their names (a failure here removes the rest) + for part in parts { + part.commit(&self.guard)?; + } + progress(total, total, ""); + let mut out = self.summary(Format::Jpeg, files.len(), skipped, warnings); + out["files"] = json!(files); + out["bytes"] = json!(bytes); + Ok(Outcome { json: out }) + } + + fn run_video(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + let st = self.request.settings.clone(); + let container = Container::parse(&st.container).unwrap_or(Container::Mp4); + let (w, h) = (self.width, self.height); + let fps = st.fps; + let mut enc = Av1Encoder::new(&VideoConfig { width: w as u32, height: h as u32, fps, quality: st.quality, speed: st.speed }) + .map_err(|e| e.to_string())?; + let target = PathBuf::from(&self.request.path); + let (part, file) = PartFile::new(&target)?; + let mut mux = VideoWriter::new(BufWriter::new(file), container, w as u32, h as u32, fps).map_err(|e| e.to_string())?; + let total = self.total(); + let mut warnings = std::mem::take(&mut self.warnings); + let mut skipped = 0; + let mut slides_done = 0; + let mut samples = 0u64; + let mut keys = 0u64; + // frames emitted so far, and the slideshow time (seconds) of the slides kept + let mut frames = 0u64; + let mut clock = 0.0f64; + let mut prev: Option<Rgba8> = None; + let mut emit = |mux: &mut VideoWriter<BufWriter<std::fs::File>>, + enc: &mut Av1Encoder, + pixels: &[[u8; 4]], + periods: u64, + key: bool| + -> Result<(), String> { + let out = enc.push(pixels, periods, key).map_err(|e| e.to_string())?; + for s in out { + if let Some(c) = enc.config_record() { + mux.set_config(c); + } + keys += u64::from(s.key); + samples += 1; + mux.add(&s).map_err(|e| e.to_string())?; + } + Ok(()) + }; + let fade_color = self.fade_color; + let slides = std::mem::take(&mut self.slides); + for slide in slides { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let label = slide.label(); + progress(frames, total, &label); + let (duration, fade) = (slide.duration, slide.fade); + let img = match slide.render(w, h) { + Ok(img) => img, + Err(e) => { + warnings.push(format!("{label}: {e}; skipped")); + skipped += 1; + continue; + } + }; + let start = (clock * f64::from(fps)).round() as u64; + clock += duration; + let end = ((clock * f64::from(fps)).round() as u64).max(start + 1); + let n = end - start; + let fade_frames = match &prev { + Some(_) => ((fade * f64::from(fps)).round() as u64).min(n), + None => 0, + }; + if let Some(a) = &prev { + for j in 0..fade_frames { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let k = (j + 1) as f32 / (fade_frames + 1) as f32; + emit(&mut mux, &mut enc, &blend(a, &img, k, fade_color), 1, false)?; + frames += 1; + progress(frames, total, &label); + } + } + if n > fade_frames { + // a held slide is a key frame: clean of the slide before it, and a place to seek to + emit(&mut mux, &mut enc, &img.data, n - fade_frames, true)?; + frames += n - fade_frames; + } + slides_done += 1; + prev = Some(img); + } + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + if slides_done == 0 { + return Err("no slide could be rendered".into()); + } + progress(frames, total, "AV1"); + for s in enc.finish().map_err(|e| e.to_string())? { + if let Some(c) = enc.config_record() { + mux.set_config(c); + } + keys += u64::from(s.key); + samples += 1; + mux.add(&s).map_err(|e| e.to_string())?; + } + let periods = mux.periods(); + let file = mux.finish().map_err(|e| e.to_string())?; + let file = file.into_inner().map_err(|e| format!("video: {}", e.error()))?; + file.sync_all().map_err(|e| format!("video: {e}"))?; + let bytes = file.metadata().map(|m| m.len()).unwrap_or(0); + drop(file); + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + part.commit(&self.guard)?; + progress(total, total, ""); + let mut out = self.summary(Format::Video, slides_done, skipped, warnings); + out["container"] = json!(container.id()); + out["codec"] = json!("av1"); + out["fps"] = json!(fps); + out["frames"] = json!(periods); + out["samples"] = json!(samples); + out["keyFrames"] = json!(keys); + out["seconds"] = json!(periods as f64 / f64::from(fps)); + out["bytes"] = json!(bytes); + out["audio"] = json!(false); + Ok(Outcome { json: out }) + } +} + +/// A slideshow export running in the background (`slideshow.export`; `slideshow.exportStatus`). +#[derive(Debug, Default)] +pub struct ExportRun { + /// Its row in the activity stack (`activity.cancel {id}` stops it). + pub task_id: u64, + pub format: String, + pub path: String, + pub total: std::sync::atomic::AtomicU64, + pub done: std::sync::atomic::AtomicU64, + pub cancel: Arc<AtomicBool>, + pub finished: AtomicBool, + /// The front end has told the user how it ended. + pub reported: AtomicBool, + pub result: std::sync::Mutex<Option<Result<Value, String>>>, +} + +impl ExportRun { + pub fn running(&self) -> bool { + !self.finished.load(Ordering::Relaxed) + } + + pub fn result(&self) -> Option<Result<Value, String>> { + self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() + } + + pub fn json(&self) -> Value { + let mut out = json!({ + "taskId": self.task_id, + "format": self.format, + "path": self.path, + "running": self.running(), + "done": self.done.load(Ordering::Relaxed), + "total": self.total.load(Ordering::Relaxed), + "cancelled": self.cancel.load(Ordering::Relaxed), + }); + match self.result() { + Some(Ok(v)) => out["result"] = v, + Some(Err(e)) => out["error"] = json!(e), + None => {} + } + out + } +} + +/// Run `prepared` to the end, recording progress and the outcome in `run` (and the row). +pub fn run_to_end(prepared: Prepared, run: &ExportRun, task: &crate::activity::TaskGuard) { + let handle = task.handle(); + let progress = |done: u64, total: u64, detail: &str| { + run.done.store(done, Ordering::Relaxed); + run.total.store(total, Ordering::Relaxed); + handle.progress(done, total); + handle.detail(detail); + }; + // a panic in a renderer or the encoder is an error, never a crash (the part file goes with it) + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepared.run(&progress, &run.cancel))) + .unwrap_or_else(|_| Err("slideshow export failed unexpectedly".into())) + .map(|o| o.json); + *run.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r); + run.finished.store(true, Ordering::Relaxed); +} diff --git a/crates/engine/src/tests_slideshow_export.rs b/crates/engine/src/tests_slideshow_export.rs new file mode 100644 index 000000000..766ae66f5 --- /dev/null +++ b/crates/engine/src/tests_slideshow_export.rs @@ -0,0 +1,334 @@ +//! `slideshow.export`: PDF, JPEG sequence and AV1 video of the slideshow, validation, the activity +//! row, cancel and photos that can't be rendered. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, Source}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ssexp-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } + fn files(&self, rel: &str) -> Vec<String> { + let mut v: Vec<String> = std::fs::read_dir(self.0.join(rel)) + .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect()) + .unwrap_or_default(); + v.sort(); + v + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A demo session playing 1 s slides with 0.5 s fades, its first `n` photos as ids. +fn session(n: usize) -> (Session, Vec<u64>) { + let mut s = Session::with_demo(); + s.execute("slideshow.playback", &json!({"slideDuration": 1.0, "fadeDuration": 0.5})).unwrap(); + let ids = s.catalog.photos().take(n).map(|p| p.id.0).collect(); + (s, ids) +} + +fn export(s: &mut Session, p: Value) -> Value { + let mut p = p; + p["wait"] = json!(true); + s.execute("slideshow.export", &p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +#[test] +fn every_parameter_is_checked() { + let (mut s, ids) = session(2); + for p in [ + json!({"path": "x.pdf"}), + json!({"format": "gif", "path": "x.gif"}), + json!({"format": "pdf"}), + json!({"format": "pdf", "path": " "}), + json!({"format": "pdf", "path": "x.pdf", "size": 10}), + json!({"format": "pdf", "path": "x.pdf", "size": 100_000}), + json!({"format": "pdf", "path": "x.pdf", "size": "big"}), + json!({"format": "pdf", "path": "x.pdf", "size": -5}), + json!({"format": "video", "path": "x.mp4", "size": 4320}), + json!({"format": "video", "path": "x.mp4", "size": "8kp"}), + json!({"format": "pdf", "path": "x.pdf", "quality": 0}), + json!({"format": "pdf", "path": "x.pdf", "quality": 101}), + json!({"format": "video", "path": "x.mp4", "fps": 29}), + json!({"format": "video", "path": "x.mp4", "fps": 30.5}), + json!({"format": "video", "path": "x.avi", "container": "avi"}), + json!({"format": "video", "path": "x.mp4", "speed": 11}), + json!({"format": "pdf", "path": "x.pdf", "fullScreen": "yes"}), + json!({"format": "pdf", "path": "x.pdf", "name": "a"}), + json!({"format": "jpeg", "path": "out", "name": "../up"}), + json!({"format": "jpeg", "path": "out", "name": ""}), + json!({"format": "pdf", "path": "x.pdf", "ids": "all"}), + json!({"format": "pdf", "path": "x.pdf", "ids": ["one"]}), + json!({"format": "pdf", "path": "x.pdf", "ids": []}), + json!({"format": "pdf", "path": "x.pdf", "ids": [u64::MAX]}), + json!({"format": "pdf", "path": "x.pdf", "typo": 1}), + json!({"format": "pdf", "path": "x.pdf", "wait": "now"}), + json!("pdf"), + ] { + let e = s.execute("slideshow.export", &p).err().unwrap_or_else(|| panic!("accepted {p}")); + assert!(!e.to_string().is_empty()); + } + assert!(s.activity.list().is_empty(), "nothing started"); + assert!(s.slideshow_export.is_none()); + // the photos in view: none + s.execute("library.filter", &json!({"text": "zzz no photo is called this"})).unwrap(); + assert!(s.visible_cloned().is_empty()); + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": "x.pdf"})).unwrap_err(); + assert!(e.to_string().contains("no photos in view"), "{e}"); + // in view: exported without ids + s.execute("library.filter", &json!({"text": ""})).unwrap(); + assert!(!s.visible_cloned().is_empty() && !ids.is_empty()); +} + +/// A minimal PDF reader: the cross-reference table points at every object, and the pages. +fn pdf_pages(bytes: &[u8]) -> Vec<String> { + let text = String::from_utf8_lossy(bytes); + let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap(); + assert!(bytes[xref..].starts_with(b"xref\n0 ")); + let table = std::str::from_utf8(&bytes[xref..]).unwrap(); + let count: usize = table.lines().nth(1).unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); + for (id, line) in table.lines().skip(3).take(count - 1).enumerate() { + let offset: usize = line.split_whitespace().next().unwrap().parse().unwrap(); + assert!(bytes[offset..].starts_with(format!("{} 0 obj\n", id + 1).as_bytes()), "object {} misplaced", id + 1); + } + text.split(" 0 obj\n").filter(|o| o.starts_with("<< /Type /Page ")).map(|o| o.split("\nendobj").next().unwrap().to_string()).collect() +} + +/// The JPEGs embedded in a PDF, decoded. +fn pdf_images(bytes: &[u8]) -> Vec<lightcraft_raster::Rgba8> { + let mut out = Vec::new(); + let mut at = 0; + while let Some(start) = bytes[at..].windows(3).position(|v| v == [0xff, 0xd8, 0xff]).map(|p| p + at) { + let end = bytes[start..].windows(10).position(|v| v == b"\nendstream").unwrap() + start; + out.push(lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()); + at = end; + } + out +} + +#[test] +fn pdf_has_a_page_per_slide_titles_included() { + let dir = Scratch::new("pdf"); + let (mut s, ids) = session(3); + s.execute("slideshow.titles", &json!({"intro": {"enabled": true, "content": "text", "text": "Hello"}, "ending": {"enabled": true}})).unwrap(); + let path = dir.path("Show.pdf"); + let r = export(&mut s, json!({"format": "pdf", "path": path, "size": 160, "quality": 80, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["slides"].as_u64(), r["width"].as_u64(), r["height"].as_u64()), (Some(5), Some(5), Some(160), Some(90))); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + let pages = pdf_pages(&bytes); + assert_eq!(pages.len(), 5); + assert!(pages.iter().all(|p| p.contains("/MediaBox [0 0 160.00 90.00]") && p.contains("/Dur 1.00")), "{pages:?}"); + assert!(!pages[0].contains("/Trans"), "nothing fades into the first slide"); + assert!(pages[1].contains("/Trans << /S /Fade /D 0.50 >>")); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Count 5 ") && text.contains("/PageMode /FullScreen")); + let images = pdf_images(&bytes); + assert_eq!(images.len(), 5); + assert!(images.iter().all(|i| (i.width, i.height) == (160, 90))); + // the intro (black with white text) and a photo slide differ + let mean = |i: &lightcraft_raster::Rgba8| i.data.iter().map(|p| u64::from(p[1])).sum::<u64>() / i.data.len() as u64; + assert!(mean(&images[0]) < mean(&images[1]), "the intro screen is the first page"); + assert!(dir.files("").iter().all(|f| !f.ends_with(".part"))); + // no full screen; a 4:3 aspect makes 4:3 pages + s.execute("slideshow.layout", &json!({"aspect": "4:3"})).unwrap(); + let r = export(&mut s, json!({"format": "pdf", "path": path, "size": 120, "fullScreen": false, "ids": ids})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(120), Some(90))); + assert!(!String::from_utf8_lossy(&std::fs::read(&path).unwrap()).contains("/FullScreen")); +} + +#[test] +fn jpeg_sequence_is_numbered_in_a_folder() { + let dir = Scratch::new("jpeg"); + let (mut s, ids) = session(3); + let out = dir.path("Trip"); + let r = export(&mut s, json!({"format": "jpeg", "path": out, "size": 128, "ids": ids})); + assert_eq!(dir.files("Trip"), vec!["Trip-001.jpg", "Trip-002.jpg", "Trip-003.jpg"]); + assert_eq!(r["files"].as_array().unwrap().len(), 3); + let img = lightcraft_codecs::decode(&std::fs::read(Path::new(&out).join("Trip-002.jpg")).unwrap(), Default::default()).unwrap().to_srgb8(); + assert_eq!((img.width, img.height), (128, 72)); + // a name of its own, and a portrait-free square check: the long side is the size + let r = export(&mut s, json!({"format": "jpeg", "path": out, "size": 64, "name": "Slide", "ids": [ids[0]]})); + assert_eq!(r["files"][0].as_str().unwrap(), Path::new(&out).join("Slide-001.jpg").to_string_lossy()); +} + +/// MP4 boxes of a byte range: (type, body range). +fn boxes(d: &[u8], r: std::ops::Range<usize>) -> Vec<([u8; 4], std::ops::Range<usize>)> { + let mut out = Vec::new(); + let mut p = r.start; + while p + 8 <= r.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= r.end); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, r.end); + out +} + +fn child(d: &[u8], r: std::ops::Range<usize>, path: &[&[u8; 4]]) -> std::ops::Range<usize> { + path.iter().fold(r, |r, k| boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap().1) +} + +#[test] +fn video_is_av1_in_mp4_with_fades_as_frames() { + let dir = Scratch::new("mp4"); + let (mut s, ids) = session(2); + let path = dir.path("show.mp4"); + let r = export(&mut s, json!({"format": "video", "path": path, "size": 64, "fps": 24, "quality": 40, "ids": ids})); + // 2 slides × 1 s at 24 fps; the second fades in over 12 frames + assert_eq!((r["frames"].as_u64(), r["seconds"].as_f64(), r["width"].as_u64(), r["height"].as_u64()), (Some(48), Some(2.0), Some(114), Some(64))); + assert_eq!(r["samples"].as_u64(), Some(1 + 12 + 1), "held slides are one sample each, fades a frame each"); + assert_eq!((r["codec"].as_str(), r["audio"].as_bool(), r["container"].as_str()), (Some("av1"), Some(false), Some("mp4"))); + let d = std::fs::read(&path).unwrap(); + let top: Vec<_> = boxes(&d, 0..d.len()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(top, vec![*b"ftyp", *b"mdat", *b"moov"]); + let stbl = child(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let be = |at: usize| u32::from_be_bytes(d[at..at + 4].try_into().unwrap()); + let stsz = child(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 14, "sample count"); + let stts = child(&d, stbl.clone(), &[b"stts"]); + let runs = be(stts.start + 4) as usize; + let ticks: u32 = (0..runs).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + let mdhd = child(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!(ticks / be(mdhd.start + 12), 2, "duration: 2 s"); + let stsd = child(&d, stbl, &[b"stsd"]); + let entry = &boxes(&d, stsd.start + 8..stsd.end)[0]; + assert_eq!(&entry.0, b"av01"); + let inner = boxes(&d, entry.1.start + 78..entry.1.end); + let av1c = &inner.iter().find(|(k, _)| k == b"av1C").unwrap().1; + assert_eq!(d[av1c.start], 0x81, "av1C marker and version"); + assert_eq!((d[av1c.start + 4] >> 3) & 0x0f, 1, "the sequence header OBU follows"); + assert!(dir.files("").iter().all(|f| !f.ends_with(".part"))); +} + +#[test] +fn video_in_webm_with_a_fade_colour() { + let dir = Scratch::new("webm"); + let (mut s, ids) = session(2); + s.execute("slideshow.playback", &json!({"fadeColor": "#ffffff"})).unwrap(); + let path = dir.path("show.webm"); + let r = export(&mut s, json!({"format": "video", "path": path, "size": "64p", "fps": 25, "container": "webm", "ids": ids})); + assert_eq!((r["frames"].as_u64(), r["container"].as_str()), (Some(50), Some("webm"))); + let d = std::fs::read(&path).unwrap(); + assert!(d.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]), "EBML header"); + assert!(d.windows(4).any(|w| w == b"webm") && d.windows(5).any(|w| w == b"V_AV1")); + // Segment: the 8-byte size spans the rest of the file + let seg = d.windows(4).position(|w| w == [0x18, 0x53, 0x80, 0x67]).unwrap(); + let size = u64::from_be_bytes(d[seg + 4..seg + 12].try_into().unwrap()) & 0x00ff_ffff_ffff_ffff; + assert_eq!(seg + 12 + size as usize, d.len()); +} + +fn wait_for(s: &Session) -> Value { + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let run = s.slideshow_export.clone().unwrap(); + if !run.running() { + return run.json(); + } + assert!(Instant::now() < deadline, "export did not finish"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn background_export_has_an_activity_row_and_reports_its_result() { + let dir = Scratch::new("bg"); + let (mut s, ids) = session(4); + let path = dir.path("bg.pdf"); + let r = s.execute("slideshow.export", &json!({"format": "pdf", "path": path, "size": 96, "ids": ids})).unwrap(); + assert_eq!((r["background"].as_bool(), r["slides"].as_u64(), r["total"].as_u64()), (Some(true), Some(4), Some(4))); + let rows = s.activity.list(); + let row = rows.iter().find(|t| t.id == r["taskId"].as_u64().unwrap()).expect("a row in the activity stack"); + assert_eq!((row.kind, row.label.as_str(), row.total), ("export", "Exporting slideshow", 4)); + assert!(row.cancellable); + assert!(s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("other.pdf"), "ids": ids})).is_err(), "one at a time"); + let status = wait_for(&s); + assert_eq!(status["result"]["pages"], 4, "{status}"); + assert_eq!(status["done"], 4); + assert!(s.activity.list().is_empty(), "the row goes when the export ends"); + assert_eq!(s.execute("slideshow.exportStatus", &json!({})).unwrap()["result"]["path"], json!(path)); + assert!(Path::new(&path).exists()); +} + +#[test] +fn cancel_leaves_no_file_behind() { + let dir = Scratch::new("cancel"); + for (format, name, extra) in [("pdf", "c.pdf", json!({})), ("jpeg", "seq", json!({})), ("video", "c.mp4", json!({"size": 64}))] { + let (mut s, ids) = session(12); + let path = dir.path(name); + let mut p = json!({"format": format, "path": path, "ids": ids}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let r = s.execute("slideshow.export", &p).unwrap(); + s.execute("activity.cancel", &json!({"id": r["taskId"]})).unwrap(); + let status = wait_for(&s); + assert!(status["error"].as_str().is_some_and(|e| e.contains("cancelled")), "{format}: {status}"); + assert!(status["cancelled"].as_bool().unwrap()); + assert!(!Path::new(&path).is_file(), "{format}: no output"); + let left: Vec<_> = dir.files("").into_iter().chain(dir.files("seq")).filter(|f| f.ends_with(".part") || f.ends_with(".jpg")).collect(); + assert!(left.is_empty(), "{format}: {left:?}"); + assert!(s.activity.list().is_empty()); + } +} + +#[test] +fn a_photo_that_cannot_render_is_skipped_with_a_warning() { + let dir = Scratch::new("skip"); + let (mut s, mut ids) = session(2); + let id = s.catalog.alloc_photo_id(); + let gone = dir.path("gone.jpg"); + s.catalog + .apply(Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: gone }, "gone.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00")) }) + .unwrap(); + ids.insert(1, id.0); + let r = export(&mut s, json!({"format": "pdf", "path": dir.path("s.pdf"), "size": 64, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["skipped"].as_u64()), (Some(2), Some(1))); + assert!(r["warnings"][0].as_str().unwrap().contains("gone.jpg"), "{r}"); + // video: the skipped slide takes no time + let r = export(&mut s, json!({"format": "video", "path": dir.path("s.mp4"), "size": 64, "fps": 24, "ids": ids})); + assert_eq!((r["frames"].as_u64(), r["skipped"].as_u64()), (Some(48), Some(1))); + // only the broken photo: nothing to write, and nothing written + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("none.pdf"), "ids": [id.0], "wait": true})).unwrap_err(); + assert!(e.to_string().contains("no slide"), "{e}"); + assert!(dir.files("").iter().all(|f| f != "none.pdf" && !f.ends_with(".part"))); +} + +#[test] +fn unwritable_targets_and_long_videos_are_errors() { + let dir = Scratch::new("bad"); + let (mut s, ids) = session(2); + // a folder where the file should go + std::fs::create_dir_all(dir.path("taken.pdf")).unwrap(); + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("taken.pdf"), "size": 64, "ids": ids, "wait": true})); + assert!(e.is_err()); + // a file where the folder should go + std::fs::write(dir.path("file"), b"x").unwrap(); + let e = s.execute("slideshow.export", &json!({"format": "jpeg", "path": dir.path("file"), "size": 64, "ids": ids, "wait": true})); + assert!(e.is_err()); + assert!(s.activity.list().is_empty()); + // more than 2 hours of video: 120 s slides × 61 + s.execute("slideshow.playback", &json!({"slideDuration": 120})).unwrap(); + let many: Vec<u64> = std::iter::repeat_n(ids[0], 61).collect(); + let e = s.execute("slideshow.export", &json!({"format": "video", "path": dir.path("long.mp4"), "ids": many})).unwrap_err(); + assert!(e.to_string().contains("limited to 120 min"), "{e}"); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ed7549c32..eb57dafd0 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} importiert", "Skipped {problem}": "Übersprungen: {problem}", "…and {more} more": "…und {more} weitere", - "Template preview: {name}": "Vorlagenvorschau: {name}" + "Template preview: {name}": "Vorlagenvorschau: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Folien · {time}", + "{n} pages": "{n} Seiten", + "{n} JPEG files": "{n} JPEG-Dateien", + "{s} s of AV1 video": "{s} s AV1-Video", + "Slideshow exported: {what}": "Diashow exportiert: {what}", + " · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 78b215d7e..22cde84e3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2117,5 +2117,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto.", + "Exporting slideshow": "Diashow wird exportiert", + "Export Slideshow…": "Diashow exportieren…", + "Export Slideshow": "Diashow exportieren", + "Export Slideshow: folder for the JPEG files": "Diashow exportieren: Ordner für die JPEG-Dateien", + "Frame rate": "Bildrate", + "Container": "Container", + "Export PDF…": "PDF exportieren…", + "Export JPEG…": "JPEG exportieren…", + "Export Video…": "Video exportieren…", + "Automatically show full screen": "Automatisch im Vollbild anzeigen", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Folie (lange Seite in Pixeln = Punkte), mit Foliendauer und Überblendung für den Präsentationsmodus des Betrachters.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 167288981..62fde3ad1 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "Se importó {name}", "Skipped {problem}": "Omitido: {problem}", "…and {more} more": "…y {more} más", - "Template preview: {name}": "Vista previa de la plantilla: {name}" + "Template preview: {name}": "Vista previa de la plantilla: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} fotos en la vista · {slides} diapositivas · {time}", + "{n} pages": "{n} páginas", + "{n} JPEG files": "{n} archivos JPEG", + "{s} s of AV1 video": "{s} s de vídeo AV1", + "Slideshow exported: {what}": "Presentación exportada: {what}", + " · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e826a8393..439348347 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una pared gris claro con paspartú blanco y sombra suave; el pie de foto y la valoración debajo de la foto.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para llenar toda la pantalla, con fundidos cruzados lentos.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un encuadre 16:9 con franjas negras arriba y abajo, el título en la esquina, fundidos a negro y un cartel final.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fondo pizarra oscuro con la exposición, el ISO y la distancia focal, y luego la cámara y el objetivo, bajo cada foto." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fondo pizarra oscuro con la exposición, el ISO y la distancia focal, y luego la cámara y el objetivo, bajo cada foto.", + "Exporting slideshow": "Exportando presentación", + "Export Slideshow…": "Exportar presentación…", + "Export Slideshow": "Exportar presentación", + "Export Slideshow: folder for the JPEG files": "Exportar presentación: carpeta para los archivos JPEG", + "Frame rate": "Fotogramas por segundo", + "Container": "Contenedor", + "Export PDF…": "Exportar PDF…", + "Export JPEG…": "Exportar JPEG…", + "Export Video…": "Exportar vídeo…", + "Automatically show full screen": "Mostrar automáticamente a pantalla completa", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Una página por diapositiva (el lado largo en píxeles = puntos), con la duración y el fundido de la diapositiva para el modo de presentación del visor.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Archivos JPEG numerados (nombre-001.jpg…) en una carpeta nueva cuyo nombre se elige en el paso siguiente.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada diapositiva durante su duración con sus fundidos. Sin sonido: la música aún no se exporta.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Las diapositivas usan el diseño, las superposiciones y las pantallas de inicio y final de la presentación, en el orden de la vista.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Las fotos de la vista como diapositivas, con los ajustes de arriba. El progreso aparece en la pila de actividad." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index f5c7823fa..9f268e446 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} importé", "Skipped {problem}": "Ignoré : {problem}", "…and {more} more": "…et {more} de plus", - "Template preview: {name}": "Aperçu du modèle : {name}" + "Template preview: {name}": "Aperçu du modèle : {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos affichées · {slides} diapositives · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} fichiers JPEG", + "{s} s of AV1 video": "{s} s de vidéo AV1", + "Slideshow exported: {what}": "Diaporama exporté : {what}", + " · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index dece0397c..08bd10e8f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Un mur gris clair avec un passe-partout blanc et une ombre douce ; la légende et la note sous la photo.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Chaque photo recadrée pour remplir tout l'écran, avec des fondus enchaînés lents.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un cadre 16:9 avec des bandes noires en haut et en bas, le titre dans le coin, des fondus au noir et un carton de fin.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fond ardoise sombre avec l'exposition, l'ISO et la focale, puis le boîtier et l'objectif, sous chaque photo." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fond ardoise sombre avec l'exposition, l'ISO et la focale, puis le boîtier et l'objectif, sous chaque photo.", + "Exporting slideshow": "Exportation du diaporama", + "Export Slideshow…": "Exporter le diaporama…", + "Export Slideshow": "Exporter le diaporama", + "Export Slideshow: folder for the JPEG files": "Exporter le diaporama : dossier des fichiers JPEG", + "Frame rate": "Images par seconde", + "Container": "Conteneur", + "Export PDF…": "Exporter en PDF…", + "Export JPEG…": "Exporter en JPEG…", + "Export Video…": "Exporter en vidéo…", + "Automatically show full screen": "Afficher automatiquement en plein écran", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Une page par diapositive (le grand côté en pixels = points), avec la durée et le fondu de la diapositive pour le mode présentation du lecteur.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Fichiers JPEG numérotés (nom-001.jpg…) dans un nouveau dossier nommé à l'étape suivante.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vidéo AV1, chaque diapositive affichée pendant sa durée avec ses fondus. Sans son : la musique n'est pas encore exportée.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Les diapositives utilisent la mise en page, les incrustations et les écrans d'ouverture et de fin du diaporama, dans l'ordre de l'affichage.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Les photos affichées en diapositives, avec les réglages ci-dessus. La progression s'affiche dans la pile d'activités." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e40560762..df2d59b5a 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} を読み込みました", "Skipped {problem}": "スキップ: {problem}", "…and {more} more": "…ほか {more} 件", - "Template preview: {name}": "テンプレートのプレビュー: {name}" + "Template preview: {name}": "テンプレートのプレビュー: {name}", + "{n} photos in view · {slides} slides · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}", + "{n} pages": "{n} ページ", + "{n} JPEG files": "JPEG ファイル {n} 個", + "{s} s of AV1 video": "AV1 ビデオ {s} 秒", + "Slideshow exported: {what}": "スライドショーを書き出しました:{what}", + " · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 62ce87d27..9305d980b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1964,5 +1964,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "明るいグレーの壁に白いマットと柔らかな影。写真の下にキャプションとレーティング。", "Every photo cropped to fill the whole screen, with slow crossfades.": "各写真を画面全体に合わせて切り抜き、ゆっくりクロスフェードします。", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "上下に黒帯のある16:9の画面、隅にタイトル、黒を経由するフェード、エンディングカード。", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "暗いスレート色の背景。各写真の下に露出・ISO・焦点距離、その下にカメラとレンズ。" + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "暗いスレート色の背景。各写真の下に露出・ISO・焦点距離、その下にカメラとレンズ。", + "Exporting slideshow": "スライドショーを書き出し中", + "Export Slideshow…": "スライドショーを書き出し…", + "Export Slideshow": "スライドショーを書き出し", + "Export Slideshow: folder for the JPEG files": "スライドショーを書き出し:JPEG ファイルのフォルダー", + "Frame rate": "フレームレート", + "Container": "コンテナー", + "Export PDF…": "PDF を書き出し…", + "Export JPEG…": "JPEG を書き出し…", + "Export Video…": "ビデオを書き出し…", + "Automatically show full screen": "自動的にフルスクリーンで表示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "スライドごとに 1 ページ(長辺のピクセル数 = ポイント数)。ビューアーのプレゼンテーションモード用にスライドの表示時間とフェードを含みます。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "番号付きの JPEG ファイル(name-001.jpg…)を、次の手順で名前を付ける新しいフォルダーに書き出します。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 ビデオ。各スライドを表示時間のあいだフェード付きで表示します。音声なし:音楽はまだ書き出されません。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "スライドにはスライドショーのレイアウト、オーバーレイ、イントロ画面とエンディング画面が表示順で使われます。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "表示中の写真を上の設定でスライドにします。進行状況はアクティビティ一覧に表示されます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 6b5b32988..330a6473b 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "{name} importado", "Skipped {problem}": "Ignorado: {problem}", "…and {more} more": "…e mais {more}", - "Template preview: {name}": "Prévia do modelo: {name}" + "Template preview: {name}": "Prévia do modelo: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} fotos na visualização · {slides} slides · {time}", + "{n} pages": "{n} páginas", + "{n} JPEG files": "{n} arquivos JPEG", + "{s} s of AV1 video": "{s} s de vídeo AV1", + "Slideshow exported: {what}": "Apresentação exportada: {what}", + " · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9329b2d23..a82853734 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Uma parede cinza-clara com passe-partout branco e sombra suave; a legenda e a classificação abaixo da foto.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para preencher a tela inteira, com transições lentas.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Um quadro 16:9 com faixas pretas acima e abaixo, o título no canto, transições pelo preto e uma cartela final.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Um fundo ardósia escuro com a exposição, ISO e distância focal, e depois a câmera e a lente, abaixo de cada foto." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Um fundo ardósia escuro com a exposição, ISO e distância focal, e depois a câmera e a lente, abaixo de cada foto.", + "Exporting slideshow": "Exportando apresentação", + "Export Slideshow…": "Exportar apresentação…", + "Export Slideshow": "Exportar apresentação", + "Export Slideshow: folder for the JPEG files": "Exportar apresentação: pasta para os arquivos JPEG", + "Frame rate": "Taxa de quadros", + "Container": "Contêiner", + "Export PDF…": "Exportar PDF…", + "Export JPEG…": "Exportar JPEG…", + "Export Video…": "Exportar vídeo…", + "Automatically show full screen": "Mostrar automaticamente em tela cheia", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Uma página por slide (o lado maior em pixels = pontos), com a duração e o esmaecimento do slide para o modo de apresentação do visualizador.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Arquivos JPEG numerados (nome-001.jpg…) em uma pasta nova cujo nome é escolhido na próxima etapa.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada slide exibido pela sua duração com seus esmaecimentos. Sem som: a música ainda não é exportada.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Os slides usam o layout, as sobreposições e as telas de abertura e encerramento da apresentação, na ordem da visualização.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "As fotos da visualização como slides, com os ajustes acima. O progresso aparece na pilha de atividades." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 177857dc4..a073dd0e2 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "Импортировано: {name}", "Skipped {problem}": "Пропущено: {problem}", "…and {more} more": "…и ещё {more}", - "Template preview: {name}": "Просмотр шаблона: {name}" + "Template preview: {name}": "Просмотр шаблона: {name}", + "{n} photos in view · {slides} slides · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}", + "{n} pages": "Страниц: {n}", + "{n} JPEG files": "Файлов JPEG: {n}", + "{s} s of AV1 video": "{s} с видео AV1", + "Slideshow exported: {what}": "Слайд-шоу экспортировано: {what}", + " · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2a0b609eb..870089cd7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1678,5 +1678,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Светло-серая стена с белым паспарту и мягкой тенью; подпись и рейтинг под фото.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Каждое фото обрезано на весь экран, с медленной сменой наплывом.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 с чёрными полосами сверху и снизу, название в углу, затемнения через чёрный и финальная карточка.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Тёмный грифельный фон с экспозицией, ISO и фокусным расстоянием, а ниже камерой и объективом под каждым фото." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Тёмный грифельный фон с экспозицией, ISO и фокусным расстоянием, а ниже камерой и объективом под каждым фото.", + "Exporting slideshow": "Экспорт слайд-шоу", + "Export Slideshow…": "Экспортировать слайд-шоу…", + "Export Slideshow": "Экспорт слайд-шоу", + "Export Slideshow: folder for the JPEG files": "Экспорт слайд-шоу: папка для файлов JPEG", + "Frame rate": "Частота кадров", + "Container": "Контейнер", + "Export PDF…": "Экспорт в PDF…", + "Export JPEG…": "Экспорт в JPEG…", + "Export Video…": "Экспорт видео…", + "Automatically show full screen": "Автоматически открывать во весь экран", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Одна страница на слайд (длинная сторона в пикселях = пунктах), с длительностью и переходом слайда для режима презентации в просмотрщике.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумерованные файлы JPEG (имя-001.jpg…) в новой папке, имя которой задаётся на следующем шаге.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Видео AV1: каждый слайд показывается свою длительность, с переходами. Без звука: музыка пока не экспортируется.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайды используют макет, наложения, вступительный и финальный экраны слайд-шоу, в порядке просмотра.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографии в просмотре как слайды, с настройками выше. Ход выполнения виден в списке задач." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index d334f2649..578f3466f 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "Імпортовано: {name}", "Skipped {problem}": "Пропущено: {problem}", "…and {more} more": "…і ще {more}", - "Template preview: {name}": "Перегляд шаблону: {name}" + "Template preview: {name}": "Перегляд шаблону: {name}", + "{n} photos in view · {slides} slides · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}", + "{n} pages": "Сторінок: {n}", + "{n} JPEG files": "Файлів JPEG: {n}", + "{s} s of AV1 video": "{s} с відео AV1", + "Slideshow exported: {what}": "Слайд-шоу експортовано: {what}", + " · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index e7b3687bf..28b7cbf48 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2156,5 +2156,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Світло-сіра стіна з білим паспарту та м'якою тінню; підпис і рейтинг під фото.", "Every photo cropped to fill the whole screen, with slow crossfades.": "Кожне фото обрізане на весь екран, з повільною зміною накладанням.", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 з чорними смугами згори й знизу, назва в куті, затемнення через чорний і фінальна картка.", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Темне грифельне тло з експозицією, ISO та фокусною відстанню, а нижче камерою й об'єктивом під кожним фото." + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Темне грифельне тло з експозицією, ISO та фокусною відстанню, а нижче камерою й об'єктивом під кожним фото.", + "Exporting slideshow": "Експорт слайд-шоу", + "Export Slideshow…": "Експортувати слайд-шоу…", + "Export Slideshow": "Експорт слайд-шоу", + "Export Slideshow: folder for the JPEG files": "Експорт слайд-шоу: тека для файлів JPEG", + "Frame rate": "Частота кадрів", + "Container": "Контейнер", + "Export PDF…": "Експорт у PDF…", + "Export JPEG…": "Експорт у JPEG…", + "Export Video…": "Експорт відео…", + "Automatically show full screen": "Автоматично відкривати на весь екран", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Одна сторінка на слайд (довга сторона в пікселях = пунктах), з тривалістю та переходом слайда для режиму презентації в переглядачі.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумеровані файли JPEG (назва-001.jpg…) у новій теці, назву якої задаєте на наступному кроці.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Відео AV1: кожен слайд показується свою тривалість, з переходами. Без звуку: музика поки не експортується.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайди використовують макет, накладання, вступний і фінальний екрани слайд-шоу, у порядку перегляду.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографії в перегляді як слайди, з налаштуваннями вище. Поступ видно у списку завдань." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 0d6aa11ef..06d722647 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "已导入 {name}", "Skipped {problem}": "已跳过:{problem}", "…and {more} more": "…还有 {more} 个", - "Template preview: {name}": "模板预览:{name}" + "Template preview: {name}": "模板预览:{name}", + "{n} photos in view · {slides} slides · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}", + "{n} pages": "{n} 页", + "{n} JPEG files": "{n} 个 JPEG 文件", + "{s} s of AV1 video": "{s} 秒 AV1 视频", + "Slideshow exported: {what}": "幻灯片放映已导出:{what}", + " · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0780ecb42..78afa9aa5 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "浅灰色墙面、白色卡纸和柔和阴影;照片下方显示说明和星级。", "Every photo cropped to fill the whole screen, with slow crossfades.": "每张照片裁剪以填满整个屏幕,带缓慢交叉淡化。", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 画面,上下黑边,标题在角落,经黑色淡入淡出,并有结尾卡。", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每张照片下方显示曝光、ISO 和焦距,再下面是相机和镜头。" + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每张照片下方显示曝光、ISO 和焦距,再下面是相机和镜头。", + "Exporting slideshow": "正在导出幻灯片放映", + "Export Slideshow…": "导出幻灯片放映…", + "Export Slideshow": "导出幻灯片放映", + "Export Slideshow: folder for the JPEG files": "导出幻灯片放映:JPEG 文件所在的文件夹", + "Frame rate": "帧率", + "Container": "容器格式", + "Export PDF…": "导出 PDF…", + "Export JPEG…": "导出 JPEG…", + "Export Video…": "导出视频…", + "Automatically show full screen": "自动全屏显示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "每张幻灯片一页(长边像素数 = 点数),并带有幻灯片时长和淡化效果,供查看器的演示模式使用。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "带编号的 JPEG 文件(name-001.jpg…),放在下一步命名的新文件夹中。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 视频,每张幻灯片按其时长显示并带有淡化效果。无声音:暂不导出音乐。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻灯片使用幻灯片放映的版面、叠加以及片头和片尾画面,按视图顺序排列。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "将视图中的照片按上方设置制作为幻灯片。进度显示在活动列表中。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index e3e30a19b..5515cc6ea 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -237,5 +237,11 @@ "Imported {name}": "已匯入 {name}", "Skipped {problem}": "已略過:{problem}", "…and {more} more": "…還有 {more} 個", - "Template preview: {name}": "範本預覽:{name}" + "Template preview: {name}": "範本預覽:{name}", + "{n} photos in view · {slides} slides · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}", + "{n} pages": "{n} 頁", + "{n} JPEG files": "{n} 個 JPEG 檔案", + "{s} s of AV1 video": "{s} 秒 AV1 視訊", + "Slideshow exported: {what}": "幻燈片播放已匯出:{what}", + " · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 66e802ef9..7b1286991 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1962,5 +1962,20 @@ "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "淺灰色牆面、白色卡紙和柔和陰影;照片下方顯示說明和星級。", "Every photo cropped to fill the whole screen, with slow crossfades.": "每張照片裁切以填滿整個螢幕,帶緩慢交叉淡化。", "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 畫面,上下黑邊,標題在角落,經黑色淡入淡出,並有結尾卡。", - "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每張照片下方顯示曝光、ISO 和焦距,再下面是相機和鏡頭。" + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每張照片下方顯示曝光、ISO 和焦距,再下面是相機和鏡頭。", + "Exporting slideshow": "正在匯出幻燈片播放", + "Export Slideshow…": "匯出幻燈片播放…", + "Export Slideshow": "匯出幻燈片播放", + "Export Slideshow: folder for the JPEG files": "匯出幻燈片播放:JPEG 檔案所在的資料夾", + "Frame rate": "影格速率", + "Container": "容器格式", + "Export PDF…": "匯出 PDF…", + "Export JPEG…": "匯出 JPEG…", + "Export Video…": "匯出視訊…", + "Automatically show full screen": "自動全螢幕顯示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "每張幻燈片一頁(長邊像素數 = 點數),並附有幻燈片時長與淡化效果,供檢視器的簡報模式使用。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "編號的 JPEG 檔案(name-001.jpg…),放在下一步命名的新資料夾中。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 視訊,每張幻燈片依其時長顯示並附有淡化效果。無聲音:尚未匯出音樂。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻燈片使用幻燈片播放的版面、疊加以及片頭與片尾畫面,依檢視順序排列。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "將檢視中的照片依上方設定製作為幻燈片。進度顯示在活動清單中。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fec622092..13d0da5ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod pick; pub mod region; pub mod render; pub mod shortcuts; +pub mod slideshow_export; pub mod softpaint; pub mod state; pub mod sync; @@ -313,6 +314,8 @@ pub struct LightcraftApp { pub tasks: tasks::Tasks, /// The files of the last finished background export (`ui.inspect` → `export.last`). pub last_export_result: Option<Value>, + /// The last slideshow export's end has been told (a toast). + pub slideshow_export_reported: bool, /// The look the loupe shows while the pointer rests on a preset or profile (set by the /// panels each frame; nothing is committed, no history entry). pub hover_preview: Option<HoverPreview>, @@ -389,6 +392,7 @@ impl LightcraftApp { export: None, tasks: Default::default(), last_export_result: None, + slideshow_export_reported: true, hover_preview: None, window_is_fullscreen: false, gpu_applied: None, @@ -1036,6 +1040,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); export_task::poll(self, &ctx); + slideshow_export::poll(self, &ctx); panels::activity::show(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f93f7ad66..731ab2fb1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -141,6 +141,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.photoCounts", "Show Photo Counts", None, "View"), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), ("view.slideshowOptions", "Slideshow Options…", None, "View"), + ("dialog.slideshowExport", "Export Slideshow…", None, "View"), + ("app.slideshowExport", "Export Slideshow", None, ""), // the slideshow's playback controls (Space, ←/→ and Esc while it plays) ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), ("slideshow.next", "Next Slide", None, ""), @@ -982,6 +984,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.dialog = Some(Dialog::CopySettings { groups }); Ok(Value::Null) } + "dialog.slideshowExport" => { + let format = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, + Some("video") => lightcraft_engine::slideshow_export::Format::Video, + _ => lightcraft_engine::slideshow_export::Format::Pdf, + }; + app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); + Ok(Value::Null) + } + "app.slideshowExport" => crate::slideshow_export::start(app, id, p), "dialog.contactSheet" => { app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); Ok(Value::Null) @@ -1602,6 +1614,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { match id { "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "dialog.slideshowExport" | "app.slideshowExport" => { + !cfg!(target_arch = "wasm32") && !app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) + } s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { app.session.active().is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..344ed50cc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -109,6 +109,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", + Dialog::SlideshowExport { .. } => "Export Slideshow", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", @@ -477,6 +478,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); group_checklist(ui, "pasteGroup", groups); } + Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), Dialog::ContactSheet { options } => { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); @@ -976,6 +978,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label.as_str() } Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::SlideshowExport { .. } => "Export…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", @@ -1040,6 +1043,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } | Dialog::Export { .. } | Dialog::ContactSheet { .. } + | Dialog::SlideshowExport { .. } | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1359,6 +1363,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso }), ), Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), + Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { @@ -1524,6 +1529,60 @@ fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { } /// A labelled row (fixed label column). +/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes +/// after (a save dialog). +fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { + use lightcraft_engine::slideshow_export::{ExportSettings, Format}; + let t = Tokens::get(ui.ctx()); + let photos = app.session.visible_cloned().len(); + let slide = app.session.slideshow.slide_duration; + let (intro, ending) = app.session.slide_titles.durations(slide); + let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); + let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); + let summary = crate::i18n::tr_format!( + "{n} photos in view · {slides} slides · {time}", + n = photos, + slides = photos + titles, + time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) + ); + ui.label(egui::RichText::new(summary).color(t.text_dim)); + let before = settings.format; + choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); + if settings.format != before { + *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; + } + match settings.format { + Format::Video => { + choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); + choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); + let mut webm = settings.container == "webm"; + choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); + settings.container = if webm { "webm".into() } else { "mp4".into() }; + } + _ => { + choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); + } + } + field(ui, "Quality", |ui| { + let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); + crate::widgets::register(ui.ctx(), "slider:slideshowExportQuality", r.rect); + }); + let note = match settings.format { + Format::Pdf => { + let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); + crate::widgets::register(ui.ctx(), "check:slideshowExportFullScreen", r.rect); + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." + } + Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", + Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", + }; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) + .color(t.text_dim), + ); +} + fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3f59df629..bb273150d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -663,6 +663,8 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { super::slide_overlays::sections(app, ui, t); ui.add_space(6.0); super::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); }); }); ui.add_space(12.0); @@ -673,6 +675,26 @@ fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). +fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Export")); + let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); + ui.horizontal(|ui| { + for (id, label, format) in [ + ("slideshowExportPdf", "Export PDF…", "pdf"), + ("slideshowExportJpeg", "Export JPEG…", "jpeg"), + ("slideshowExportVideo", "Export Video…", "video"), + ] { + let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.clicked() { + let _ = app.run("dialog.slideshowExport", json!({"format": format})); + } + } + }); + hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); +} + fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; heading(ui, t, crate::i18n::tr("Playback")); diff --git a/crates/ui-egui/src/slideshow_export.rs b/crates/ui-egui/src/slideshow_export.rs new file mode 100644 index 000000000..c8c0794c7 --- /dev/null +++ b/crates/ui-egui/src/slideshow_export.rs @@ -0,0 +1,166 @@ +//! View ▸ Export Slideshow…: the dialog's choices go to `app.slideshowExport`, which asks where +//! to write (a PDF or video file, or the folder of a JPEG sequence) and starts the engine's +//! `slideshow.export` in the background. The export's row in the activity stack shows its +//! progress and ✕ (the engine owns the row); [`poll`] tells the user how it ended. + +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::pick::{PickKind, PickRequest, Picked}; + +/// `app.slideshowExport {format, size?, quality?, fps?, container?, fullScreen?, speed?, path?}`: +/// without a path, a save dialog asks for one first. +pub fn start(app: &mut LightcraftApp, id: &str, p: &Value) -> Result<Value, String> { + if app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err("a slideshow export is already running".into()); + } + let mut params = p.clone(); + if p.get("path").and_then(Value::as_str).is_none_or(|x| x.trim().is_empty()) { + let req = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => PickRequest { + kind: PickKind::Save, + title: Some(crate::i18n::tr("Export Slideshow: folder for the JPEG files").to_string()), + filter: None, + file_name: Some("Slideshow".into()), + }, + Some("video") if p.get("container").and_then(Value::as_str) == Some("webm") => { + PickRequest::save(crate::i18n::tr("Export Slideshow"), "WebM", &["webm"], "Slideshow.webm") + } + Some("video") => PickRequest::save(crate::i18n::tr("Export Slideshow"), "MP4", &["mp4"], "Slideshow.mp4"), + _ => PickRequest::save(crate::i18n::tr("Export Slideshow"), "PDF", &["pdf"], "Slideshow.pdf"), + }; + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no save dialog on this platform; supply path".into()), + }; + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + params["path"] = json!(path); + } + let r = app.run("slideshow.export", params)?; + app.slideshow_export_reported = false; + Ok(r) +} + +/// Each frame: when the export has ended, say how (once). +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.slideshow_export_reported { + return; + } + let Some(run) = app.session.slideshow_export.clone() else { return }; + if run.running() { + return; + } + app.slideshow_export_reported = true; + let msg = match run.result() { + Some(Ok(r)) => { + let what = match r["format"].as_str() { + Some("pdf") => crate::i18n::tr_format!("{n} pages", n = r["pages"].as_u64().unwrap_or(0)), + Some("jpeg") => crate::i18n::tr_format!("{n} JPEG files", n = r["slides"].as_u64().unwrap_or(0)), + _ => crate::i18n::tr_format!("{s} s of AV1 video", s = r["seconds"].as_f64().unwrap_or(0.0).round()), + }; + let mut m = crate::i18n::tr_format!("Slideshow exported: {what}", what = what); + if let Some(n) = r["skipped"].as_u64().filter(|n| *n > 0) { + m += &crate::i18n::tr_format!(" · {n} skipped (could not be rendered)", n = n); + } + m + } + Some(Err(e)) => e, + None => crate::i18n::tr("Export stopped unexpectedly").to_string(), + }; + app.toast(ctx, msg); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::Dialog; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + /// The app with a picker that answers `answer` and remembers what it was asked. + fn app_answering(answer: String) -> (LightcraftApp, Arc<Mutex<Vec<PickRequest>>>) { + let asked = Arc::new(Mutex::new(Vec::<PickRequest>::new())); + let requests = asked.clone(); + let services = crate::Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec![answer.clone()]).unwrap(); + Ok(rx) + })), + ..Default::default() + }; + let mut s = lightcraft_engine::Session::with_demo(); + s.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap(); + (LightcraftApp::new(s, services), asked) + } + + fn run_dialog(h: &mut crate::headless::Headless) -> Value { + let dialog = h.app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); + let deadline = Instant::now() + Duration::from_secs(120); + let mut seen_row = false; + loop { + h.step(); + seen_row |= h.app.session.activity.list().iter().any(|t| t.kind == "export" && t.label == "Exporting slideshow"); + if h.app.slideshow_export_reported && h.app.session.slideshow_export.is_some() { + break; + } + assert!(Instant::now() < deadline, "export did not finish"); + std::thread::sleep(Duration::from_millis(5)); + } + let run = h.app.session.slideshow_export.clone().unwrap(); + let mut r = run.result().unwrap().unwrap(); + r["sawRow"] = json!(seen_row); + r["toast"] = json!(h.app.ui.toast.as_ref().map(|t| t.0.clone())); + r + } + + #[test] + fn menu_dialog_picker_and_background_pdf_export() { + let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Show.pdf").to_string_lossy().to_string(); + let (mut app, asked) = app_answering(path.clone()); + assert!(crate::menus::ui_enabled(&app, "dialog.slideshowExport")); + app.run("dialog.slideshowExport", json!({"format": "pdf"})).unwrap(); + let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no slideshow export dialog") }; + assert_eq!((settings.size, settings.quality, settings.full_screen), (1920, 90, true)); + settings.size = 96; + let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); + let r = run_dialog(&mut h); + let req = asked.lock().unwrap(); + assert_eq!(req[0].kind, PickKind::Save); + assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]); + assert_eq!(r["width"], 96); + assert!(r["pages"].as_u64().unwrap() > 1); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-1.4")); + assert!(r["toast"].as_str().unwrap().contains("Slideshow exported"), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn video_dialog_asks_for_an_mp4_and_jpeg_for_a_folder_name() { + let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-v-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Show.mp4").to_string_lossy().to_string(); + let (mut app, asked) = app_answering(path.clone()); + app.run("dialog.slideshowExport", json!({"format": "video"})).unwrap(); + let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no dialog") }; + assert_eq!((settings.size, settings.fps, settings.container.as_str()), (720, 30, "mp4")); + settings.size = 64; + settings.fps = 24; + let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); + let r = run_dialog(&mut h); + assert_eq!(asked.lock().unwrap()[0].filter.as_ref().unwrap().1, &["mp4"]); + assert_eq!((r["codec"].as_str(), r["height"].as_u64()), (Some("av1"), Some(64))); + assert!(r["sawRow"].as_bool().unwrap(), "the export showed in the activity stack"); + assert!(std::path::Path::new(&path).is_file()); + // JPEG: a save dialog names the folder (no file type) + let (mut app, asked) = app_answering(dir.join("Frames").to_string_lossy().to_string()); + app.run("app.slideshowExport", json!({"format": "jpeg", "size": 64})).unwrap(); + assert!(asked.lock().unwrap()[0].filter.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2eefe08a3..921899fb2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -537,6 +537,10 @@ pub enum Dialog { ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, + /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. + SlideshowExport { + settings: lightcraft_engine::slideshow_export::ExportSettings, + }, /// `parent`: the folder to create it in (none: the top level). NewAlbum { name: String, diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 576b3868f..67ed1ca20 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -52,6 +52,7 @@ guard.detail(&file_name); |---|---|---| | Export, contact sheet | `crates/ui-egui/src/export_task.rs` | adopt the job's flag; update the row each frame | | Build Previews | `crates/engine/src/cmd/previews.rs` | move the guard into the worker thread | +| Slideshow export | `crates/engine/src/cmd/slideshow.rs`, `crates/engine/src/slideshow_export.rs` | an engine command that owns its worker and row; progress from the worker through a `TaskHandle`; results read back by command | | Model downloads | `crates/engine/src/face_download.rs` | report progress through a `TaskHandle` in shared state | | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bee1fa1d9..64e6b54db 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -85,6 +85,9 @@ filmstrip is visible. | `engine.execute {command: "slideshow.templates.apply"}` | `{id}` or `{name}`; `{id: "previous"}` | Replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's, saved with the library. No undo: the replaced settings become `previous` for the session → `{applied, name, warnings, previous, settings}` (`warnings`: a background photo not in this library, an unreadable plate or watermark image…) | | `engine.execute {command: "slideshow.templates.save"}` / `update` / `rename` / `delete` | `{name, replace?}` / `{id}` / `{id, name}` / `{id}` | Save the current settings as a user template (`<name>.json`; path separators and reserved characters become `_`, a taken or built-in name is refused unless `replace`), update one with the current settings, rename (its file follows), delete. Built-ins can't change | | `engine.execute {command: "slideshow.templates.import"}` / `export` | `{path}` / `{id, path}` | Import a template `.json` (at most 256 KB; format, version and every setting checked; a taken name gets " (2)") → `{id, name, renamed, warnings}`; export any template as `.json` | +| `engine.execute {command: "slideshow.export"}` | `{format: "pdf"\|"jpeg"\|"video", path, size?, quality?, fps?, container?: "mp4"\|"webm", fullScreen?, speed?, name?, ids?, wait?}` | The slideshow (the photos in view, or `ids`, as composed slides between the intro and ending screens that are on) as a PDF (one page per slide; `size` = long side 64–8192 px, default 1920), a JPEG sequence (`path` = the folder, files `<name>-001.jpg`…) or AV1 video in MP4 / WebM (`size` = frame height 64–2160 or `"720p"` / `"1080p"`, `fps` 24 / 25 / 30 / 60; fades rendered as frames; no audio; at most 2 hours). Runs in the background → `{taskId, background, slides, seconds, total, width, height, warnings}`; its row is in `activity.list` (`kind: "export"`, label "Exporting slideshow"), `activity.cancel {id: taskId}` stops it and leaves no file (outputs are written as `.part` and renamed when complete). `wait: true` runs it in the call and replies with the result. One at a time | +| `engine.execute {command: "slideshow.exportStatus"}` | — | The last slideshow export: `{taskId, format, path, running, done, total, cancelled, result?, error?}`; `result` has `pages` (PDF), `files` (JPEG) or `frames`, `samples`, `keyFrames`, `seconds`, `fps`, `container`, `codec: "av1"`, `audio: false` (video), and `skipped` / `warnings` for photos that could not be rendered | +| `engine.execute {command: "dialog.slideshowExport"}` / `app.slideshowExport` | `{format?}` / `{format, size?, quality?, fps?, container?, fullScreen?, path?}` | View ▸ Export Slideshow…: opens the dialog; `app.slideshowExport` asks for the path with a save dialog when none is given, then starts `slideshow.export` | | `app.quit` | — | Close the app | Image navigation is also available directly as the UI command `view.navigate`, with diff --git a/docs/mcp.md b/docs/mcp.md index b3b50f808..4ca0d1db4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -210,6 +210,12 @@ it skipped), `cmd_slideshow_templates_apply {id}` replaces the settings with one `{id: "previous"}` for the session), `cmd_slideshow_preview {template: id}` previews one without applying it, and `cmd_slideshow_templates_save` / `update` / `rename` / `delete` / `import` / `export` manage the user's own (JSON files in `<settings>/slideshow-templates`). +`cmd_slideshow_export {format: "pdf"|"jpeg"|"video", path, size?, quality?, fps?, container?: "mp4"|"webm", +fullScreen?, name?, ids?, wait?}` writes the slideshow (titles included) as a PDF (a page per slide), a numbered JPEG +sequence in a folder, or an AV1 video (no audio). It runs in the background and returns `{taskId, total, …}`: +`cmd_activity_list` shows its progress, `cmd_activity_cancel {id: taskId}` stops it (nothing is left on disk) and +`cmd_slideshow_exportStatus` gives the result (`pages`, `files`, or `frames` / `samples` / `seconds` for video) or the +error. With `wait: true` the call blocks and replies with the result. ## Resources diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index fd417806b..5290a22fe 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 35 | 20 | 28 | 9 | — | 23/25 (92%) | -| **Total** | 399 | 54 | 66 | 36 | 192/200 (96%) | 143/155 (92%) | +| Lightroom Classic extras | 35 | 21 | 27 | 9 | — | 23/25 (92%) | +| **Total** | 399 | 55 | 65 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.1%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 50.0% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 50.3% of 163. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -732,10 +732,10 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | | LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | | LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | -| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow), titles in slideshow export (LRC-SS-EXPORT) | +| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | -| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | +| LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `<name>-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | | LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing | diff --git a/docs/slideshow.md b/docs/slideshow.md new file mode 100644 index 000000000..79cf5f04e --- /dev/null +++ b/docs/slideshow.md @@ -0,0 +1,64 @@ +# Slideshow + +LightCraft's slideshow follows Lightroom Classic's Slideshow module (no Adobe assets, templates or designs): the photos +in view played full screen with a layout, overlays, intro and ending screens and fades, and exported as PDF, JPEG or +video. There is no Slideshow module view yet: the settings live in Settings ▸ Slideshow (View ▸ Slideshow Options…) +next to a live preview of the selected photo's slide, and View ▸ Slideshow (⌥⌘↩) plays. + +| Part | Commands | Code | +|---|---|---| +| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` | +| Layout, options, backdrop: margins, aspect preview, zoom to fill, stroke, shadow, colour, colour wash, background photo | `slideshow.layout` | `crates/slideshow` (the composer, no UI), `crates/engine/src/slide.rs` | +| Overlays: identity plate, rating stars, text with tokens, watermark | `slideshow.overlays` | `crates/engine/src/slide_overlay.rs` | +| Intro and ending screens | `slideshow.titles` | `crates/engine/src/slide_title.rs` | +| Templates: built-ins of our own and the user's (`<settings>/slideshow-templates/*.json`) | `slideshow.templates.*` | `crates/engine/src/slide_template.rs` | +| One slide or screen as a PNG | `slideshow.preview` | `crates/engine/src/cmd/slideshow.rs` | +| Export: PDF, JPEG sequence, AV1 video | `slideshow.export`, `slideshow.exportStatus` | `crates/engine/src/slideshow_export.rs`, `crates/codecs/src/video.rs` | + +All settings are library preferences (`prefs.json`); sizes and offsets are fractions of the slide's shorter side, so a +slide looks the same on any screen and at any export size. Playback, the preview, `slideshow.preview` and export draw +the same pixels through the same composer. + +## Export + +View ▸ Export Slideshow… (or Export PDF / JPEG / Video at the end of Settings ▸ Slideshow) chooses a format and size, +then asks where to write. The slides are the photos in view, in view order, between the intro and ending screens that +are on; random order and repeat are playback choices and don't apply. The slide aspect is the Layout's aspect preview +(Screen exports 16:9). + +- **PDF** — one page per slide, its picture a JPEG at the chosen quality, the page as many points as the slide has + pixels (1024 / 1920 / 3840 on the long side; 64–8192 by command). Each page has the slide duration (`/Dur`) and, + after the first, a fade transition (`/Trans`), which PDF viewers use in presentation mode; "Automatically show full + screen" sets `/PageMode /FullScreen`. Written by `crates/engine/src/pdf.rs` (shared with the contact sheet), streamed + to disk. +- **JPEG** — `<name>-001.jpg`, `-002.jpg`… in the chosen folder (created; `name` defaults to the folder's name). +- **Video** — AV1 in MP4 (default) or WebM, 720p or 1080p (64–2160 by command), 24 or 30 fps (25 / 60 by command), no + audio (music is a later item, LRC-SS-MUSIC). The timeline is sampled at the frame rate: a slide's fade-in is rendered + frame by frame (a crossfade, or out to the fade colour and in from it), then the slide is held for the rest of its + duration as **one** sample lasting that long (variable frame rate), so a still slide costs one encoded frame. That + frame is a key frame: seeking lands on slides. Encoded by `rav1e` (pure Rust, BSD-2-Clause, already used for AVIF) at + speed 10, 8-bit 4:2:0 BT.709 limited range, in order (low latency, no frame reordering); at most 2 hours. + + The muxers are our own (`crates/codecs/src/video.rs`, from the public ISO/IEC 14496-12 + AV1-ISOBMFF and + Matroska/WebM specifications): MP4 is `ftyp`, one `mdat` (64-bit size patched at the end) and `moov` with an `av01` + sample entry (`av1C` holding the sequence header OBU, `colr nclx`, `pasp`), `stts` durations, `stss` key frames and a + single chunk; WebM is the EBML header and a Segment with SeekHead, Info (duration), Tracks (`V_AV1`, `CodecPrivate` = + `av1C`), Clusters of SimpleBlocks (the last frame a BlockGroup with its duration) and Cues. Samples are temporal units + without temporal delimiters. No AV1 decoder is in the product, so the tests check the container structure, sample + counts and durations; ffmpeg / browsers play the files (checked by hand when the muxers change). + +The export runs on a worker thread with a row in the activity stack ("Exporting slideshow": slides done, or frames for +video, and the slide being rendered; ✕ or `activity.cancel {id: taskId}` stops it). Every output is written as +`<file>.part` and renamed when complete, so a cancelled, failed or interrupted export leaves nothing behind (for a JPEG +sequence, all files are renamed at the end). A photo that can't be rendered (its file gone or unreadable) is skipped +and named in the result's `warnings`; a full disk or an unwritable folder is an error. An original in the library is +never overwritten (`check_write_target` before and after). + +Encoding speed (rav1e speed 10, measured 2026-10-11 on a 32-thread Linux machine shared with other work): one minute of +slideshow (15 slides of 4 s with 1 s fades, 30 fps: 435 encoded frames) took 35 s at 720p and 74 s at 1080p, slide +rendering included. The cost is in the fades (one encoded frame per frame of fade); held slides cost one frame each. + +## Not yet + +The Slideshow module view (left-panel template browser, slide editor with draggable guides and overlays), music and +sync to music, pan & zoom, H.264 video, saving a slideshow with an album, export presets. diff --git a/docs/whats-new.md b/docs/whats-new.md index 2dfb22884..40814834d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,22 @@ ## 2026-10-11 +### Slideshow export: PDF, JPEG and video +- View ▸ Export Slideshow… (or the Export buttons at the end of Settings ▸ Slideshow) writes the slideshow out, like + Lightroom Classic's Export PDF, Export JPEG and Export Video: the photos in view as their slides, with the layout, + overlays and the intro and ending screens, in view order. +- **PDF:** one page per slide at 1024, 1920 or 3840 pixels on the long side, at a JPEG quality you choose. Each page + carries the slide duration and fade, so a PDF viewer's presentation mode plays it as a slideshow, and "Automatically + show full screen" asks the viewer to open it that way. +- **JPEG:** numbered files (`Holiday-001.jpg`, `Holiday-002.jpg`…) in a new folder. +- **Video:** AV1 video in an MP4 (or WebM) file at 720p or 1080p, 24 or 30 frames per second: each slide stays up for + its duration and the fades are rendered frame by frame (crossfades, or fades through the fade colour). The video has + no sound yet. It is AV1 rather than Lightroom's H.264, which has no open encoder we can ship; current browsers, + VLC and recent phones and TVs play it. +- The export runs in the background with its row in the activity stack; ✕ stops it and leaves no half-written file + behind. A photo that can't be read is skipped and named in the message at the end. Agents use `slideshow.export` + (`slideshow.exportStatus` for the result). + ### Slideshow templates - Settings ▸ Slideshow now opens with a template browser, like Lightroom Classic's Template Browser. A template holds everything about how a slideshow looks and plays: the playback timing and fades, the layout, options and backdrop, From c822501a2b11c2b6077552312ed0d832181a4208 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:51:59 +0200 Subject: [PATCH 008/315] Rotate left/right turns the crop with the image instead of cutting it (#962) --- crates/engine/src/cmd/library.rs | 4 ++++ crates/engine/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 27e96f4fd..7d85aeaaa 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -208,6 +208,10 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> { if let Some(d) = s.develop_of(id) { let mut d = (*d).clone(); d.orientation = d.orientation.rotated(cw); + // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) + let turn = lightcraft_geom::Orientation::Normal.rotated(cw); + d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); + d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); ops.extend(s.develop_op(id, d, "Rotate")); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cea8475e6..014837eb7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1106,3 +1106,24 @@ fn undo_and_redo_show_the_photo_they_change() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.selection, before, "a step over several photos keeps the selection"); } + +/// Rotate turns the crop with the image: a crop of the left half becomes the top half, and turning back restores it. +#[test] +fn rotate_carries_the_crop_with_the_image() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let mut d = active_dev(&s); + let left_half = Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }; + d.crop.geometry.rect = left_half; + d.crop.aspect = Some((2, 3)); + s.set_develop(id, d, "Crop").unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + let c = active_dev(&s).crop; + assert_eq!(c.geometry.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.5 }); + assert_eq!(c.aspect, Some((3, 2)), "a locked aspect turns too"); + s.execute("photo.rotateLeft", &json!({})).unwrap(); + let c = active_dev(&s).crop; + assert_eq!(c.geometry.rect, left_half); + assert_eq!(c.aspect, Some((2, 3))); +} From 7421407d25df8ee938d72085fdff8a43ad46a9a5 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Sun, 11 Oct 2026 16:46:28 +0200 Subject: [PATCH 009/315] LR-VIEW-ZOOM: full-size slider-drag drafts when the GPU renders (#330) A slider drag rendered the loupe's whole frame at 0.6 of its size, so the fit view went soft while dragging. On the GPU a canvas-sized frame takes a few ms, so drags now render at full size there; the CPU keeps the 0.6 draft (region::drag_scale, CPU_DRAFT_SCALE). The no-window path's canvas cap now follows an explicit `drafting` flag instead of `scale < 1.0`, so a GPU drag at 1:1 still never renders every pixel of the photo each frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/detail.rs | 5 ++- crates/ui-egui/src/region.rs | 53 ++++++++++++++++++++--- crates/ui-egui/src/tests_preview_limit.rs | 5 ++- docs/parity-checklist.md | 5 ++- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..28365fbc3 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -436,7 +436,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - let scale = if interacting { 0.6 } else { 1.0 }; + // (`ready` first: `available` would wait for the device while it is still being created) + let gpu = interacting && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); + let scale = crate::region::drag_scale(interacting, gpu); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -451,6 +453,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { native_long, texture_side, draft_scale: scale, + drafting: interacting, windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe29e135..acfc87063 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -70,8 +70,11 @@ pub struct ViewSizes { pub native_long: usize, /// The largest texture the GPU takes. pub texture_side: usize, - /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). + /// 1.0, or less while a slider is dragged (a draft of the whole-frame render; see + /// [`drag_scale`]). pub draft_scale: f32, + /// A slider is being dragged: the render is a draft, never above the canvas size. + pub drafting: bool, /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay /// that needs whole-frame context, soft proofing, or a read too far for one window). Mask /// overlays are window-safe. @@ -90,6 +93,15 @@ pub struct LoupePlan { pub window_edge: Option<usize>, } +/// Size of a slider drag's whole-frame draft on the CPU, against the render at rest. +pub const CPU_DRAFT_SCALE: f32 = 0.6; + +/// [`ViewSizes::draft_scale`] for the loupe: a drag on the GPU renders at full size (a canvas-sized +/// draft takes a few ms there, issue #330); on the CPU it renders a smaller draft to keep up. +pub fn drag_scale(drafting: bool, gpu: bool) -> f32 { + if drafting && !gpu { CPU_DRAFT_SCALE } else { 1.0 } +} + /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame /// render is canvas-sized, and the window holds only the pixels on screen at no more than 100 %. pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { @@ -100,7 +112,7 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { // the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must // not render the photo's every pixel each frame - let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; + let long = if v.drafting { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; } @@ -225,7 +237,7 @@ mod tests { const BIG: usize = 16384; fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, drafting: false, windows: true } } fn auto() -> AppSettings { @@ -276,7 +288,7 @@ mod tests { #[test] fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(24000.0, 2800.0, 6000) }); + let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); assert_eq!(drag.window_edge, still.window_edge); } @@ -286,7 +298,7 @@ mod tests { // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) }; + let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, drafting: draft_scale < 1.0, ..sizes(24000.0, 2800.0, 6000) }; let still = plan(&auto(), no_window(1.0)); let drag = plan(&auto(), no_window(0.6)); assert_eq!(still.window_edge, None); @@ -294,6 +306,33 @@ mod tests { assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); } + // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) + #[test] + fn a_drag_shrinks_the_draft_only_on_the_cpu() { + assert_eq!(drag_scale(true, true), 1.0); + assert_eq!(drag_scale(true, false), CPU_DRAFT_SCALE); + assert_eq!(drag_scale(false, true), 1.0); + assert_eq!(drag_scale(false, false), 1.0); + } + + // Given a drag at fit on the GPU, the draft is as sharp as the render at rest + #[test] + fn a_gpu_drag_at_fit_keeps_the_resolution() { + for drawn in [1400.0, 2800.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft_scale: drag_scale(true, true), drafting: true, ..sizes(drawn, drawn, 6000) }); + assert_eq!(drag, still, "{drawn}"); + } + } + + // Given no window and a drag on the GPU, the draft is still at most the canvas: a drag at 1:1 + // must not render the photo's every pixel each frame + #[test] + fn a_gpu_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft_scale: 1.0, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge <= 2816, "{drag:?}"); + } + // Given the user capped the preview size, the whole-frame render obeys it, and a fit view // stays on it (no original decoded); a zoomed view still gets its sharp window #[test] @@ -326,7 +365,7 @@ mod tests { fn a_drag_at_fit_on_a_large_display_keeps_the_window() { for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(drawn, drawn, 6000) }); assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); } @@ -370,7 +409,7 @@ mod tests { // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) #[test] fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) }); + let p = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(2800.0, 2800.0, 6000) }); assert_eq!(p.window_edge, None); } diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 3b712e876..976d4d0e7 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -423,7 +423,10 @@ mod in_the_loupe { h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); h.settle(SETTLE); let main = rendered_long_edge(&h); - assert!(main <= 1000 && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); + // canvas-sized: the whole canvas on the GPU, a 0.6 draft of it on the CPU (#330) + let canvas = h.app.canvas_rect.expect("the loupe was drawn"); + let canvas_long = canvas.width().max(canvas.height()) as usize + crate::state::LOUPE_EDGE_STEP; + assert!(main <= canvas_long && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); let region = h.app.region_view.expect("the window is drafted too"); assert_eq!(region.full.0.max(region.full.1), native, "at 100 %, magnified by the GPU"); let (w, hh) = region_tile(&h).expect("a window draft"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..7a03a5c8c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -129,7 +129,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size when the GPU renders (a canvas-sized frame takes a few ms there) and at 0.6 of it on the CPU, so the fit view stays sharp while dragging (`region::drag_scale`, issue #330). A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution when the GPU renders (0.6 draft on the CPU only, issue #330); status unchanged | | 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 | From c0a1a6443a8b5508305cbea2d402ddb5fb627469 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Sun, 11 Oct 2026 17:05:28 +0200 Subject: [PATCH 010/315] LR-VIEW-ZOOM: choose the drag draft size from what the render costs (#330) Review follow-up. "A GPU device exists" does not mean the GPU draws the view: soft proofs and the HDR range render on the CPU, and a LUT profile, refined masks or a photo past a GPU limit fall back per render. Those drags had become full-size CPU renders. region::DragScale now picks the draft size when a drag starts and keeps it until the drag ends (no resolution jump mid-gesture): full size where the GPU draws the view (not soft-proofed, no HDR range) and no earlier drag of the same control on this photo went over the 16 ms slider budget at full size; 0.6 otherwise. Full-size drafts are timed as they land (the first, rebuilding stage caches, is left out; the median decides). ViewSizes carries one `draft: Option<f32>` instead of a scale and a flag that could disagree. Tests: DragScale unit tests (budget, lock, hostile times) and headless drags for a remembered slow drag and a soft-proofed drag, each checked to fail without its guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 21 ++- crates/ui-egui/src/region.rs | 199 ++++++++++++++++++---- crates/ui-egui/src/tests_preview_limit.rs | 38 +++++ docs/parity-checklist.md | 4 +- 5 files changed, 227 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..bd825d467 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -336,6 +336,8 @@ pub struct LightcraftApp { pub region_before_view: Option<region::RegionView>, /// The loupe's render sizes while a pinch or two-finger scroll runs. pub(crate) size_hold: region::SizeHold, + /// The size of a slider drag's whole-frame drafts (full size where they keep up, issue #330). + pub(crate) drag_scale: region::DragScale, /// (photo, look, window frame size) a window was refused for: it reads more than one render holds. pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>, pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, @@ -439,6 +441,7 @@ impl LightcraftApp { region_view: None, region_before_view: None, size_hold: Default::default(), + drag_scale: Default::default(), window_refused: None, region_tiles: Default::default(), compare_windows: [None; 2], diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 28365fbc3..22173a61d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -436,15 +436,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - // (`ready` first: `available` would wait for the device while it is still being created) - let gpu = interacting && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); - let scale = crate::region::drag_scale(interacting, gpu); + let overlay = view_overlay(app, &d); + // a drag drafts at full size where the GPU draws the view (soft proofs and the HDR range + // render on the CPU; `ready` first: `available` would wait for a device still being created) + let gpu = interacting + && !app.ui.soft_proof + && overlay != lightcraft_pipeline::Overlay::HdrRange + && lightcraft_engine::gpu::ready() + && lightcraft_engine::gpu::available(); + let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); + let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + if let Some((key, ms)) = shown { + app.drag_scale.observe(key, ms); + } // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); - let overlay = view_overlay(app, &d); // Diagnostic views that need whole-frame context, soft proofing, or reads too far for one // window still render the whole frame at the size it is drawn. A mask overlay is window-safe. let sizes = crate::region::ViewSizes { @@ -452,8 +462,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, - draft_scale: scale, - drafting: interacting, + draft, windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index acfc87063..bd7c57c94 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -70,11 +70,10 @@ pub struct ViewSizes { pub native_long: usize, /// The largest texture the GPU takes. pub texture_side: usize, - /// 1.0, or less while a slider is dragged (a draft of the whole-frame render; see - /// [`drag_scale`]). - pub draft_scale: f32, - /// A slider is being dragged: the render is a draft, never above the canvas size. - pub drafting: bool, + /// While a slider is dragged, the scale of the whole-frame draft against the render at rest + /// (1.0 or [`CPU_DRAFT_SCALE`], from [`DragScale`]); a draft is never above the canvas size. + /// `None`: no drag. + pub draft: Option<f32>, /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay /// that needs whole-frame context, soft proofing, or a read too far for one window). Mask /// overlays are window-safe. @@ -93,13 +92,95 @@ pub struct LoupePlan { pub window_edge: Option<usize>, } -/// Size of a slider drag's whole-frame draft on the CPU, against the render at rest. +/// Size of a slider drag's whole-frame draft when full size would be too slow (the CPU renders +/// it, or full-size drafts of this drag went over [`DRAFT_BUDGET_MS`]), against the render at rest. pub const CPU_DRAFT_SCALE: f32 = 0.6; -/// [`ViewSizes::draft_scale`] for the loupe: a drag on the GPU renders at full size (a canvas-sized -/// draft takes a few ms there, issue #330); on the CPU it renders a smaller draft to keep up. -pub fn drag_scale(drafting: bool, gpu: bool) -> f32 { - if drafting && !gpu { CPU_DRAFT_SCALE } else { 1.0 } +/// The slider budget (ms): a drag whose full-size drafts take longer gets smaller drafts next time. +pub const DRAFT_BUDGET_MS: f64 = 16.0; + +/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. +const SLOW_DRAGS_KEPT: usize = 256; + +/// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it +/// ends (a change in the middle would make the resolution jump). A drag renders at full size when +/// the GPU draws the view and no earlier drag of the same control on this photo went over budget +/// at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue #330). Whether the GPU drew a render is +/// not enough on its own: some views (a LUT profile, refined masks, a photo past a GPU limit) fall +/// back to the CPU per render, which only the time a draft took shows. +#[derive(Clone, Debug, Default)] +pub struct DragScale { + /// The drag running now: (photo, control) and its scale. + current: Option<((lightcraft_catalog::PhotoId, String), f32)>, + /// How long the current drag's full-size drafts took (ms). + samples: Vec<f64>, + /// Key of the whole-frame render last timed (each render is timed once). + seen: Option<u64>, + /// Drags whose full-size drafts went over budget. + slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, +} + +impl DragScale { + /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control + /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame + /// render on screen (a render from before the drag is not one of its drafts). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>) -> Option<f32> { + let Some((photo, control)) = drag else { + self.finish(); + return None; + }; + match &self.current { + Some(((p, c), s)) if *p == photo && c == control => return Some(*s), + Some(_) => self.finish(), + None => {} + } + let key = (photo, control.to_string()); + let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + self.current = Some((key, s)); + self.seen = shown; + Some(s) + } + + /// The whole-frame render on screen: `key` identifies it, `ms` is how long it took. Times the + /// current drag's full-size drafts. + pub fn observe(&mut self, key: u64, ms: f64) { + let full = matches!(self.current, Some((_, s)) if s >= 1.0); + if !full || self.seen == Some(key) { + return; + } + self.seen = Some(key); + if ms.is_finite() && ms >= 0.0 && self.samples.len() < 1024 { + self.samples.push(ms); + } + } + + /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + #[cfg(test)] + pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { + self.slow.insert((photo, control.to_string())); + } + + /// The drag ended: remember it if its full-size drafts were over budget. + fn finish(&mut self) { + if let Some((key, _)) = self.current.take() + && over_budget(&self.samples) + { + if self.slow.len() >= SLOW_DRAGS_KEPT { + self.slow.clear(); + } + self.slow.insert(key); + } + self.samples.clear(); + self.seen = None; + } +} + +/// Whether a drag's draft times (ms) are over [`DRAFT_BUDGET_MS`]: the median, leaving out the +/// first draft (it rebuilds the stage caches the others reuse). +fn over_budget(samples: &[f64]) -> bool { + let mut rest: Vec<f64> = samples.iter().skip(1).copied().collect(); + rest.sort_by(f64::total_cmp); + rest.get(rest.len() / 2).is_some_and(|median| *median > DRAFT_BUDGET_MS) } /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame @@ -107,12 +188,12 @@ pub fn drag_scale(drafting: bool, gpu: bool) -> f32 { pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 }; let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; - let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; + let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.clamp(0.1, 1.0) } else { 1.0 }); if !v.windows { // the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must // not render the photo's every pixel each frame - let long = if v.drafting { drawn_long.min(canvas_long) } else { drawn_long }; + let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; } @@ -237,7 +318,7 @@ mod tests { const BIG: usize = 16384; fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, drafting: false, windows: true } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } } fn auto() -> AppSettings { @@ -288,7 +369,7 @@ mod tests { #[test] fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); assert_eq!(drag.window_edge, still.window_edge); } @@ -298,9 +379,9 @@ mod tests { // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, drafting: draft_scale < 1.0, ..sizes(24000.0, 2800.0, 6000) }; - let still = plan(&auto(), no_window(1.0)); - let drag = plan(&auto(), no_window(0.6)); + let no_window = |draft: Option<f32>| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; + let still = plan(&auto(), no_window(None)); + let drag = plan(&auto(), no_window(Some(0.6))); assert_eq!(still.window_edge, None); assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); @@ -309,27 +390,85 @@ mod tests { // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) #[test] fn a_drag_shrinks_the_draft_only_on_the_cpu() { - assert_eq!(drag_scale(true, true), 1.0); - assert_eq!(drag_scale(true, false), CPU_DRAFT_SCALE); - assert_eq!(drag_scale(false, true), 1.0); - assert_eq!(drag_scale(false, false), 1.0); + use lightcraft_catalog::PhotoId; + let mut gpu = DragScale::default(); + assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + let mut cpu = DragScale::default(); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); } - // Given a drag at fit on the GPU, the draft is as sharp as the render at rest + // Given a drag that started before the GPU was ready, it keeps its size until it ends: the + // resolution never jumps in the middle of a drag #[test] - fn a_gpu_drag_at_fit_keeps_the_resolution() { + fn a_drag_keeps_its_draft_size_until_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None), None); + assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends + assert_eq!(d.scale(drag, false, None), Some(1.0)); + } + + // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, + // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts + // small; other controls and photos keep full size + #[test] + fn a_drag_over_budget_drafts_small_the_next_time() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let exposure = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + d.observe(7, 400.0); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 40.0), (10, 45.0), (11, 9.0)] { + d.observe(key, ms); + d.observe(key, ms); // a render is timed once, however many frames show it + } + assert_eq!(d.scale(None, true, None), None); + assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + } + + // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be + // slow), the next drag stays at full size + #[test] + fn a_drag_within_budget_stays_full_size() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { + d.observe(key, ms); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + // hostile times are ignored, never a panic + for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { + d.observe(key, ms); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + } + + // Given a drag at fit at full size, the draft is as sharp as the render at rest + #[test] + fn a_full_size_drag_at_fit_keeps_the_resolution() { for drawn in [1400.0, 2800.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: drag_scale(true, true), drafting: true, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); assert_eq!(drag, still, "{drawn}"); } } - // Given no window and a drag on the GPU, the draft is still at most the canvas: a drag at 1:1 + // Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 // must not render the photo's every pixel each frame #[test] - fn a_gpu_drag_without_a_window_drafts_at_the_canvas_size() { - let drag = plan(&auto(), ViewSizes { windows: false, draft_scale: 1.0, drafting: true, ..sizes(24000.0, 2800.0, 6000) }); + fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge <= 2816, "{drag:?}"); } @@ -365,7 +504,7 @@ mod tests { fn a_drag_at_fit_on_a_large_display_keeps_the_window() { for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); } @@ -409,7 +548,7 @@ mod tests { // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) #[test] fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft_scale: 0.6, drafting: true, ..sizes(2800.0, 2800.0, 6000) }); + let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); assert_eq!(p.window_edge, None); } @@ -480,7 +619,7 @@ mod tests { #[test] fn hostile_sizes_give_a_usable_plan() { for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { - let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft_scale: v, ..sizes(1.0, 1.0, 6000) }); + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); } let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 976d4d0e7..076181599 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -433,6 +433,44 @@ mod in_the_loupe { assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame"); } + /// Drag Exposure in the loupe and return the long edge of the whole-frame draft shown, with + /// the largest a small (CPU-sized) draft of the photo as drawn can be. + fn drag_exposure(h: &mut Headless) -> (usize, usize) { + h.app.session.begin_interaction("Exposure").unwrap(); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + h.settle(SETTLE); + let image = h.app.image_rect.expect("the photo was drawn"); + let small = (image.width().max(image.height()) * crate::region::CPU_DRAFT_SCALE) as usize + crate::state::LOUPE_EDGE_STEP; + (rendered_long_edge(h), small) + } + + // Given a drag whose full-size drafts were too slow before (the CPU, a LUT profile, a slow + // GPU), when the same slider is dragged again, then it drafts small (#330) + #[test] + fn a_slow_drag_drafts_small() { + // the GPU device up front (the loupe never waits for it): with one, it is the earlier + // drag's time that makes this drag small + let _ = lightcraft_engine::gpu::available(); + let (mut h, _) = detail(); + let id = h.app.session.active().expect("a photo is open"); + h.app.drag_scale.remember_slow(id, "Exposure"); + let (main, small) = drag_exposure(&mut h); + assert!(main <= small, "the draft is {main} px, a small one at most {small}"); + } + + // Given soft proofing (rendered on the CPU even with a GPU), a drag drafts small (#330) + #[test] + fn a_soft_proofed_drag_drafts_small() { + // the GPU device up front (the loupe never waits for it): with one, this is the case where + // the GPU is there and the render still goes to the CPU + let _ = lightcraft_engine::gpu::available(); + let (mut h, _) = detail(); + h.app.ui.soft_proof = true; + h.settle(SETTLE); + let (main, small) = drag_exposure(&mut h); + assert!(main <= small, "the draft is {main} px, a small one at most {small}"); + } + fn tile(h: &Headless, slot: Slot) -> Option<(usize, usize)> { h.app.renderer.textures.get(&slot).map(|t| (t.size[0], t.size[1])) } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7a03a5c8c..8fc8117b1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -129,7 +129,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size when the GPU renders (a canvas-sized frame takes a few ms there) and at 0.6 of it on the CPU, so the fit view stays sharp while dragging (`region::drag_scale`, issue #330). A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); the size is chosen when a drag starts and kept until it ends (`region::DragScale`, issue #330). A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | @@ -787,7 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution when the GPU renders (0.6 draft on the CPU only, issue #330); status unchanged | +| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); status unchanged | | 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 | From dafa00483a9e98c43bb644cbba885dbcfad63d95 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:57:08 +0200 Subject: [PATCH 011/315] Export: "Same folder as original photo" destination mode app.export takes destinationMode (specificFolder | sameAsOriginal); the Export dialog shows it as "Export to". In sameAsOriginal mode each photo is written into its original's folder plus the optional subfolder, with the usual conflict policy and original guard per folder. A photo with no folder on disk or whose folder is missing gets its own error. Saved in presets and Export with Previous like the other options. Fixes #956 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/export.rs | 133 +++++++++++++++++++++---- crates/engine/src/tests_export.rs | 91 +++++++++++++++++ crates/mcp/src/headless.rs | 2 +- crates/mcp/src/tools.rs | 4 +- crates/ui-egui/locales/de.json | 5 +- crates/ui-egui/locales/es.json | 5 +- crates/ui-egui/locales/fr.json | 5 +- crates/ui-egui/locales/ja.json | 5 +- crates/ui-egui/locales/pt-br.json | 5 +- crates/ui-egui/locales/ru.json | 5 +- crates/ui-egui/locales/uk.json | 5 +- crates/ui-egui/locales/zh-hans.json | 5 +- crates/ui-egui/locales/zh-hant.json | 5 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/menubar.rs | 6 ++ crates/ui-egui/src/panels/dialogs.rs | 36 +++++-- crates/ui-egui/src/tests_dialog_fit.rs | 6 +- docs/control-protocol.md | 2 +- docs/parity-checklist.md | 5 +- docs/whats-new.md | 10 ++ 20 files changed, 298 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..28bb324f0 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -151,6 +151,29 @@ pub enum Conflict { Skip, } +/// Where a batch's files go (Lightroom Classic's Export To). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DestinationMode { + /// One folder for the whole batch ([`Destination::dir`]). + #[default] + SpecificFolder, + /// Each photo's own folder: the folder its original is in. A photo without one on disk (a + /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. + SameAsOriginal, +} + +impl DestinationMode { + /// `specificFolder` / `sameAsOriginal` (any case). + pub fn parse(s: &str) -> Option<Self> { + match s.trim().to_ascii_lowercase().as_str() { + "specificfolder" => Some(Self::SpecificFolder), + "sameasoriginal" => Some(Self::SameAsOriginal), + _ => None, + } + } +} + /// A named set of `app.export` params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportPreset { @@ -645,6 +668,8 @@ pub struct ExportOptions { pub naming: String, /// First `{seq}` value. pub start_number: u32, + /// A specific folder for the whole batch, or each photo's own folder. + pub destination_mode: DestinationMode, /// Created inside the destination folder (empty = none). pub subfolder: String, /// When a file of the same name exists in the destination folder. @@ -695,6 +720,7 @@ impl Default for ExportOptions { sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), start_number: 1, + destination_mode: DestinationMode::SpecificFolder, subfolder: String::new(), conflict: Conflict::Unique, tiff_compression: TiffCompression::Deflate, @@ -736,6 +762,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "sharpenAmount", "naming", "startNumber", + "destinationMode", "subfolder", "conflict", "tiffCompression", @@ -924,6 +951,7 @@ impl ExportOptions { "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { @@ -1036,6 +1064,7 @@ impl ExportOptions { sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), + destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), conflict: enm(p, "conflict").unwrap_or(d.conflict), tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { @@ -1554,6 +1583,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI pub struct PreparedExport { pub photo: lightcraft_catalog::PhotoId, pub file_name: String, + /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` + /// for a demo photo or one in the browser's storage. + pub source_dir: Option<String>, work: Work, /// The library's originals, which [`run_batch`] never writes over (shared by a batch). guard: std::sync::Arc<crate::originals::OriginalGuard>, @@ -1613,6 +1645,7 @@ fn prepare_guarded( ) -> Result<PreparedExport, String> { let p = session.catalog.photo(id).ok_or("no such photo")?; let file_name = o.file_name_for(p, seq); + let source_dir = original_folder(&p.source); // HDR output only for photos edited in HDR; the rest of the batch exports as usual let sdr_opts; let o = if o.hdr && !p.develop.hdr.enabled { @@ -1648,7 +1681,18 @@ fn prepare_guarded( }; (work, weight) }; - Ok(PreparedExport { photo: id, file_name, work, guard, weight }) + Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) +} + +/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo +/// photo, and on the web, where an original lives in the browser's storage, not in a folder. +fn original_folder(source: &lightcraft_catalog::Source) -> Option<String> { + let lightcraft_catalog::Source::File { path } = source else { return None }; + if cfg!(target_arch = "wasm32") { + return None; + } + let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); + (!dir.is_empty()).then_some(dir) } /// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level @@ -1733,8 +1777,9 @@ pub enum DngCompression { } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied to each file together with its sidecars), or one exact file path (single photo; an -/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar) +/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each +/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file +/// already there is replaced). Either way a catalogued original (or its sidecar) /// is never written over: see [`crate::originals`]. #[derive(Clone, Debug, Default)] pub struct Destination { @@ -1931,12 +1976,12 @@ fn run_batch_with( if !progress(i, &item.file_name) { break; } - let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone()); - placer.place(photo, name, &guard, run_gated(item))?; + let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); + placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; } return Ok(std::mem::take(&mut placer.out)); } - let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect(); + let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); let queue = Queue { lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), cv: std::sync::Condvar::new(), @@ -1951,12 +1996,12 @@ fn run_batch_with( } } let placed = || { - for (i, (photo, name, guard)) in heads.into_iter().enumerate() { + for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { if !progress(i, &name) { break; } let e = queue.result(i); - placer.place(photo, name, &guard, e)?; + placer.place(photo, name, source_dir.as_deref(), &guard, e)?; } Ok::<_, String>(()) }; @@ -2067,12 +2112,11 @@ impl Queue { } } -/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an -/// original) and its write, in batch order. +/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, +/// sidecars, never an original) and its write, in batch order. struct Placer<'a> { o: &'a ExportOptions, to: &'a Destination, - dir: String, single: bool, taken: std::collections::HashSet<String>, out: Vec<serde_json::Value>, @@ -2093,29 +2137,53 @@ impl<'a> Placer<'a> { exists: &'a dyn Fn(&str) -> bool, stop_on_error: bool, ) -> Placer<'a> { - let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) }; - Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } + Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } } - /// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err` - /// only when it failed and `stop_on_error` is set. + /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or + /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no + /// folder of its own, or whose folder is gone: never a file written somewhere else instead. + fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result<String, String> { + let base = match self.o.destination_mode { + DestinationMode::SpecificFolder => self.to.dir.clone(), + DestinationMode::SameAsOriginal => { + let Some(d) = source_dir else { + return Err(format!( + "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" + )); + }; + if !(self.exists)(d) { + return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); + } + d.to_string() + } + }; + Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) + } + + /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it + /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. fn place( &mut self, photo: lightcraft_catalog::PhotoId, name: String, + source_dir: Option<&str>, guard: &crate::originals::OriginalGuard, e: Result<Exported, String>, ) -> Result<(), String> { use serde_json::json; - let (o, dir, exists) = (self.o, &self.dir, self.exists); - let e = match e { - Ok(e) => e, + // an exact file path needs no folder + let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; + let (o, exists) = (self.o, self.exists); + let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { + Ok(v) => v, Err(err) if self.stop_on_error => return Err(err), Err(err) => { self.out.push(json!({"photo": photo.0, "file": name, "error": err})); return Ok(()); } }; + let dir = dir.as_str(); // the exported file and its sidecars let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>(); let path = match self.to.exact.as_deref().filter(|_| self.single) { @@ -3008,6 +3076,35 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own + /// folder; it is checked like the other params and kept in presets and Export with Previous. + #[test] + fn destination_mode_param() { + use serde_json::json; + assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); + for (v, want) in [ + ("sameAsOriginal", DestinationMode::SameAsOriginal), + ("SameAsOriginal", DestinationMode::SameAsOriginal), + ("specificFolder", DestinationMode::SpecificFolder), + ] { + assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); + } + let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); + assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); + assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); + assert!( + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) + .unwrap_err() + .to_string() + .contains("did you mean `destinationMode`") + ); + // saved (a preset, `lastExport`) and read back + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let j = o.to_json(); + assert_eq!(j["destinationMode"], "sameAsOriginal"); + assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); + } + /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. #[test] diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b57bd471b..df304b481 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -859,3 +859,94 @@ mod apple_photos { assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos")); } } + +/// Issue #956: "Same folder as original photo" puts each photo's export next to its own original +/// (plus the subfolder), so one batch from two folders lands in two folders. The conflict policy +/// and the original guard work per folder as they do for a specific folder. +#[test] +fn same_folder_as_original_exports_next_to_each_photo() { + use crate::export::{Conflict, Destination, DestinationMode}; + let dir = temp_dir("export-same-folder"); + let (a, b) = (dir.join("A"), dir.join("B")); + // two different photos (the same bytes would be imported once) + let jpeg = |blue: u8| { + let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, blue, 255]); + crate::export::encode_image(&img, &ExportOptions::default()).unwrap() + }; + let originals = [(&a, jpeg(120)), (&b, jpeg(30))]; + for (d, bytes) in &originals { + std::fs::create_dir_all(d).unwrap(); + std::fs::write(d.join("IMG_1.jpg"), bytes).unwrap(); + } + let mut s = Session::new().with_fs(); + let paths = [a.join("IMG_1.jpg").to_string_lossy().to_string(), b.join("IMG_1.jpg").to_string_lossy().to_string()]; + s.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + assert_eq!(ids.len(), 2); + // the folder of a specific-folder export is not used + let to = Destination { dir: dir.join("unused").to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let exists = |p: &str| std::path::Path::new(p).exists(); + let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); + let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); + let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()]; + let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect(); + want.sort(); + got.sort(); + assert_eq!(got, want); + assert!(!dir.join("unused").exists(), "nothing written to the specific folder"); + // again: each folder's own collision gets the next free name + let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); + assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("Exports/IMG_1-2.jpg")), "{files:?}"); + // no subfolder: next to the original, which is never written over + let beside = ExportOptions { subfolder: String::new(), conflict: Conflict::Overwrite, ..o.clone() }; + let err = crate::export::export_batch(&mut s, &ids, &beside, &to, &mut crate::export::write_file, &exists).unwrap_err(); + assert!(err.contains("original of IMG_1.jpg"), "{err}"); + let unique = ExportOptions { conflict: Conflict::Unique, ..beside }; + let files = crate::export::export_batch(&mut s, &ids, &unique, &to, &mut crate::export::write_file, &exists).unwrap(); + assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("IMG_1-2.jpg")), "{files:?}"); + for (d, bytes) in &originals { + assert_eq!(&std::fs::read(d.join("IMG_1.jpg")).unwrap(), bytes, "the original is untouched"); + assert!(d.join("IMG_1-2.jpg").exists()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #956: a photo with no folder on disk (a demo photo) or whose folder is gone gets its own +/// error in "Same folder as original photo" mode instead of a file written somewhere else. +#[test] +fn same_folder_as_original_refuses_photos_without_a_folder() { + use crate::export::{Destination, DestinationMode}; + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let to = Destination { dir: "out".into(), exact: None }; + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let mut written = Vec::new(); + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { + written.push(p.to_string()); + Ok(()) + }; + let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); + assert!(err.contains("no folder on disk"), "{err}"); + // without stopping: one error per photo, nothing written + let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); + let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap(); + assert_eq!(files.len(), 2); + for (f, id) in files.iter().zip(&ids) { + assert_eq!(f["photo"], id.0, "{f}"); + assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); + } + drop(write); + assert!(written.is_empty(), "{written:?}"); + + // a photo on disk whose folder is gone (moved, an unplugged drive) + let (mut s, id, dir, _src, _) = library_with_jpeg("export-same-folder-gone"); + let items = crate::export::prepare_batch(&mut s, &[id], &o).unwrap(); + let gone = dir.to_string_lossy().to_string(); + let exists = |p: &str| p != gone && !p.starts_with(&format!("{gone}/")); + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { Err(format!("wrote {p}")) }; + let files = crate::export::run_batch(items, &o, &to, &mut write, &exists, false, &mut |_, _| true).unwrap(); + let e = files[0]["error"].as_str().unwrap(); + assert!(e.contains("folder is missing") && e.contains(&gone), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..9a3de29c4 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -142,7 +142,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..4453d07a2 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -235,7 +235,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> { "preset": {"type": "string", "description": "Export preset id or name"}, "background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"}, "resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"}, - "subfolder": {"type": "string"}, + "destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"}, + "subfolder": {"type": "string", "description": "Created inside the destination folder"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, @@ -746,6 +747,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "preset", "background", "resize", + "destinationMode", "subfolder", "conflict", "tiffCompression", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..434fe4176 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,8 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Export to": "Exportieren nach", + "Specific folder": "Bestimmter Ordner", + "Same folder as original photo": "Im selben Ordner wie das Originalfoto" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..af07a801a 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Export to": "Exportar a", + "Specific folder": "Carpeta específica", + "Same folder as original photo": "Misma carpeta que la foto original" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..329322ac3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Export to": "Exporter vers", + "Specific folder": "Dossier spécifique", + "Same folder as original photo": "Même dossier que la photo d’origine" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..f3cfd2078 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,8 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Export to": "書き出し先", + "Specific folder": "指定フォルダー", + "Same folder as original photo": "元の写真と同じフォルダー" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..9c99a14dd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Export to": "Exportar para", + "Specific folder": "Pasta específica", + "Same folder as original photo": "Mesma pasta da foto original" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..ea0970e30 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,8 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Export to": "Экспорт в", + "Specific folder": "Указанная папка", + "Same folder as original photo": "Та же папка, что и у оригинала" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..0978bcbb7 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,8 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Export to": "Експорт до", + "Specific folder": "Вказана папка", + "Same folder as original photo": "Та сама папка, що й оригінал" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..cb39d88b9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,8 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Export to": "导出到", + "Specific folder": "指定文件夹", + "Same folder as original photo": "与原始照片相同的文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..ec4f99849 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,8 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Export to": "輸出至", + "Specific folder": "指定資料夾", + "Same folder as original photo": "與原始照片相同的資料夾" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..017a48e5f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -480,7 +480,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String // An exact output file needs no folder. A folder given but left blank (the Export dialog's Folder // field cleared) is an error, like Lightroom refusing to export to an unspecified folder, rather // than a silent write into the working directory. No folder at all (e.g. File → Export with - // Preset): the last export's, else the default. + // Preset): the last export's, else the default. "Same folder as original photo" needs none: + // each photo goes to its own folder. let exact = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string); let dir = match p.get("dir").and_then(Value::as_str) { Some(d) => d.trim().to_string(), @@ -490,7 +491,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String last_dir.map(str::to_string).unwrap_or_else(default_export_dir) } }; - if dir.is_empty() && exact.is_none() { + let own_folders = opts.destination_mode == lightcraft_engine::export::DestinationMode::SameAsOriginal; + if dir.is_empty() && exact.is_none() && !own_folders { return Err(NO_EXPORT_FOLDER.into()); } let to = Destination { dir: dir.clone(), exact }; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 58bcb390f..c3e43fab1 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -1186,6 +1186,12 @@ mod tests { let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); + // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it + // gets its own error rather than a file written elsewhere (issue #956) + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); + let e = r.unwrap_err(); + assert!(e.contains("no folder on disk"), "{e}"); + assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); } #[test] diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..f2a3803d1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -898,18 +898,32 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.start_number = v as u32; } } - field(ui, "Folder", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() - && let Some(pick) = app.services.pick_folder.as_mut() - && let Some(d) = pick() - { - *dir = d; - } - ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())); + // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on + // the web, where originals live in the browser's storage, not in folders) + use lightcraft_engine::export::DestinationMode as D; + if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { + choices( + ui, + "Export to", + "exportDestinationMode", + &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], + &mut opts.destination_mode, + ); + } + if opts.destination_mode == D::SpecificFolder { + field(ui, "Folder", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *dir = d; + } + ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())); + }); }); - }); + } field(ui, "Subfolder", |ui| { ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)) }); diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 895a1d864..649cbed01 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -93,9 +93,9 @@ fn export_dialog_is_unchanged_in_a_tall_window() { let h = export_dialog([1600.0, 1000.0]); assert_fits(&h, "Export at 1600×1000"); let dialog = rect(&h, "dialog:window"); - // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the - // format row gained JPEG XL, #780) - assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}"); + // (the whole dialog, nothing scrolled away: 846 tall as before #781, plus the Export to row + // since #956; 615 wide since the format row gained JPEG XL, #780) + assert!((dialog.height() - 878.0).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..570b0c515 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,7 +73,7 @@ filmstrip is visible. | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | -| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..9d04c1124 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-EXP-LOCATION: Same folder as original photo; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -395,7 +395,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | LR-EXP-LOCATION: Export to "Same folder as original photo" (`destinationMode`), issue #956; status unchanged (✅) | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..d9ddf49ce 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,6 +11,16 @@ - Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in noticeably duller. +### Export next to the originals +- The Export dialog has Export to: Specific folder (as before) or Same folder as original photo, like Lightroom + Classic (issue #956). With the second, each photo is exported into the folder its original is in, plus the + Subfolder if you give one (say `Exports`), so a batch from several folders lands next to each set of originals. + "If file exists" works per folder as usual, and an original is never written over. +- A photo that has no folder on disk (a demo photo, or one in the web version's browser storage) or whose folder + is missing (moved, drive unplugged) is not exported; it gets its own error naming it. +- The choice is saved in export presets and Export with Previous. Agents pass + `destinationMode: "sameAsOriginal"` (or `"specificFolder"`) to `app.export`; `dir` is then not needed. + ### Open Library knows a Lightroom catalog - Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` / `.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The From 20822fa513d08fda989ec808301510a086b3cb18 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Sun, 11 Oct 2026 17:32:53 +0200 Subject: [PATCH 012/315] LR-VIEW-ZOOM: one rule for which renders the GPU draws (#330) The loupe sized its slider-drag drafts by its own copy of try_render's "CPU only" list (soft proof, HDR range). A new CPU-only overlay added to try_render (e.g. #995's sharpening mask) would have left the loupe drafting full size on the CPU for its first drag. lightcraft_gpu::handles is now the single rule: try_render returns NotTried by it and the loupe asks it, so the two can't drift apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/gpu/src/lib.rs | 32 ++++++++++++++++++++++------- crates/ui-egui/src/panels/detail.rs | 7 +++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fb9785ba6..76fb0f2c1 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -437,6 +437,14 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R try_render(src, info, s, req, stages).ok() } +/// Whether the GPU renders requests of this kind at all (a given render can still fall back to +/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports and soft proofs render on +/// the CPU, and so does Visualize HDR, which needs the float HDR render. [`try_render`] follows it, +/// and the loupe sizes its slider-drag drafts by it (issue #330), so the two can't drift apart. +pub fn handles(depth: lightcraft_pipeline::OutputDepth, proofed: bool, overlay: lightcraft_pipeline::Overlay) -> bool { + depth == lightcraft_pipeline::OutputDepth::U8 && !proofed && overlay != lightcraft_pipeline::Overlay::HdrRange +} + /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU /// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason /// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike @@ -453,13 +461,7 @@ pub fn try_render( // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); let s: &DevelopSettings = &effective; - // the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU - // Visualize HDR needs the float HDR render: CPU too - if !enabled() - || req.depth != lightcraft_pipeline::OutputDepth::U8 - || req.proof.is_some() - || req.overlay == lightcraft_pipeline::Overlay::HdrRange - { + if !enabled() || !handles(req.depth, req.proof.is_some(), req.overlay) { return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); @@ -533,3 +535,19 @@ pub fn try_render( Err(Fallback::NotTried) } } + +#[cfg(test)] +mod tests { + use lightcraft_pipeline::{OutputDepth, Overlay}; + + // Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR), + // the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU + #[test] + fn the_gpu_handles_8_bit_unproofed_renders_without_the_hdr_range() { + assert!(super::handles(OutputDepth::U8, false, Overlay::None)); + assert!(super::handles(OutputDepth::U8, false, Overlay::Spots(50))); + assert!(!super::handles(OutputDepth::U16, false, Overlay::None)); + assert!(!super::handles(OutputDepth::U8, true, Overlay::None)); + assert!(!super::handles(OutputDepth::U8, false, Overlay::HdrRange)); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 22173a61d..0b17684d3 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -437,11 +437,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); let overlay = view_overlay(app, &d); - // a drag drafts at full size where the GPU draws the view (soft proofs and the HDR range - // render on the CPU; `ready` first: `available` would wait for a device still being created) + // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; + // `ready` first: `available` would wait for a device still being created) let gpu = interacting - && !app.ui.soft_proof - && overlay != lightcraft_pipeline::Overlay::HdrRange + && lightcraft_engine::gpu::handles(lightcraft_pipeline::OutputDepth::U8, app.ui.soft_proof, overlay) && lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); From cc3eb1c846a1136ec52c854b2aa86dd20750dd34 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 02:40:10 +1100 Subject: [PATCH 013/315] LR-EXP-DNGOPT: lossy DNG export (DNG 1.4 compression 34892) DNG export gains a "Lossy" compression: the raw data is demosaiced to linear camera RGB (OpcodeList3 and the default crop applied), scaled to the resize long edge when one is set, and stored as 8-bit baseline JPEG tiles at the export quality. The codes follow a 1/2.2 power curve that a LinearizationTable maps back to linear 16-bit values; colour matrices, white balance and the profile look are kept. Available in the export dialog, `app.export` (`dngCompression: "lossy"`) and the MCP export tool. Also fixes a new clippy lint (chunks_exact_to_as_chunks) in pipeline/spots.rs that fails `cargo xtask ci` on Rust 1.99. Sources: Adobe DNG Specification 1.4+ (Compression 34892, LinearizationTable); own design for the code curve. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/engine/src/export.rs | 18 ++++- crates/engine/src/tests_xmp.rs | 30 +++++++- crates/mcp/src/tools.rs | 2 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/Cargo.toml | 2 +- crates/raw/src/dngwrite.rs | 102 +++++++++++++++++++++++++++ crates/raw/tests/lossy_dng.rs | 95 +++++++++++++++++++++++++ crates/raw/tests/robust.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- docs/file-format-parity.md | 9 +-- docs/gaps.md | 5 +- docs/parity-checklist.md | 2 +- 12 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 crates/raw/tests/lossy_dng.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..033de22fb 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -928,7 +928,7 @@ impl ExportOptions { "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { serde_json::from_value::<DngCompression>(v.clone()) - .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; } "metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?, "colorSpace" => { @@ -1579,6 +1579,9 @@ enum Work { read: Option<crate::merge::ByteReader>, packet: String, dng: Option<DngCompression>, + /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). + quality: u8, + lossy_edge: Option<u32>, /// The original must not carry a location ([`removes_location`]). remove_location: bool, label: String, @@ -1642,6 +1645,11 @@ fn prepare_guarded( read: session.media.file_bytes.clone(), packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), dng, + quality: o.quality, + lossy_edge: o.resize.is_some().then(|| { + let (w, h) = output_size(p, o); + u32::try_from(w.max(h)).unwrap_or(u32::MAX) + }), remove_location: removes_location(p, &session.catalog, o), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), @@ -1681,7 +1689,7 @@ impl PreparedExport { }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) } - Work::File { path, read, packet, dng, label, size, remove_location } => { + Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { let bytes = match &read { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, @@ -1711,6 +1719,9 @@ impl PreparedExport { DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + DngCompression::Lossy => { + lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } + } }; let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; @@ -1730,6 +1741,9 @@ pub enum DngCompression { /// Deflate (zip) tiles with prediction. Deflate, Uncompressed, + /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize + /// setting when there is one (Lightroom's "Use Lossy Compression"). + Lossy, } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..7fcfebb73 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -850,7 +850,7 @@ fn dng_export_compression_choices() { s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let mut sizes = Vec::new(); - for c in ["lossless", "deflate", "uncompressed"] { + for c in ["lossless", "deflate", "uncompressed", "lossy"] { let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c})); let mut out = Vec::new(); let mut write = |_: &str, b: &[u8]| { @@ -863,6 +863,7 @@ fn dng_export_compression_choices() { sizes.push((c, out.len())); } assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); + assert!(sizes[3].1 < sizes[0].1 / 2, "lossy is much smaller than lossless: {sizes:?}"); } fn write_jpeg(path: &Path, capture: Option<&str>) { @@ -1124,3 +1125,30 @@ fn files_sharing_a_stem_get_their_own_sidecars() { let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } + +/// Lossy DNG export: demosaiced JPEG tiles at the resize long edge, smaller than lossless, colour tags kept. +#[test] +fn lossy_dng_export_scales_and_shrinks() { + let src = temp_dir("lossydng"); + std::fs::write(src.join("synth.dng"), synthetic_dng_with(None, Default::default())).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let mut export = |p: serde_json::Value| { + let o = crate::export::ExportOptions::from_json(&p); + let mut out = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + out = b.to_vec(); + Ok(()) + }; + crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap(); + out + }; + let lossless = export(json!({"format": "dng"})); + let lossy = export(json!({"format": "dng", "dngCompression": "lossy", "quality": 80, "longEdge": 32})); + let raw = lightcraft_raw::decode(&lossy).unwrap(); + assert_eq!((raw.cpp, raw.cfa.is_none(), raw.width.max(raw.height)), (3, true, 32)); + assert_eq!(raw.color.as_shot_neutral, Some([0.5, 1.0, 0.7])); + assert!(lossy.len() < lossless.len(), "{} vs {}", lossy.len(), lossless.len()); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 93b0567d8..553416def 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> { "subfolder": {"type": "string"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, - "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, + "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng"], "description": "psd = a flat PSD (no layers); original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..5b1706bde 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -20,6 +20,7 @@ rayon = { workspace = true } miniz_oxide = { workspace = true } zune-jpeg = { workspace = true } zune-core = { workspace = true } +jpeg-encoder = { workspace = true } jxl-oxide = { workspace = true, optional = true } [features] @@ -28,7 +29,6 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] -jpeg-encoder = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } # Pinned CC0 corpus identities; not a product dependency. diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 2f04f016b..88ad740fb 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -23,6 +23,67 @@ pub enum DngCompression { tile: u32, half: bool, }, + /// Lossy DNG (DNG 1.4 compression 34892): the raw data demosaiced to linear camera RGB (with `OpcodeList3` + /// and the default crop applied), scaled down to at most `long_edge` pixels on the long side when given, + /// then stored as 8-bit baseline JPEG tiles of `tile × tile` pixels at JPEG `quality` (1–100). The 8-bit + /// codes follow a 1/2.2 power curve that a `LinearizationTable` maps back to linear 16-bit values, so + /// shadows keep their precision. The colour tags (matrices, white balance, profile look) are kept. + LossyJpeg { + tile: u32, + quality: u8, + long_edge: Option<u32>, + }, +} + +/// Gamma of the 8-bit codes in a lossy DNG (own choice: perceptually close to even steps, like sRGB). +const LOSSY_GAMMA: f64 = 2.2; + +/// The 256-entry `LinearizationTable` of a lossy DNG: code → linear 16-bit value. +fn lossy_linearization() -> Vec<u16> { + (0..256u32).map(|i| ((i as f64 / 255.0).powf(LOSSY_GAMMA) * 65535.0).round() as u16).collect() +} + +/// The image a lossy DNG stores: `raw` developed to linear camera RGB (white = 1), scaled to fit +/// `long_edge`, as 8-bit codes (in `u16` samples) on the [`LOSSY_GAMMA`] curve, with the levels, +/// geometry and opcode lists that this data needs (black 0, white 65535 after linearization). +fn lossy_source(raw: &RawImage, long_edge: Option<u32>) -> Result<RawImage> { + let mut rgb = raw.develop(crate::Method::Ahd)?; + if let Some(edge) = long_edge.map(|e| e.max(1) as usize) { + let long = rgb.width.max(rgb.height); + if long > edge { + let s = edge as f64 / long as f64; + let w = ((rgb.width as f64 * s).round() as usize).max(1); + let h = ((rgb.height as f64 * s).round() as usize).max(1); + rgb = lightcraft_raster::resample::resize(&rgb, w, h, lightcraft_raster::resample::Filter::Lanczos3); + } + } + let (w, h) = (rgb.width, rgb.height); + if w == 0 || h == 0 { + return Err(RawError::Corrupt("empty image".into())); + } + let inv = 1.0 / LOSSY_GAMMA as f32; + let data: Vec<u16> = rgb + .data + .par_iter() + .flat_map_iter(|px| px.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0).powf(inv) * 255.0).round() as u16 } else { 0 })) + .collect(); + let full = crate::Rect::new(0, 0, w, h); + Ok(RawImage { + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 8, + black: crate::BlackLevel::default(), + white: vec![65535.0], + active_area: full, + crop: full, + // list 1 and 2 work on the undemosaiced data and list 3 is applied by `develop` + opcodes: crate::OpcodeLists::default(), + linearized: true, + ..raw.clone() + }) } /// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). @@ -98,6 +159,14 @@ fn rat_vec(v: &[f64]) -> Value { /// Serialise `raw` as a DNG. pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { raw.validate()?; + let lossy; + let raw = match opts.compression { + DngCompression::LossyJpeg { long_edge, .. } => { + lossy = lossy_source(raw, long_edge)?; + &lossy + } + _ => raw, + }; let (w, h, cpp) = (raw.width, raw.height, raw.cpp); let mut ifd = IfdBuilder::new(); ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); @@ -276,6 +345,37 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { } ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] }); } + (RawData::U16(v), DngCompression::LossyJpeg { tile, quality, .. }) => { + // `lossy_source` made 3-sample 8-bit codes + let tile = (tile.clamp(16, 4096) & !15) as usize; + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8; cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG])); + ifd.set(t::LINEARIZATION_TABLE, Value::Short(lossy_linearization())); + let quality = quality.clamp(1, 100); + let (ta, td) = (w.div_ceil(tile), h.div_ceil(tile)); + let tiles: Vec<Vec<u8>> = (0..ta * td) + .into_par_iter() + .map(|i| { + let (tx, ty) = (i % ta * tile, i / ta * tile); + let mut buf = Vec::with_capacity(tile * tile * 3); + for y in 0..tile { + let sy = (ty + y).min(h - 1); + for x in 0..tile { + let sx = (tx + x).min(w - 1); + for s in 0..3 { + buf.push(v.get((sy * w + sx) * cpp + s.min(cpp - 1)).map_or(0, |&c| c.min(255) as u8)); + } + } + } + let mut out = Vec::new(); + jpeg_encoder::Encoder::new(&mut out, quality) + .encode(&buf, tile as u16, tile as u16, jpeg_encoder::ColorType::Rgb) + .map(|()| out) + .map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}"))) + }) + .collect::<Result<_>>()?; + ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); + } (RawData::U16(v), _) => { let maxv = v.iter().copied().max().unwrap_or(0); let bits = (16 - maxv.leading_zeros()).max(raw.bits.min(16)).max(2) as u8; @@ -327,6 +427,8 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); } + // handled above (the data is converted first) + DngCompression::LossyJpeg { .. } => return Err(RawError::Unsupported("lossy DNG of this data".into())), DngCompression::Lj92 { tile } => { let tile = (tile.max(2) & !1) as usize; if tile > 65534 { diff --git a/crates/raw/tests/lossy_dng.rs b/crates/raw/tests/lossy_dng.rs new file mode 100644 index 000000000..dd34b5158 --- /dev/null +++ b/crates/raw/tests/lossy_dng.rs @@ -0,0 +1,95 @@ +//! Lossy DNG (compression 34892) round trip: write, decode, develop, compare with the source. + +use lightcraft_raw::{ + BlackLevel, Cfa, ColorData, DngCompression, DngWriteOptions, Mat3, Method, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect, +}; + +/// A smooth Bayer mosaic (12-bit, black 64) of `w × h` with a colour matrix and a white balance. +fn mosaic(w: usize, h: usize) -> RawImage { + let data: Vec<u16> = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let c = (x % 2) + (y % 2); + (64.0 + 3000.0 * (x as f32 / w as f32) * (0.5 + c as f32 * 0.25) + 500.0 * (y as f32 / h as f32)) as u16 + }) + .collect(); + let color = ColorData { + illuminant: [21, 0], + color_matrix: [Some(Mat3([[0.7, -0.1, -0.05], [-0.4, 1.2, 0.2], [-0.1, 0.2, 0.6]])), None], + as_shot_neutral: Some([0.5, 1.0, 0.7]), + ..Default::default() + }; + RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(data), + cfa: Some(Cfa::bayer("RGGB").unwrap()), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![4095.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(2, 2, w - 4, h - 4), + orientation: Orientation::Rotate90, + color, + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + } +} + +fn lossy(raw: &RawImage, long_edge: Option<u32>) -> Vec<u8> { + let opts = DngWriteOptions { compression: DngCompression::LossyJpeg { tile: 32, quality: 95, long_edge }, ..Default::default() }; + lightcraft_raw::write_dng(raw, &opts).unwrap() +} + +#[test] +fn lossy_dng_round_trips_close_to_the_source() { + let raw = mosaic(80, 56); + let bytes = lossy(&raw, None); + let back = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((back.width, back.height, back.cpp, back.cfa.is_none()), (76, 52, 3, true), "demosaiced, default crop applied"); + assert!(back.linearized); + assert_eq!(back.orientation, raw.orientation); + assert_eq!(back.color.color_matrix, raw.color.color_matrix); + assert_eq!(back.color.as_shot_neutral, raw.color.as_shot_neutral); + let a = raw.develop(Method::Ahd).unwrap(); + let b = back.develop(Method::Ahd).unwrap(); + assert_eq!((a.width, a.height), (b.width, b.height)); + let (mut sum, mut worst) = (0f64, 0f32); + for (p, q) in a.data.iter().zip(&b.data) { + for k in 0..3 { + let d = (p[k].clamp(0.0, 1.0) - q[k]).abs(); + sum += d as f64; + worst = worst.max(d); + } + } + let mean = sum / (a.data.len() * 3) as f64; + assert!(mean < 0.01 && worst < 0.08, "mean {mean} worst {worst}"); +} + +#[test] +fn lossy_dng_scales_to_the_long_edge_and_is_smaller() { + let raw = mosaic(160, 96); + let small = lossy(&raw, Some(78)); + let back = lightcraft_raw::decode(&small).unwrap(); + assert_eq!(back.width.max(back.height), 78); + assert_eq!(back.width, 78); + assert!(back.height == 46 || back.height == 47, "{}", back.height); + let full = lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: DngCompression::Uncompressed, ..Default::default() }).unwrap(); + assert!(small.len() < full.len() / 2, "{} vs {}", small.len(), full.len()); +} + +#[test] +fn lossy_dng_of_linear_data_and_extreme_values() { + // a 3-sample linear raw with values above white, negative and non-finite samples: no panic, clipped + let (w, h) = (20usize, 12usize); + let data: Vec<f32> = (0..w * h * 3).map(|i| [2.0, -1.0, f32::NAN, 0.5, f32::INFINITY][i % 5]).collect(); + let raw = RawImage { cpp: 3, cfa: None, data: RawData::F32(data), white: vec![1.0], black: BlackLevel::uniform(0.0), ..mosaic(w, h) }; + let back = lightcraft_raw::decode(&lossy(&raw, Some(4))).unwrap(); + assert_eq!(back.width.max(back.height), 4); + let rgb = back.develop(Method::Bilinear).unwrap(); + assert!(rgb.data.iter().flatten().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); +} diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 7bd5a5e54..fa51ab5fb 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -75,7 +75,9 @@ proptest! { #[test] fn truncation_at_every_length() { - for comp in [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }] { + for comp in + [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }, DngCompression::LossyJpeg { tile: 16, quality: 80, long_edge: None }] + { let d = sample(comp); for n in (0..d.len()).step_by(7) { exercise(&d[..n]); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..32cf784fe 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -879,7 +879,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if opts.format == F::Dng { use lightcraft_engine::export::DngCompression as Z; - choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression); + choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], &mut opts.dng_compression); } let naming_id = egui::Id::new("export-naming"); let tags_open = field(ui, "File name", |ui| { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 79c66b69c..140591148 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; formats measured from the code and the target's Info.plist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (lossy DNG export) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every format Lightroom reads or writes, with our support, fidelity and tests. Camera raw formats are deep enough to have their own document: [raw-parity.md](raw-parity.md). @@ -24,7 +24,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` | | WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | | GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | -| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed or LJ92; ⬜ lossy DNG, embedded original | `crates/raw/src/dngwrite.rs` | +| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed, LJ92, Deflate or lossy (JPEG tiles, optional long edge); ⬜ embedded original | `crates/raw/src/dngwrite.rs` | | Render to DNG (15.5) | — | ✅ | — | ⬜ | new in Classic 15.5 | | Original + sidecar | — | ✅ | — | ✅ | | | HDR gain map JPEG (ISO 21496-1) | ✅ | ✅ | ✅ (gain map read) | ✅ | `crates/codecs/src/gainmap.rs` | @@ -63,13 +63,13 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | Part | Weight | Ready for real work | Kind | |---|---:|---:|---| -| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, PSD write, lossy DNG) | +| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, Render to DNG) | | Sidecars, metadata, presets | 25% | ~85% | estimated | | Lightroom catalog migration | 25% | ~50% | estimated (one-way, approximate rendering of unsupported settings) | | HDR, print, video output | 15% | ~35% | estimated | | **Non-raw file formats** | 100% | **~70%** | estimated; raw is in [raw-parity.md](raw-parity.md) (~50%) | -Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is written or found; PSD write 4–8 h; lossy DNG and Render to DNG 4–8 h; HLG 3–5 h; catalog import depth 8–12 h). +Remaining: **21–36 h** (AVIF decode 8–15 h if a permissive decoder is written or found; Render to DNG 2–4 h; HLG 3–5 h; catalog import depth 8–12 h). ## Revision history @@ -77,3 +77,4 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte |---|---|---| | 2026-10-10 | major | Created from the code and the installed Lightroom's document types | | 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR | +| 2026-10-12 | minor | Lossy DNG export (JPEG tiles, `dngCompression: "lossy"`); PSD write had already landed (#953), so it leaves the remaining hours | diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..37b69ba6a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 25: lossy DNG export; earlier: CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -39,7 +39,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 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** 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) | +| 25 | **File writes**: Render to DNG; AVIF import (lossy DNG export landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–33 | [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) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 25: lossy DNG export (DNG compression 34892) lands; Render to DNG and AVIF import remain | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..c139c74e4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -397,7 +397,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | -| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | +| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/lossy_dng.rs`); no embedded JPEG preview size | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | | LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | From 594c046d521422eeedb3cb32f218012ae0d24d08 Mon Sep 17 00:00:00 2001 From: OneSlips <alurkoff@yahoo.com> Date: Sun, 11 Oct 2026 12:42:38 +1000 Subject: [PATCH 014/315] LR-MASK-EFFECTS: mask effect (brush) presets and Lightroom local preset import Named sets of local adjustment sliders, picked from a new Effect menu above the mask sliders in the Masking panel (apply sets every slider and keeps Amount; save current; import; right-click delete; the menu names the matching effect, else Custom). Saved with the library preferences. Commands: mask.effects, mask.applyEffect, mask.saveEffect, mask.deleteEffect, mask.importEffects, mask.exportEffects (.lceffect). Lightroom local adjustment presets (.lrtemplate with type LocalizedAdjustmentPreset) are read by mask.importEffects and routed there by preset.import / File > Import Profiles & Presets, which used to reject them as having no develop settings. Their lower-camel Local* keys map through the same table as XMP mask fields (crs_masks::local_values); sliders without a counterpart (toningLuminance, grain) are reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/mask_effects.rs | 379 ++++++++++++++++++++++++++ crates/engine/src/cmd/masks.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/preset_files.rs | 22 +- crates/engine/src/crs_masks.rs | 16 +- crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 4 + crates/engine/src/tests_prefs.rs | 54 ++++ crates/ui-egui/src/menus.rs | 7 +- crates/ui-egui/src/panels/masking.rs | 51 ++++ crates/ui-egui/src/tests_masking.rs | 33 +++ 11 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/cmd/mask_effects.rs diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs new file mode 100644 index 000000000..64b9fcac5 --- /dev/null +++ b/crates/engine/src/cmd/mask_effects.rs @@ -0,0 +1,379 @@ +//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's +//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences. +//! Imported from Lightroom `.lrtemplate` local presets (`type = "LocalizedAdjustmentPreset"`) +//! and our own `.lceffect` JSON files. + +use lightcraft_develop::LocalAdjustments; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, has_active, str_param}; +use crate::preset_import::{Lua, parse_lua}; +use crate::{Result, Session}; + +/// File extension of exported effect presets (JSON). +pub const EFFECT_EXT: &str = "lceffect"; +/// `format` tag of an exported effect-preset file. +const FILE_FORMAT: &str = "lightcraft.maskEffects"; +/// The Lua `type` of a Lightroom local adjustment preset. +const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset"; + +/// A named set of local adjustment sliders. Applying one sets every slider of the mask (those the +/// preset doesn't name go to 0); the mask's Amount stays. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MaskEffect { + pub name: String, + pub adjust: LocalAdjustments, +} + +impl MaskEffect { + /// `a` with this preset's sliders and `a`'s Amount. + pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { + LocalAdjustments { amount: a.amount, ..self.adjust } + } + /// Does `a` carry exactly this preset's sliders (Amount aside)? + pub fn matches(&self, a: &LocalAdjustments) -> bool { + let strip = |a: &LocalAdjustments| LocalAdjustments { amount: 100.0, ..*a }; + let (x, y) = (serde_json::to_value(strip(&self.adjust)).unwrap_or_default(), serde_json::to_value(strip(a)).unwrap_or_default()); + match (x.as_object(), y.as_object()) { + (Some(x), Some(y)) => x.iter().all(|(k, v)| { + let (p, q) = (v.as_f64().unwrap_or(0.0), y.get(k).and_then(Value::as_f64).unwrap_or(0.0)); + (p - q).abs() < 1e-6 + }), + _ => false, + } + } +} + +/// Name of the effect the mask adjustments `a` match, if any. +pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> { + s.mask_effects.iter().find(|e| e.matches(a)).map(|e| e.name.clone()) +} + +/// Add or replace (same name, ignoring case) an effect. +fn upsert(s: &mut Session, e: MaskEffect) { + match s.mask_effects.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&e.name)) { + Some(x) => *x = e, + None => s.mask_effects.push(e), + } +} + +fn name_param(p: &Value, c: &str) -> Result<String> { + str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`")) +} + +/// The selected mask's adjustments on the active photo. +fn selected_adjust(s: &Session, p: &Value, c: &str) -> Result<(u32, LocalAdjustments)> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mid = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask).ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let d = s.develop_of(id).unwrap_or_default(); + let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?; + Ok((mid, m.adjust)) +} + +fn list(s: &Session) -> Value { + let current = s + .active() + .and_then(|id| s.develop_of(id)) + .and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))) + .and_then(|a| matching_effect(s, &a)); + json!({"effects": s.mask_effects, "current": current}) +} + +fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.applyEffect"; + let name = name_param(p, C)?; + let effect = s.mask_effects.iter().find(|x| x.name.eq_ignore_ascii_case(&name)).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let (mid, _) = selected_adjust(s, p, C)?; + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); + let m = d.masks.iter_mut().find(|m| m.id == mid).ok_or_else(|| bad(C, format!("no mask {mid}")))?; + m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); + s.set_develop(id, d, &format!("Mask Effect: {}", effect.name))?; + Ok(json!({"name": effect.name, "mask": mid})) +} + +fn save_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.saveEffect"; + let name = name_param(p, C)?; + let (_, adjust) = selected_adjust(s, p, C)?; + upsert(s, MaskEffect { name, adjust: LocalAdjustments { amount: 100.0, ..adjust } }); + s.save_prefs()?; + Ok(list(s)) +} + +fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.deleteEffect"; + let name = name_param(p, C)?; + let n = s.mask_effects.len(); + s.mask_effects.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if n == s.mask_effects.len() { + return Err(bad(C, format!("no effect `{name}`"))); + } + s.save_prefs()?; + Ok(list(s)) +} + +/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)? +pub fn is_lr_local_template(text: &str) -> bool { + parse_lua(text.trim_start_matches('\u{feff}')) + .ok() + .and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE)) + .unwrap_or(false) +} + +/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the +/// non-zero sliders we have no equivalent for (e.g. `toningLuminance`, `grain`). +pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let root = parse_lua(text.trim_start_matches('\u{feff}'))?; + if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) { + return Err("not a local adjustment preset".into()); + } + let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) }; + // `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case + let crs = |k: &str| { + let mut c = k.chars(); + c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default() + }; + let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None }); + let values = crate::crs_masks::local_values(num); + let unmapped: Vec<String> = fields + .iter() + .filter(|(k, v)| matches!(v, Lua::Num(n) if n.abs() > 1e-9) && !crate::crs_masks::is_local_field(&crs(k))) + // a colour tint's hue without saturation does nothing + .filter(|(k, _)| k != "toningHue") + .map(|(k, _)| k.clone()) + .collect(); + let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; + let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty()); + let name = + title.map(|t| t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d).to_string()).unwrap_or_else(|| stem.to_string()); + Ok((MaskEffect { name, adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..adjust }) }, unmapped)) +} + +/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. +pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?; + let items = match &v { + Value::Object(o) if o.contains_key("effects") => o["effects"].clone(), + Value::Array(_) => v.clone(), + Value::Object(_) => Value::Array(vec![v.clone()]), + _ => return Err("not an effect preset file".into()), + }; + let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; + let list: Vec<MaskEffect> = list + .into_iter() + .filter(|e| !e.name.trim().is_empty()) + .map(|e| MaskEffect { name: e.name.trim().to_string(), adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..e.adjust }) }) + .collect(); + if list.is_empty() { + return Err("no effect presets in the file".into()); + } + Ok(list) +} + +/// Effects in file `path` (`.lrtemplate` or `.lceffect`) with the sliders each one couldn't keep. +pub fn read_file(path: &str) -> std::result::Result<Vec<(MaskEffect, Vec<String>)>, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let p = std::path::Path::new(path); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Effect".into()); + match p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() { + Some("lrtemplate") => parse_lr_local_template(&String::from_utf8_lossy(&bytes), &stem).map(|e| vec![e]), + _ => parse_file(&bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect()), + } +} + +/// Add `effects` (same-named ones replaced) and save → the import report rows. +pub fn add_effects(s: &mut Session, effects: Vec<(MaskEffect, Vec<String>, String)>) -> Result<Vec<Value>> { + let rows: Vec<Value> = effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect(); + let any = !effects.is_empty(); + for (e, _, _) in effects { + upsert(s, e); + } + if any { + s.save_prefs()?; + } + Ok(rows) +} + +/// The files under `paths` (folders walked, at most 8 levels) an effect import reads. +fn effect_files(paths: &[String]) -> Vec<String> { + fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec<String>) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + if depth < 8 { + walk(&p, depth + 1, out); + } + } else if p.extension().is_some_and(|e| e.eq_ignore_ascii_case("lrtemplate") || e.eq_ignore_ascii_case(EFFECT_EXT)) { + out.push(p.to_string_lossy().to_string()); + } + } + } + let mut out = Vec::new(); + for x in paths { + let p = std::path::Path::new(x); + if p.is_dir() { + walk(p, 0, &mut out); + } else { + out.push(x.clone()); + } + } + out +} + +fn import_effects(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.importEffects"; + let mut paths: Vec<String> = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if let Some(x) = str_param(p, "path") { + paths.push(x.to_string()); + } + if paths.is_empty() { + return Err(bad(C, "missing `path` / `paths`")); + } + let (mut ok, mut failed) = (Vec::new(), Vec::new()); + for f in effect_files(&paths) { + match read_file(&f) { + Ok(list) => ok.extend(list.into_iter().map(|(e, u)| (e, u, f.clone()))), + Err(e) => failed.push(json!([f, e])), + } + } + let imported = add_effects(s, ok)?; + Ok(json!({"imported": imported, "failed": failed})) +} + +fn export_effects(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.exportEffects"; + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let mut path = std::path::PathBuf::from(path); + if path.extension().is_none() { + path.set_extension(EFFECT_EXT); + } + let names: Option<Vec<String>> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let chosen: Vec<&MaskEffect> = + s.mask_effects.iter().filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name)))).collect(); + if chosen.is_empty() { + return Err(bad(C, "no effect presets to export")); + } + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; + } + let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "effects": chosen})).unwrap_or_default(); + std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + query "mask.effects", + "Mask Effect Presets", + [], + None, + "{} → {effects: [{name, adjust: {exposure, contrast, …}}], current: name of the effect the selected mask's sliders match, or null}", + always, + |s, _| Ok(list(s)) + ), + cmd!( + "mask.applyEffect", + "Apply Mask Effect", + [], + None, + "{name, id?} — sets every slider of the selected mask (or mask `id`) from the effect preset; Amount stays", + has_active, + apply_effect + ), + cmd!( + "mask.saveEffect", + "Save Mask Effect", + [], + None, + "{name, id?} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", + has_active, + save_effect + ), + cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name} — removes an effect preset", always, delete_effect), + cmd!( + "mask.importEffects", + "Import Mask Effects", + [], + None, + "{path | paths: [file or folder]} — Lightroom local adjustment presets (.lrtemplate) and .lceffect files; same-named effects are replaced → {imported: [{name, file, unmapped}], failed: [[file, error]]}", + always, + import_effects + ), + cmd!( + "mask.exportEffects", + "Export Mask Effects", + [], + None, + "{path, names?: [name] (default: all)} — writes an .lceffect JSON file → {path, count}", + always, + export_effects + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + // A synthetic template in the shape Lightroom writes (our own values). + const TEMPLATE: &str = r#"s = { + id = "00000000-0000-0000-0000-000000000001", + internalName = "Soft Glow", + title = "Soft Glow", + type = "LocalizedAdjustmentPreset", + value = { + clarity = 0.5, + clarity2012 = -0.25, + exposure = 0, + exposure2012 = 0.125, + highlights2012 = -0.5, + temperature = 0.2, + texture = -0.3, + toningHue = 240, + toningLuminance = 0.1, + toningSaturation = 0, + }, + version = 0, +}"#; + + #[test] + fn reads_a_lightroom_local_template() { + assert!(is_lr_local_template(TEMPLATE)); + let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap(); + assert_eq!(e.name, "Soft Glow"); + assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops"); + assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one"); + assert!((e.adjust.highlights + 50.0).abs() < 1e-9); + assert!((e.adjust.temp - 20.0).abs() < 1e-9); + assert!((e.adjust.texture + 30.0).abs() < 1e-9); + assert!((e.adjust.amount - 100.0).abs() < 1e-9); + assert_eq!(unmapped, vec!["toningLuminance".to_string()]); + } + + #[test] + fn develop_templates_are_not_local_ones() { + let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#; + assert!(!is_lr_local_template(develop)); + assert!(parse_lr_local_template(develop, "x").is_err()); + assert!(parse_lr_local_template("garbage {", "x").is_err()); + assert!(parse_lr_local_template(r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, "x").is_err()); + } + + #[test] + fn apply_keeps_amount_and_matches_ignore_it() { + let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() } }; + let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() }; + let out = e.apply(¤t); + assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0)); + assert!(e.matches(&out)); + assert!(!e.matches(¤t)); + } +} diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index ae8b862b6..d05f80abb 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -971,7 +971,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid) } -fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { +pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }; c(&mut a.exposure, -4.0, 4.0); for v in [ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..36b242741 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ pub mod keywords; pub mod library; pub mod lut_profiles; pub mod manage; +pub mod mask_effects; mod masks; mod merge; pub mod metadata; @@ -145,6 +146,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(manage::specs()); v.extend(previews::specs()); v.extend(lut_profiles::specs()); + v.extend(mask_effects::specs()); v.extend(cull::specs()); v.extend(convert::specs()); v.extend(convert::edit_specs()); diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c..ec31130d4 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -18,9 +18,22 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { let mut imported = Vec::new(); let mut failed = Vec::new(); let mut skipped = 0usize; + // Lightroom local adjustment (brush) presets become mask effects + let mut effects = Vec::new(); for top in &paths { let from_dir = std::path::Path::new(top).is_dir(); for f in expand_preset_paths(std::slice::from_ref(top)) { + if f.to_ascii_lowercase().ends_with(".lrtemplate") { + let text = std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default(); + if super::mask_effects::is_lr_local_template(&text) { + let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + match super::mask_effects::parse_lr_local_template(&text, &stem) { + Ok((e, unmapped)) => effects.push((e, unmapped, f.clone())), + Err(e) => failed.push(json!([f, e])), + } + continue; + } + } // presets in a folder go to a group named after it (unless the file names its own) // the path from the imported folder (inclusive) to the file's folder let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new("")); @@ -45,7 +58,12 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { } } } - Ok(json!({"imported": imported, "skipped": skipped, "failed": failed})) + let effects = if dry { + effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect() + } else { + super::mask_effects::add_effects(s, effects)? + }; + Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects})) } fn export(s: &mut Session, p: &Value) -> Result<Value> { @@ -83,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; Lightroom local adjustment presets (.lrtemplate) become mask effects (`mask.effects`) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,file,unmapped}]}", always, import ), diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b..798555e4f 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[ ("LocalClarity", "clarity", 100.0), ]; -fn adjust(c: &XmpValue) -> Value { +/// Is `crs` (without the `crs:` prefix) a local slider field we map? +pub(crate) fn is_local_field(crs: &str) -> bool { + LOCAL.iter().any(|(k, _, _)| *k == crs) +} + +/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without +/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones. +pub(crate) fn local_values(get: impl Fn(&str) -> Option<f64>) -> serde_json::Map<String, Value> { let mut a = serde_json::Map::new(); for (crs, ours, k) in LOCAL { if a.contains_key(*ours) { continue; } - if let Some(v) = num(c, &format!("crs:{crs}")) { + if let Some(v) = get(crs).filter(|v| v.is_finite()) { let lim = if *ours == "exposure" { 4.0 } else if *ours == "color_hue" { @@ -82,6 +89,11 @@ fn adjust(c: &XmpValue) -> Value { a.insert((*ours).into(), json!(v)); } } + a +} + +fn adjust(c: &XmpValue) -> Value { + let mut a = local_values(|crs| num(c, &format!("crs:{crs}"))); let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0); a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0))); Value::Object(a) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..b8114eaf2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -257,6 +257,8 @@ pub struct Session { /// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]), /// persisted in prefs.json. pub curve_presets: Vec<cmd::curves::CurvePreset>, + /// Mask effect presets (the Masking panel's Effect menu). + pub mask_effects: Vec<cmd::mask_effects::MaskEffect>, /// Saved colour-label name sets. pub label_sets: Vec<cmd::manage::LabelSet>, /// Look-alike signatures by content key (Find Similar). @@ -373,6 +375,7 @@ impl Session { metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), + mask_effects: Vec::new(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c2..36a7378f6 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -167,6 +167,8 @@ struct PrefsFile { filter_presets: Vec<crate::cmd::filters::FilterPreset>, /// Point-curve presets. curve_presets: Vec<crate::cmd::curves::CurvePreset>, + /// Mask effect presets. + mask_effects: Vec<crate::cmd::mask_effects::MaskEffect>, /// Colour-label name sets. label_sets: Vec<crate::cmd::manage::LabelSet>, /// Imported LUT profiles. @@ -396,6 +398,7 @@ impl Session { self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; + self.mask_effects = prefs.mask_effects; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -631,6 +634,7 @@ impl Session { metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), + mask_effects: self.mask_effects.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3534064f2..494b17ede 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -265,6 +265,60 @@ fn curve_presets_save_apply_export_import_and_persist() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn mask_effects_import_apply_save_export_and_persist() { + let lib = temp_dir("effects"); + let src = lib.join("Local Adjustment Presets"); + std::fs::create_dir_all(&src).unwrap(); + // a Lightroom local adjustment preset (synthetic values) and a develop template beside it + std::fs::write( + src.join("Brighten.lrtemplate"), + "s = {\n\ttitle = \"Brighten\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {\n\t\texposure2012 = 0.25,\n\t\tshadows2012 = 0.2,\n\t\tgrain = 0.3,\n\t},\n\tversion = 0,\n}", + ) + .unwrap(); + std::fs::write(src.join("Look.lrtemplate"), "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }") + .unwrap(); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + // the general preset import routes the local one to the effects + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); + assert_eq!(r["effects"][0]["name"], "Brighten"); + assert_eq!(r["effects"][0]["unmapped"], json!(["grain"])); + assert_eq!(s.mask_effects.len(), 1); + // apply to a mask: every slider set, Amount kept + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"clarity": 30, "amount": 70}})).unwrap(); + assert!(s.execute("mask.applyEffect", &json!({"name": "nope"})).is_err()); + s.execute("mask.applyEffect", &json!({"name": "brighten"})).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + let a = d.masks.last().unwrap().adjust; + assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (1.0, 20.0, 0.0, 70.0)); + assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["current"], "Brighten"); + // save the mask's sliders under a new name, export, delete, re-import + s.execute("mask.adjust", &json!({"values": {"dehaze": 15}})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Brighten + Dehaze"})).unwrap(); + let r = s.execute("mask.exportEffects", &json!({"path": lib.join("out").join("mine").to_string_lossy()})).unwrap(); + assert_eq!(r["count"], 2); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with(".lceffect")); + s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).unwrap(); + assert!(s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).is_err()); + let r = s.execute("mask.importEffects", &json!({"path": path})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 2); + assert_eq!(s.mask_effects.len(), 2, "same-named effects are replaced"); + s.close_library().unwrap(); + } + // saved with the library preferences + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let names: Vec<String> = s.mask_effects.iter().map(|e| e.name.clone()).collect(); + assert_eq!(names, ["Brighten", "Brighten + Dehaze"]); + assert_eq!(s.mask_effects[1].adjust.dehaze, 15.0); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() { let lib = temp_dir("resize"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..81811fe0e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1559,9 +1559,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { let n = v["imported"].as_array().map_or(0, Vec::len); let failed = v["failed"].as_array().map_or(0, Vec::len); + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + let effects = v["effects"].as_array().map_or(0, Vec::len); + if effects > 0 { + parts.push(crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if effects == 1 { "" } else { "s" }, e = effects)); + } let profile_news = !parts.is_empty() || !problems.is_empty(); let msg = match (n, failed) { - // a zip of LUTs only: the profile half already says what happened + // a zip of LUTs (or only local presets): the other half already says what happened (0, 0) if profile_news => None, (0, 0) => Some("No new presets".to_string()), (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..5e3f8dc6d 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -16,6 +16,56 @@ const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track } } +/// The Effect menu above the mask's sliders: apply, save, import and delete effect presets. +fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, adjust: &LocalAdjustments) { + use lightcraft_engine::cmd::mask_effects::matching_effect; + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); + let current = matching_effect(&app.session, adjust); + let label = current.clone().unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); + let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); + egui::Popup::menu(&r).show(|ui| { + ui.set_min_width(220.0); + let effects: Vec<String> = app.session.mask_effects.iter().map(|e| e.name.clone()).collect(); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for name in &effects { + let r = ui.selectable_label(current.as_deref() == Some(name.as_str()), name); + register(ui.ctx(), format!("maskEffect:{name}"), r.rect); + if r.clicked() { + let _ = app.run("mask.applyEffect", json!({"name": name})); + ui.close(); + } + r.context_menu(|ui| { + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { + let _ = app.run("mask.deleteEffect", json!({"name": name})); + ui.close(); + } + }); + } + }); + if !effects.is_empty() { + ui.separator(); + } + let r = ui.button(crate::i18n::tr("Save Current Settings as New Preset…")); + register(ui.ctx(), "maskEffectMenu:save", r.rect); + if r.clicked() { + crate::panels::dialogs::prompt(app, "Save Effect Preset", "Preset name", "", "mask.saveEffect", json!({}), "name"); + ui.close(); + } + if ui.add_enabled(app.services.pick_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { + let _ = app.run("file.importPresets", json!({})); + ui.close(); + } + if !effects.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); + } + }); + }); + }); +} + /// Local adjustment sliders (key = `LocalAdjustments` field name). pub const LOCAL: &[ControlSpec] = &[ spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp), @@ -368,6 +418,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui); + effect_row(app, ui, &m.adjust); for s in LOCAL { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 8024c62c5..eb2b18a22 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -137,6 +137,9 @@ fn new_brush_starts_a_separate_mask_in_paint_mode() { fn mask_adjustments_hide_overlay_without_disabling_the_mask() { use lightcraft_pipeline::Overlay; let mut h = detail("panel.masking"); + // tall enough for the Exposure slider below the Effect menu to be on screen + let r = h.request("ui.resize", json!({"width": 1200, "height": 1000}), T); + assert_eq!(r["ok"], true, "{r}"); exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); assert!(h.app.ui.mask_overlay); @@ -1416,3 +1419,33 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() { assert!((0..3).all(|i| (l[i] - k[i]).abs() < 24.0), "live {l:?} vs committed {k:?} at ({x}, {y})"); } } + +#[test] +fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}})); + h.step(); + // save the mask's sliders from the menu (name prompt) + click(&mut h, "dropdown:maskEffect"); + click(&mut h, "maskEffectMenu:save"); + let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens"); + match &mut dlg { + crate::state::Dialog::TextPrompt { command, value, .. } => { + assert_eq!(command, "mask.saveEffect"); + *value = "Lift".into(); + } + _ => panic!("a name prompt opens"), + } + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert_eq!(h.app.session.mask_effects.len(), 1); + // change the sliders, then pick the effect again: sliders back, Amount kept + exec(&mut h, "mask.adjust", json!({"values": {"exposure": -1, "clarity": 40, "amount": 50}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + click(&mut h, "maskEffect:Lift"); + let a = develop(&h).masks.last().expect("a mask").adjust; + assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (0.7, 25.0, 0.0, 50.0)); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "Lift"); +} From 0f40a2d2f590ae2d334e03dfa9a9075ae63ac4ff Mon Sep 17 00:00:00 2001 From: OneSlips <alurkoff@yahoo.com> Date: Sun, 11 Oct 2026 23:52:22 +1000 Subject: [PATCH 015/315] LR-MASK-EFFECTS: manage, group and fully import local adjustment presets (engine) Mask effects gain groups, stars, partial presets (only some sliders, with or without Amount), optional brush settings and the settings an imported preset carries that we can't apply (kept for export). New commands: mask.updateEffect, renameEffect, duplicateEffect, favoriteEffect, setEffectGroup, renameEffectGroup, toolEffect; applyEffect takes several masks, all masks, or the same-named masks on the selected photos in one undo step. A new mask starts with the effect last applied to a mask of its kind (kept with the library). Import (mask.importEffects and preset.import): Lightroom Classic local .lrtemplate, Camera Raw local-correction .xmp, .lceffect and zips of these; folders and LrC divider presets ("10 ----- DETAIL ----- 10") become groups; grain / Point Color / toningLuminance reported and kept. Malformed files are errors, never panics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/mask_effect_files.rs | 681 ++++++++++++++++ crates/engine/src/cmd/mask_effects.rs | 881 +++++++++++++++------ crates/engine/src/cmd/masks.rs | 6 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/preset_files.rs | 50 +- crates/engine/src/lib.rs | 6 + crates/engine/src/library.rs | 4 + crates/engine/src/presets.rs | 2 +- crates/engine/src/tests_mask_effects.rs | 293 +++++++ crates/engine/src/tests_prefs.rs | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/pick.rs | 2 +- 12 files changed, 1652 insertions(+), 278 deletions(-) create mode 100644 crates/engine/src/cmd/mask_effect_files.rs create mode 100644 crates/engine/src/tests_mask_effects.rs diff --git a/crates/engine/src/cmd/mask_effect_files.rs b/crates/engine/src/cmd/mask_effect_files.rs new file mode 100644 index 000000000..e1e099bc8 --- /dev/null +++ b/crates/engine/src/cmd/mask_effect_files.rs @@ -0,0 +1,681 @@ +//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua, +//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with +//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip` +//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`, +//! empty presets that head a numbered section of the menu), become groups. +//! +//! Read from the files' public structure and black-box observation; settings we have no slider +//! for are reported and kept with the effect (an export carries them). + +use lightcraft_develop::LocalAdjustments; +use serde_json::{Map, Value, json}; + +use super::mask_effects::{MaskEffect, upsert}; +use super::{bad, str_param}; +use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip}; +use crate::{Result, Session}; + +/// File extension of exported effect presets (JSON). +pub const EFFECT_EXT: &str = "lceffect"; +/// `format` tag of an exported effect-preset file. +const FILE_FORMAT: &str = "lightcraft.maskEffects"; +/// The Lua `type` of a Lightroom local adjustment preset. +const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset"; +/// Larger files aren't presets (or bundles of them). +const MAX_FILE: u64 = 64 << 20; +/// How deep nested Lua tables are kept as JSON. +const MAX_DEPTH: usize = 16; + +/// An effect read from a file, with the settings it couldn't keep and where it came from. +#[derive(Clone, Debug)] +pub struct Found { + pub effect: MaskEffect, + pub unmapped: Vec<String>, + pub file: String, + /// Effects of one folder (or folder inside a bundle) share this key: divider presets group + /// the effects next to them. + pub dir: String, + /// The group the folder gives (when the file names none). + pub dir_group: Option<String>, + /// The file named its group (`.lceffect`, `crs:Group`): kept. + pub has_group: bool, +} + +/// A readable name for a Lightroom local setting we can't carry over. +fn setting_label(key: &str) -> String { + let k = key.trim_start_matches("Local"); + let lower = k.to_ascii_lowercase(); + match lower.as_str() { + "grain" => "Grain".into(), + "toningluminance" => "Color luminance".into(), + "pointcolors" => "Point Color".into(), + "refinesaturation" => "Refine Saturation".into(), + "curverefinesaturation" => "Refine Saturation".into(), + "maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(), + _ => k.to_string(), + } +} + +/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep). +fn lua_json(v: &Lua, depth: usize) -> Value { + match v { + Lua::Nil => Value::Null, + Lua::Bool(b) => json!(b), + Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null), + Lua::Str(s) => json!(s), + Lua::Table(..) if depth >= MAX_DEPTH => Value::Null, + Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()), + Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()), + } +} + +/// Does a setting value do anything (a non-zero number, a non-empty table, …)? +fn meaningful(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9), + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + } +} + +/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)? +pub fn is_lr_local_template(text: &str) -> bool { + parse_lua(text.trim_start_matches('\u{feff}')) + .ok() + .and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE)) + .unwrap_or(false) +} + +/// `$$$/Path/Key=Default` (a localisable string) → `Default`. +fn delocalize(t: &str) -> &str { + t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d) +} + +/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the +/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in +/// `extra`. +pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let root = parse_lua(text.trim_start_matches('\u{feff}'))?; + if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) { + return Err("not a local adjustment preset".into()); + } + let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) }; + // `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case + let crs = |k: &str| { + let mut c = k.chars(); + c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default() + }; + let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None }); + let values = crate::crs_masks::local_values(num); + let mut extra = Map::new(); + for (k, v) in fields { + if crate::crs_masks::is_local_field(&crs(k)) { + continue; + } + let j = lua_json(v, 0); + if meaningful(&j) { + extra.insert(k.clone(), j); + } + } + let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty()); + let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string()); + build(name, values, extra) +} + +fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; + let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect(); + unmapped.sort(); + unmapped.dedup(); + let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized(); + Ok((effect, unmapped)) +} + +/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*` +/// properties, or (a preset typed `Local` without them) its first correction's. +fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> { + let mut out = Map::new(); + for (k, v) in &d.properties { + if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/')) + && let Some(x) = v.first() + { + out.insert(name.to_string(), json!(x.trim())); + } + } + if !out.is_empty() { + return Some(out); + } + let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local")); + if !typed_local { + return None; + } + let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?; + let lightcraft_meta::XmpValue::Struct(m) = correction else { return None }; + for (k, v) in m { + if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local")) + && let Some(x) = v.text() + { + out.insert(name.to_string(), json!(x)); + } + } + Some(out) +} + +/// Is `text` a Camera Raw local correction preset (XMP)? +pub fn is_local_xmp(text: &str) -> bool { + lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some() +} + +/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`), +/// in `crs:Group` if it names one. +pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?; + let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?; + let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite()); + let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num)); + let mut extra = Map::new(); + for (k, v) in &fields { + if crate::crs_masks::is_local_field(k) { + continue; + } + let v = num(v).map_or_else(|| v.clone(), |x| json!(x)); + if meaningful(&v) { + extra.insert(k.clone(), v); + } + } + let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let name = first("crs:Name").unwrap_or_else(|| stem.to_string()); + let (mut e, unmapped) = build(name, values, extra)?; + e.group = first("crs:Group").unwrap_or_default(); + Ok((e, unmapped)) +} + +/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. +pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?; + let items = match v { + Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(), + v @ Value::Array(_) => v, + v @ Value::Object(_) => Value::Array(vec![v]), + _ => return Err("not an effect preset file".into()), + }; + let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; + let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect(); + if list.is_empty() { + return Err("no effect presets in the file".into()); + } + Ok(list) +} + +fn ext_of(name: &str) -> String { + std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() +} + +fn stem_of(name: &str) -> String { + std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into()) +} + +type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>; + +/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type, +/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…). +pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> { + let stem = stem_of(name); + match ext_of(name).as_str() { + "lrtemplate" => { + let text = String::from_utf8_lossy(bytes); + is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e])) + } + "xmp" => { + let text = String::from_utf8_lossy(bytes); + is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e])) + } + EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())), + _ => None, + } +} + +/// The group effects in folder `dir` (relative to the imported folder) go to. +pub fn group_for_dir(dir: &str) -> Option<String> { + effect_group_from_dir(dir) +} + +/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…) +/// give no group. +fn effect_group_from_dir(dir: &str) -> Option<String> { + const FILLER: &[&str] = &[ + "local", + "adjustment", + "adjustments", + "localcorrections", + "corrections", + "correction", + "brush", + "brushes", + "mask", + "masks", + "effect", + "effects", + ]; + let generic = |part: &str| { + let lower = part.to_ascii_lowercase(); + let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + words.iter().any(|w| FILLER.contains(w)) + && words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic")) + }; + let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect(); + group_from_dir(&kept.join("/")) +} + +/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside). +/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`. +pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool { + let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(); + if ext_of(file) == "zip" { + let Ok(entries) = read_zip(bytes) else { return false }; + let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group); + let mut any = false; + for (inner, data) in entries { + let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default(); + let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone()); + let label = format!("{file}/{inner}"); + match parse_bytes(&inner, &data) { + Some(Ok(list)) => { + any = true; + push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner); + } + Some(Err(e)) => { + any = true; + failed.push((label, e)); + } + None => {} + } + } + return any; + } + match parse_bytes(file, bytes) { + Some(Ok(list)) => { + push(out, list, file, dir, dir_group, file); + true + } + Some(Err(e)) => { + failed.push((file.to_string(), e)); + true + } + None => false, + } +} + +fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) { + let named = ext_of(name) == EFFECT_EXT; + for (effect, unmapped) in list { + let has_group = named || !effect.group.is_empty(); + out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group }); + } +} + +/// The number a preset name starts with (`24a-Eye` → 24). +fn leading_number(name: &str) -> Option<u64> { + let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() +} + +/// The section title of a divider preset — an effect that sets nothing, named like +/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`). +pub fn divider_title(e: &MaskEffect) -> Option<String> { + if !e.is_empty() { + return None; + } + let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.']; + let chars: Vec<char> = e.name.chars().collect(); + let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]); + if !has_run { + return None; + } + let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect(); + let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace()); + let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace()); + let title = title.split_whitespace().collect::<Vec<_>>().join(" "); + if !title.chars().any(char::is_alphanumeric) { + return None; + } + // "DETAIL ENHANCERS" → "Detail Enhancers" + let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase); + Some(if shouting { + title + .split(' ') + .map(|w| { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default() + }) + .collect::<Vec<_>>() + .join(" ") + } else { + title + }) +} + +/// Give `found` their groups; returns the divider presets (which become groups, not effects) as +/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its +/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group. +pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) { + let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) = + found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some()); + let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect(); + for (f, _) in &mut effects { + if let Some(g) = group { + f.effect.group = g.to_string(); + continue; + } + if f.has_group { + continue; + } + let section = leading_number(&f.effect.name).and_then(|n| { + dividers + .iter() + .filter(|(d, _)| d.dir == f.dir) + .filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t))) + .max_by_key(|(m, _)| *m) + .map(|(_, t)| t.clone()) + }); + f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default(); + } + (effects.into_iter().map(|(f, _)| f).collect(), dividers) +} + +/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import +/// of a divider dropped) and save → the report rows `(imported, dividers)`. +pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> { + let (effects, dividers) = assign_groups(found, group); + let rows: Vec<Value> = + effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect(); + let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect(); + if dry || (effects.is_empty() && dividers.is_empty()) { + return Ok((rows, div_rows)); + } + s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name)))); + for f in effects { + upsert(s, f.effect); + } + s.save_prefs()?; + Ok((rows, div_rows)) +} + +/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with +/// the group its folder gives and whether it was named (not found in a folder). +fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> { + fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + if depth < 8 { + walk(&p, base, depth + 1, out); + } + } else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) { + let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string()); + out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false)); + } + } + } + let mut out = Vec::new(); + for x in paths { + let p = std::path::Path::new(x); + if p.is_dir() { + walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out); + } else { + out.push((x.clone(), None, true)); + } + } + out +} + +/// Read a file of at most [`MAX_FILE`] bytes. +pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> { + let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err(format!("too large for a preset file ({} MB)", len >> 20)); + } + std::fs::read(path).map_err(|e| e.to_string()) +} + +pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.importEffects"; + let mut paths: Vec<String> = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if let Some(x) = str_param(p, "path") { + paths.push(x.to_string()); + } + if paths.is_empty() { + return Err(bad(C, "missing `path` / `paths`")); + } + let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let (mut found, mut failed) = (Vec::new(), Vec::new()); + for (f, dir_group, named) in effect_files(&paths) { + match read_capped(&f) { + Ok(bytes) => { + if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named { + failed.push((f, "not a local adjustment preset".into())); + } + } + Err(e) => failed.push((f, e)), + } + } + let (imported, dividers) = add_found(s, found, group, dry)?; + Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()})) +} + +pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.exportEffects"; + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let mut path = std::path::PathBuf::from(path); + if path.extension().is_none() { + path.set_extension(EFFECT_EXT); + } + let names: Option<Vec<String>> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let group = str_param(p, "group").map(str::trim); + let chosen: Vec<&MaskEffect> = s + .mask_effects + .iter() + .filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name)))) + .filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group))) + .collect(); + if chosen.is_empty() { + return Err(bad(C, "no effect presets to export")); + } + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; + } + let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen})) + .map_err(|e| crate::EngineError::Other(e.to_string()))?; + std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A synthetic template in the shape Lightroom writes (our own values). + const TEMPLATE: &str = r#"s = { + id = "00000000-0000-0000-0000-000000000001", + internalName = "Soft Glow", + title = "Soft Glow", + type = "LocalizedAdjustmentPreset", + value = { + clarity = 0.5, + clarity2012 = -0.25, + exposure = 0, + exposure2012 = 0.125, + grain = 0.25, + highlights2012 = -0.5, + luminanceNoise = 0.1, + refineSaturation = 0, + temperature = 0.2, + texture = -0.3, + toningHue = 240, + toningLuminance = 0.1, + toningSaturation = 0, + pointColors = { + { + HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, }, + HueShift = -0.05, + RangeAmount = 0.5, + SrcHue = 0.8, + }, + }, + }, + version = 0, +}"#; + + #[test] + fn reads_a_lightroom_local_template() { + assert!(is_lr_local_template(TEMPLATE)); + let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap(); + assert_eq!(e.name, "Soft Glow"); + assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops"); + assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one"); + assert!((e.adjust.highlights + 50.0).abs() < 1e-9); + assert!((e.adjust.temp - 20.0).abs() < 1e-9); + assert!((e.adjust.texture + 30.0).abs() < 1e-9); + assert!((e.adjust.noise - 10.0).abs() < 1e-9); + assert!((e.adjust.amount - 100.0).abs() < 1e-9); + assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]); + assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export"); + assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6)); + assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept"); + } + + #[test] + fn develop_templates_are_not_local_ones() { + let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#; + assert!(!is_lr_local_template(develop)); + assert!(parse_lr_local_template(develop, "x").is_err()); + assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import"); + } + + #[test] + fn malformed_files_are_errors_not_panics() { + for bad in [ + "garbage {", + "", + "s = {", + r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, + r#"s = { type = "LocalizedAdjustmentPreset" }"#, + r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#, + "s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }", + "\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }", + ] { + let _ = parse_lr_local_template(bad, "x"); + let _ = parse_bytes("x.lrtemplate", bad.as_bytes()); + } + // deeply nested tables + let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200)); + let _ = parse_lr_local_template(&deep, "x"); + let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap(); + assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it"); + for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] { + assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err())); + } + for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] { + assert!(parse_file(bad.as_bytes()).is_err(), "{bad}"); + } + assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new())); + } + + /// A Camera Raw local correction preset (our own values). + const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" + crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0"> + <crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name> + <crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + + #[test] + fn reads_a_camera_raw_local_preset() { + assert!(is_local_xmp(ACR)); + let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap(); + assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces")); + assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0)); + assert_eq!(unmapped, vec!["Grain".to_string()]); + // a develop preset is not one + let develop = ACR + .replace( + "crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"", + "crs:Exposure2012=\"1\"", + ) + .replace("PresetType=\"Local\"", "PresetType=\"Normal\""); + assert!(!is_local_xmp(&develop)); + // a preset typed Local that keeps its sliders in a correction + let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local"> + <crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let (e, _) = parse_local_xmp(nested, "Soft").unwrap(); + assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0)); + } + + fn found(name: &str, exposure: f64, dir: &str) -> Found { + Found { + effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() }, + unmapped: Vec::new(), + file: format!("{dir}/{name}.lrtemplate"), + dir: dir.into(), + dir_group: None, + has_group: false, + } + } + + #[test] + fn dividers_become_groups() { + let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() }; + assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers")); + assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc")); + assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits")); + assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks"); + assert_eq!(divider_title(&e("-----")), None, "no title"); + let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") }; + assert_eq!(divider_title(&busy), None, "a divider sets nothing"); + + let list = vec![ + found("01 Burn", -1.0, "a"), + found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"), + found("12 Sky", 0.5, "a"), + found("20 ----- RETOUCHING ----- 20", 0.0, "a"), + found("24a-Eye-Cleaner", 0.1, "a"), + found("99 Late", 0.1, "a"), + found("Burn (Darken)", -0.5, "a"), + Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") }, + ]; + let (effects, dividers) = assign_groups(list.clone(), None); + let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect(); + assert_eq!( + groups, + vec![ + ("01 Burn", ""), + ("12 Sky", "Detail Enhancers"), + ("24a-Eye-Cleaner", "Retouching"), + ("99 Late", "Retouching"), + ("Burn (Darken)", ""), + ("15 Elsewhere", "Other"), + ] + ); + assert_eq!(dividers.len(), 2); + let (effects, _) = assign_groups(list, Some("Mine")); + assert!(effects.iter().all(|f| f.effect.group == "Mine")); + } + + #[test] + fn folder_names_that_only_say_local_presets_give_no_group() { + assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None); + assert_eq!(effect_group_from_dir("LocalCorrections"), None); + assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into())); + assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into())); + } +} diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs index 64b9fcac5..387909a6d 100644 --- a/crates/engine/src/cmd/mask_effects.rs +++ b/crates/engine/src/cmd/mask_effects.rs @@ -1,75 +1,317 @@ //! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's //! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences. -//! Imported from Lightroom `.lrtemplate` local presets (`type = "LocalizedAdjustmentPreset"`) -//! and our own `.lceffect` JSON files. +//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without +//! Amount) and optional brush settings. Files: [`super::mask_effect_files`]. -use lightcraft_develop::LocalAdjustments; +use std::cmp::Ordering; + +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; +use super::mask_effect_files as files; use super::{CommandSpec, always, bad, cmd, has_active, str_param}; -use crate::preset_import::{Lua, parse_lua}; use crate::{Result, Session}; -/// File extension of exported effect presets (JSON). -pub const EFFECT_EXT: &str = "lceffect"; -/// `format` tag of an exported effect-preset file. -const FILE_FORMAT: &str = "lightcraft.maskEffects"; -/// The Lua `type` of a Lightroom local adjustment preset. -const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset"; +/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside). +pub const FIELDS: &[&str] = &[ + "temp", + "tint", + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "texture", + "clarity", + "dehaze", + "hue", + "saturation", + "sharpness", + "noise", + "moire", + "defringe", + "color_hue", + "color_sat", +]; -/// A named set of local adjustment sliders. Applying one sets every slider of the mask (those the -/// preset doesn't name go to 0); the mask's Amount stays. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] -pub struct MaskEffect { - pub name: String, - pub adjust: LocalAdjustments, +pub struct BrushSettings { + /// Radius as a fraction of the image's long edge. + pub size: f64, + /// 0..100. + pub feather: f64, + pub flow: f64, + pub density: f64, + pub auto_mask: bool, } -impl MaskEffect { - /// `a` with this preset's sliders and `a`'s Amount. - pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { - LocalAdjustments { amount: a.amount, ..self.adjust } +impl Default for BrushSettings { + fn default() -> Self { + Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false } } - /// Does `a` carry exactly this preset's sliders (Amount aside)? - pub fn matches(&self, a: &LocalAdjustments) -> bool { - let strip = |a: &LocalAdjustments| LocalAdjustments { amount: 100.0, ..*a }; - let (x, y) = (serde_json::to_value(strip(&self.adjust)).unwrap_or_default(), serde_json::to_value(strip(a)).unwrap_or_default()); - match (x.as_object(), y.as_object()) { - (Some(x), Some(y)) => x.iter().all(|(k, v)| { - let (p, q) = (v.as_f64().unwrap_or(0.0), y.get(k).and_then(Value::as_f64).unwrap_or(0.0)); - (p - q).abs() < 1e-6 - }), - _ => false, +} + +impl BrushSettings { + fn clamped(self) -> Self { + let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d }; + let d = Self::default(); + Self { + size: c(self.size, 0.002, 0.5, d.size), + feather: c(self.feather, 0.0, 100.0, d.feather), + flow: c(self.flow, 1.0, 100.0, d.flow), + density: c(self.density, 1.0, 100.0, d.density), + auto_mask: self.auto_mask, } } } -/// Name of the effect the mask adjustments `a` match, if any. -pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> { - s.mask_effects.iter().find(|e| e.matches(a)).map(|e| e.name.clone()) +/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them +/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's +/// Amount stays unless `with_amount`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MaskEffect { + pub name: String, + pub adjust: LocalAdjustments, + /// The Effect menu folder ("" = none). + #[serde(skip_serializing_if = "String::is_empty")] + pub group: String, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub favorite: bool, + /// The sliders this effect sets ([`FIELDS`] names); empty = all of them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub fields: Vec<String>, + /// Applying sets the mask's Amount to `adjust.amount` too. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub with_amount: bool, + /// Brush settings the brush takes when the effect is applied. + #[serde(skip_serializing_if = "Option::is_none")] + pub brush: Option<BrushSettings>, + /// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so + /// an export carries them. + #[serde(skip_serializing_if = "Map::is_empty")] + pub extra: Map<String, Value>, } -/// Add or replace (same name, ignoring case) an effect. -fn upsert(s: &mut Session, e: MaskEffect) { - match s.mask_effects.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&e.name)) { - Some(x) => *x = e, +impl Default for MaskEffect { + fn default() -> Self { + Self { + name: String::new(), + adjust: LocalAdjustments::default(), + group: String::new(), + favorite: false, + fields: Vec::new(), + with_amount: false, + brush: None, + extra: Map::new(), + } + } +} + +/// Field `k` of `a` (0 for an unknown name). +pub fn get(a: &LocalAdjustments, k: &str) -> f64 { + serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0) +} + +/// `a` with field `k` set to `v` (unchanged for an unknown name). +pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments { + let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a }; + if !o.contains_key(k) || !v.is_finite() { + return *a; + } + o.insert(k.into(), json!(v)); + serde_json::from_value(Value::Object(o)).unwrap_or(*a) +} + +/// The sliders of `a` away from 0 (the colour's hue only with its saturation). +pub fn changed_fields(a: &LocalAdjustments) -> Vec<String> { + let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect(); + if a.color_sat.abs() > 1e-9 { + out.push("color_hue".into()); + } + out +} + +impl MaskEffect { + /// The sliders this effect sets. + pub fn field_list(&self) -> Vec<&str> { + if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() } + } + /// `a` with this preset's sliders (and Amount, if it carries it). + pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { + let mut out = if self.fields.is_empty() { + LocalAdjustments { amount: a.amount, ..self.adjust } + } else { + self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k))) + }; + if self.with_amount { + out.amount = self.adjust.amount; + } + out + } + /// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its + /// saturation (it does nothing then). + pub fn matches(&self, a: &LocalAdjustments) -> bool { + let fields = self.field_list(); + let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9; + let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6; + fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k)) + && (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6) + } + /// Does the effect set nothing (all its sliders at 0, nothing kept from an import)? + pub fn is_empty(&self) -> bool { + self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str())) + } + /// The same effect with its values in range and its field list normalised. + pub fn normalized(mut self) -> Self { + self.name = self.name.trim().to_string(); + self.group = self.group.trim().to_string(); + let amount = if self.with_amount { self.adjust.amount } else { 100.0 }; + self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust }); + self.fields.retain(|f| FIELDS.contains(&f.as_str())); + if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") { + self.fields.push("color_hue".into()); + } + self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f)); + self.fields.dedup(); + if self.fields.len() == FIELDS.len() { + self.fields.clear(); + } + self.brush = self.brush.map(BrushSettings::clamped); + self + } +} + +/// Compare names the way people number their presets: digit runs as numbers ("2 …" before +/// "10 …"), letters ignoring case. +pub fn natural_cmp(a: &str, b: &str) -> Ordering { + fn chunks(s: &str) -> Vec<(bool, String)> { + let mut out: Vec<(bool, String)> = Vec::new(); + for c in s.chars() { + let d = c.is_ascii_digit(); + match out.last_mut() { + Some((kind, text)) if *kind == d => text.push(c), + _ => out.push((d, c.to_string())), + } + } + out + } + let (x, y) = (chunks(a), chunks(b)); + for (p, q) in x.iter().zip(y.iter()) { + let o = match (p, q) { + ((true, m), (true, n)) => { + let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0')); + m.len().cmp(&n.len()).then_with(|| m.cmp(n)) + } + ((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()), + }; + if o != Ordering::Equal { + return o; + } + } + x.len().cmp(&y.len()).then_with(|| a.cmp(b)) +} + +/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with +/// its effects (indices into `effects`) sorted by name. +pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> { + let mut groups: Vec<(String, Vec<usize>)> = Vec::new(); + if effects.iter().any(|e| e.group.is_empty()) { + groups.push((String::new(), Vec::new())); + } + for (i, e) in effects.iter().enumerate() { + match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) { + Some((_, v)) => v.push(i), + None => groups.push((e.group.clone(), vec![i])), + } + } + for (_, v) in &mut groups { + v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) { + (Some(x), Some(y)) => natural_cmp(&x.name, &y.name), + _ => Ordering::Equal, + }); + } + groups +} + +/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones). +pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> { + let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a)); + full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone()) +} + +fn position(s: &Session, name: &str) -> Option<usize> { + s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim())) +} + +/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star. +pub(crate) fn upsert(s: &mut Session, e: MaskEffect) { + match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) { + Some(x) => { + let favorite = x.favorite || e.favorite; + *x = MaskEffect { favorite, ..e }; + } None => s.mask_effects.push(e), } } fn name_param(p: &Value, c: &str) -> Result<String> { - str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`")) + name_of(p, "name", c) +} + +fn name_of(p: &Value, key: &str, c: &str) -> Result<String> { + str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`"))) +} + +fn strings(p: &Value, key: &str) -> Option<Vec<String>> { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) +} + +/// The names a command acts on: `names: [...]` or `name`. +fn names_param(p: &Value, c: &str) -> Result<Vec<String>> { + match strings(p, "names").filter(|v| !v.is_empty()) { + Some(v) => Ok(v), + None => name_param(p, c).map(|n| vec![n]), + } +} + +fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> { + position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`"))) } /// The selected mask's adjustments on the active photo. -fn selected_adjust(s: &Session, p: &Value, c: &str) -> Result<(u32, LocalAdjustments)> { +fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> { let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; - let mid = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask).ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let mid = p + .get("id") + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .or(s.active_mask) + .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; let d = s.develop_of(id).unwrap_or_default(); - let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?; - Ok((mid, m.adjust)) + d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}"))) +} + +/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the +/// effect is remembered for. +pub fn mask_tool(m: &Mask) -> Option<String> { + m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string)) +} + +/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of +/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0. +pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments { + s.mask_tool_effects + .get(tool) + .and_then(|name| position(s, name)) + .and_then(|i| s.mask_effects.get(i)) + .map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default()))) + .unwrap_or_default() } fn list(s: &Session) -> Value { @@ -78,194 +320,274 @@ fn list(s: &Session) -> Value { .and_then(|id| s.develop_of(id)) .and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))) .and_then(|a| matching_effect(s, &a)); - json!({"effects": s.mask_effects, "current": current}) + let groups: Vec<Value> = menu_order(&s.mask_effects) + .into_iter() + .map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()})) + .collect(); + json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects}) +} + +/// Masks on `d` named like one of `names` (ignoring case). +fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> { + d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect() } fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "mask.applyEffect"; let name = name_param(p, C)?; - let effect = s.mask_effects.iter().find(|x| x.name.eq_ignore_ascii_case(&name)).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; - let (mid, _) = selected_adjust(s, p, C)?; + let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; - let mut d = (*s.develop_of(id).unwrap_or_default()).clone(); - let m = d.masks.iter_mut().find(|m| m.id == mid).ok_or_else(|| bad(C, format!("no mask {mid}")))?; - m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); - s.set_develop(id, d, &format!("Mask Effect: {}", effect.name))?; - Ok(json!({"name": effect.name, "mask": mid})) + let d = s.develop_of(id).unwrap_or_default(); + // the masks: `ids`, `all`, else `id` or the selected one + let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) { + d.masks.iter().map(|m| m.id).collect() + } else if let Some(a) = p.get("ids").and_then(Value::as_array) { + a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect() + } else { + vec![selected_mask(s, p, C)?.id] + }; + if ids.is_empty() { + return Err(bad(C, "no masks to apply the effect to")); + } + if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) { + return Err(bad(C, format!("no mask {missing}"))); + } + let set_masks = |d: &DevelopSettings, ids: &[u32]| { + let mut d = d.clone(); + for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) { + m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); + } + d + }; + let label = format!("Mask Effect: {}", effect.name); + // with `photos: "selected"`, the other selected photos' masks of the same names too + let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") { + let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect(); + s.selection + .ids + .iter() + .filter(|x| **x != id) + .filter_map(|x| s.develop_of(*x).map(|o| (*x, o))) + .filter_map(|(x, o)| { + let found = masks_named(&o, &names); + (!found.is_empty()).then(|| (x, set_masks(&o, &found))) + }) + .collect() + } else { + Vec::new() + }; + let mine = set_masks(&d, &ids); + let photos = 1 + others.len(); + if others.is_empty() { + s.set_develop(id, mine, &label)?; + } else { + // one undo step for every photo + let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect(); + ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label))); + s.commit(&label, lightcraft_catalog::Op::Batch { ops })?; + } + // the next mask made with this tool starts with the effect + if p.get("remember").and_then(Value::as_bool).unwrap_or(true) { + for m in d.masks.iter().filter(|m| ids.contains(&m.id)) { + if let Some(tool) = mask_tool(m) { + s.mask_tool_effects.insert(tool, effect.name.clone()); + } + } + s.save_prefs()?; + } + Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush})) +} + +/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default). +fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> { + match p.get("fields") { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::String(x)) if x == "all" => Ok(Vec::new()), + Some(Value::String(x)) if x == "changed" => { + let f = changed_fields(a); + if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) } + } + Some(Value::Array(v)) => { + let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect(); + if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) } + } + Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")), + } +} + +fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> { + match p.get("brush") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))), + } } fn save_effect(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "mask.saveEffect"; let name = name_param(p, C)?; - let (_, adjust) = selected_adjust(s, p, C)?; - upsert(s, MaskEffect { name, adjust: LocalAdjustments { amount: 100.0, ..adjust } }); + let adjust = selected_mask(s, p, C)?.adjust; + let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default(); + let e = MaskEffect { + name, + adjust, + group, + fields: fields_param(p, &adjust, C)?, + with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false), + brush: brush_param(p, C)?, + ..Default::default() + }; + upsert(s, e.normalized()); s.save_prefs()?; Ok(list(s)) } +/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether +/// Amount it carries, its group and star). +fn update_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.updateEffect"; + let name = name_param(p, C)?; + let i = effect_index(s, &name, C)?; + let adjust = selected_mask(s, p, C)?.adjust; + let brush = brush_param(p, C)?; + let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() }; + if let Some(f) = p.get("fields").filter(|v| !v.is_null()) { + next.fields = fields_param(&json!({"fields": f}), &adjust, C)?; + } + if let Some(a) = p.get("amount").and_then(Value::as_bool) { + next.with_amount = a; + } + if brush.is_some() { + next.brush = brush; + } + *e = next.normalized(); + s.save_prefs()?; + Ok(list(s)) +} + +fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.renameEffect"; + let name = name_param(p, C)?; + let to = name_of(p, "to", C)?; + let i = effect_index(s, &name, C)?; + if position(s, &to).is_some_and(|j| j != i) { + return Err(bad(C, format!("an effect named `{to}` exists already"))); + } + if let Some(e) = s.mask_effects.get_mut(i) { + e.name = to.clone(); + } + for v in s.mask_tool_effects.values_mut() { + if v.eq_ignore_ascii_case(&name) { + *v = to.clone(); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +/// A name like `base` that no effect has: `base`, `base 2`, `base 3`… +fn free_name(s: &Session, base: &str) -> String { + (1..10_000) + .map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") }) + .find(|n| position(s, n).is_none()) + .unwrap_or_else(|| base.into()) +} + +fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.duplicateEffect"; + let name = name_param(p, C)?; + let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) { + Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))), + Some(t) => t.to_string(), + None => free_name(s, &format!("{} copy", e.name)), + }; + s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e }); + s.save_prefs()?; + let mut out = list(s); + out["name"] = json!(to); + Ok(out) +} + fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "mask.deleteEffect"; - let name = name_param(p, C)?; - let n = s.mask_effects.len(); - s.mask_effects.retain(|x| !x.name.eq_ignore_ascii_case(&name)); - if n == s.mask_effects.len() { - return Err(bad(C, format!("no effect `{name}`"))); + let names = names_param(p, C)?; + if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) { + return Err(bad(C, format!("no effect `{missing}`"))); + } + s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name))); + s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v))); + s.save_prefs()?; + Ok(list(s)) +} + +fn favorite_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.favoriteEffect"; + let names = names_param(p, C)?; + let mut idx = Vec::new(); + for n in &names { + idx.push(effect_index(s, n, C)?); + } + let want = p.get("favorite").and_then(Value::as_bool); + for i in idx { + if let Some(e) = s.mask_effects.get_mut(i) { + e.favorite = want.unwrap_or(!e.favorite); + } } s.save_prefs()?; Ok(list(s)) } -/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)? -pub fn is_lr_local_template(text: &str) -> bool { - parse_lua(text.trim_start_matches('\u{feff}')) - .ok() - .and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE)) - .unwrap_or(false) -} - -/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the -/// non-zero sliders we have no equivalent for (e.g. `toningLuminance`, `grain`). -pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> { - let root = parse_lua(text.trim_start_matches('\u{feff}'))?; - if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) { - return Err("not a local adjustment preset".into()); +fn set_group(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.setEffectGroup"; + let names = names_param(p, C)?; + let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?; + let mut idx = Vec::new(); + for n in &names { + idx.push(effect_index(s, n, C)?); } - let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) }; - // `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case - let crs = |k: &str| { - let mut c = k.chars(); - c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default() - }; - let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None }); - let values = crate::crs_masks::local_values(num); - let unmapped: Vec<String> = fields - .iter() - .filter(|(k, v)| matches!(v, Lua::Num(n) if n.abs() > 1e-9) && !crate::crs_masks::is_local_field(&crs(k))) - // a colour tint's hue without saturation does nothing - .filter(|(k, _)| k != "toningHue") - .map(|(k, _)| k.clone()) - .collect(); - let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; - let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty()); - let name = - title.map(|t| t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d).to_string()).unwrap_or_else(|| stem.to_string()); - Ok((MaskEffect { name, adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..adjust }) }, unmapped)) -} - -/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. -pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> { - let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?; - let items = match &v { - Value::Object(o) if o.contains_key("effects") => o["effects"].clone(), - Value::Array(_) => v.clone(), - Value::Object(_) => Value::Array(vec![v.clone()]), - _ => return Err("not an effect preset file".into()), - }; - let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; - let list: Vec<MaskEffect> = list - .into_iter() - .filter(|e| !e.name.trim().is_empty()) - .map(|e| MaskEffect { name: e.name.trim().to_string(), adjust: super::masks::clamp_local(LocalAdjustments { amount: 100.0, ..e.adjust }) }) - .collect(); - if list.is_empty() { - return Err("no effect presets in the file".into()); - } - Ok(list) -} - -/// Effects in file `path` (`.lrtemplate` or `.lceffect`) with the sliders each one couldn't keep. -pub fn read_file(path: &str) -> std::result::Result<Vec<(MaskEffect, Vec<String>)>, String> { - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - let p = std::path::Path::new(path); - let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Effect".into()); - match p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() { - Some("lrtemplate") => parse_lr_local_template(&String::from_utf8_lossy(&bytes), &stem).map(|e| vec![e]), - _ => parse_file(&bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect()), - } -} - -/// Add `effects` (same-named ones replaced) and save → the import report rows. -pub fn add_effects(s: &mut Session, effects: Vec<(MaskEffect, Vec<String>, String)>) -> Result<Vec<Value>> { - let rows: Vec<Value> = effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect(); - let any = !effects.is_empty(); - for (e, _, _) in effects { - upsert(s, e); - } - if any { - s.save_prefs()?; - } - Ok(rows) -} - -/// The files under `paths` (folders walked, at most 8 levels) an effect import reads. -fn effect_files(paths: &[String]) -> Vec<String> { - fn walk(dir: &std::path::Path, depth: usize, out: &mut Vec<String>) { - let Ok(rd) = std::fs::read_dir(dir) else { return }; - let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); - entries.sort(); - for p in entries { - if p.is_dir() { - if depth < 8 { - walk(&p, depth + 1, out); - } - } else if p.extension().is_some_and(|e| e.eq_ignore_ascii_case("lrtemplate") || e.eq_ignore_ascii_case(EFFECT_EXT)) { - out.push(p.to_string_lossy().to_string()); - } + // an existing group's spelling wins + let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group); + for i in idx { + if let Some(e) = s.mask_effects.get_mut(i) { + e.group = group.clone(); } } - let mut out = Vec::new(); - for x in paths { - let p = std::path::Path::new(x); - if p.is_dir() { - walk(p, 0, &mut out); - } else { - out.push(x.clone()); - } - } - out + s.save_prefs()?; + Ok(list(s)) } -fn import_effects(s: &mut Session, p: &Value) -> Result<Value> { - const C: &str = "mask.importEffects"; - let mut paths: Vec<String> = - p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); - if let Some(x) = str_param(p, "path") { - paths.push(x.to_string()); +fn rename_group(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.renameEffectGroup"; + let group = name_of(p, "group", C)?; + let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?; + let mut n = 0; + for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) { + e.group = to.clone(); + n += 1; } - if paths.is_empty() { - return Err(bad(C, "missing `path` / `paths`")); + if n == 0 { + return Err(bad(C, format!("no group `{group}`"))); } - let (mut ok, mut failed) = (Vec::new(), Vec::new()); - for f in effect_files(&paths) { - match read_file(&f) { - Ok(list) => ok.extend(list.into_iter().map(|(e, u)| (e, u, f.clone()))), - Err(e) => failed.push(json!([f, e])), - } - } - let imported = add_effects(s, ok)?; - Ok(json!({"imported": imported, "failed": failed})) + s.save_prefs()?; + Ok(list(s)) } -fn export_effects(s: &mut Session, p: &Value) -> Result<Value> { - const C: &str = "mask.exportEffects"; - let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; - let mut path = std::path::PathBuf::from(path); - if path.extension().is_none() { - path.set_extension(EFFECT_EXT); +fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.toolEffect"; + let tool = name_of(p, "tool", C)?; + match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => { + let i = effect_index(s, n, C)?; + let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default(); + s.mask_tool_effects.insert(tool, name); + } + None => { + s.mask_tool_effects.remove(&tool); + } } - let names: Option<Vec<String>> = - p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); - let chosen: Vec<&MaskEffect> = - s.mask_effects.iter().filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name)))).collect(); - if chosen.is_empty() { - return Err(bad(C, "no effect presets to export")); - } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "effects": chosen})).unwrap_or_default(); - std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; - Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) + s.save_prefs()?; + Ok(json!({"toolEffects": s.mask_tool_effects})) } pub fn specs() -> Vec<CommandSpec> { @@ -275,7 +597,7 @@ pub fn specs() -> Vec<CommandSpec> { "Mask Effect Presets", [], None, - "{} → {effects: [{name, adjust: {exposure, contrast, …}}], current: name of the effect the selected mask's sliders match, or null}", + "{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}", always, |s, _| Ok(list(s)) ), @@ -284,7 +606,7 @@ pub fn specs() -> Vec<CommandSpec> { "Apply Mask Effect", [], None, - "{name, id?} — sets every slider of the selected mask (or mask `id`) from the effect preset; Amount stays", + "{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}", has_active, apply_effect ), @@ -293,28 +615,67 @@ pub fn specs() -> Vec<CommandSpec> { "Save Mask Effect", [], None, - "{name, id?} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", + "{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", has_active, save_effect ), - cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name} — removes an effect preset", always, delete_effect), + cmd!( + "mask.updateEffect", + "Update Mask Effect", + [], + None, + "{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)", + has_active, + update_effect + ), + cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect), + cmd!( + "mask.duplicateEffect", + "Duplicate Mask Effect", + [], + None, + "{name, to?} (default \"<name> copy\") → list + {name}", + always, + duplicate_effect + ), + cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect), + cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect), + cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group), + cmd!( + "mask.renameEffectGroup", + "Rename Mask Effect Group", + [], + None, + "{group, to} (\"\" ungroups; an existing name merges)", + always, + rename_group + ), + cmd!( + "mask.toolEffect", + "Set Effect for New Masks", + [], + None, + "{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)", + always, + tool_effect + ), cmd!( "mask.importEffects", "Import Mask Effects", [], None, - "{path | paths: [file or folder]} — Lightroom local adjustment presets (.lrtemplate) and .lceffect files; same-named effects are replaced → {imported: [{name, file, unmapped}], failed: [[file, error]]}", + "{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}", always, - import_effects + files::import_effects ), cmd!( "mask.exportEffects", "Export Mask Effects", [], None, - "{path, names?: [name] (default: all)} — writes an .lceffect JSON file → {path, count}", + "{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}", always, - export_effects + files::export_effects ), ] } @@ -323,57 +684,73 @@ pub fn specs() -> Vec<CommandSpec> { mod tests { use super::*; - // A synthetic template in the shape Lightroom writes (our own values). - const TEMPLATE: &str = r#"s = { - id = "00000000-0000-0000-0000-000000000001", - internalName = "Soft Glow", - title = "Soft Glow", - type = "LocalizedAdjustmentPreset", - value = { - clarity = 0.5, - clarity2012 = -0.25, - exposure = 0, - exposure2012 = 0.125, - highlights2012 = -0.5, - temperature = 0.2, - texture = -0.3, - toningHue = 240, - toningLuminance = 0.1, - toningSaturation = 0, - }, - version = 0, -}"#; - - #[test] - fn reads_a_lightroom_local_template() { - assert!(is_lr_local_template(TEMPLATE)); - let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap(); - assert_eq!(e.name, "Soft Glow"); - assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops"); - assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one"); - assert!((e.adjust.highlights + 50.0).abs() < 1e-9); - assert!((e.adjust.temp - 20.0).abs() < 1e-9); - assert!((e.adjust.texture + 30.0).abs() < 1e-9); - assert!((e.adjust.amount - 100.0).abs() < 1e-9); - assert_eq!(unmapped, vec!["toningLuminance".to_string()]); - } - - #[test] - fn develop_templates_are_not_local_ones() { - let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#; - assert!(!is_lr_local_template(develop)); - assert!(parse_lr_local_template(develop, "x").is_err()); - assert!(parse_lr_local_template("garbage {", "x").is_err()); - assert!(parse_lr_local_template(r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, "x").is_err()); - } - #[test] fn apply_keeps_amount_and_matches_ignore_it() { - let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() } }; + let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() }; let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() }; let out = e.apply(¤t); assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0)); assert!(e.matches(&out)); assert!(!e.matches(¤t)); } + + #[test] + fn partial_effects_leave_other_sliders_and_can_carry_amount() { + let e = MaskEffect { + name: "Lift".into(), + adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() }, + fields: vec!["shadows".into()], + with_amount: true, + ..Default::default() + }; + let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() }; + let out = e.apply(¤t); + assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0)); + assert!(e.matches(&out)); + assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried"); + } + + #[test] + fn hue_without_saturation_still_matches() { + let e = MaskEffect { + name: "E".into(), + adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() }, + ..Default::default() + }; + assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() })); + assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() })); + } + + #[test] + fn natural_order_and_groups() { + let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"]; + v.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]); + let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() }; + let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")]; + let order = menu_order(&list); + let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect(); + assert_eq!( + names, + vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),] + ); + } + + #[test] + fn normalized_clamps_and_tidies_fields() { + let e = MaskEffect { + name: " X ".into(), + adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() }, + fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()], + brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }), + ..Default::default() + } + .normalized(); + assert_eq!(e.name, "X"); + assert_eq!(e.adjust.exposure, 4.0); + assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried"); + assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]); + let b = e.brush.unwrap(); + assert_eq!((b.size, b.flow), (0.04, 1.0)); + } } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index d05f80abb..7b4548734 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -171,11 +171,14 @@ pub fn specs() -> Vec<CommandSpec> { return Ok(json!({"pending": true})); } let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); + // the effect last applied to a mask of this kind + let adjust = super::mask_effects::new_mask_adjust(s, &kind); let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| { masks.push(Mask { id: next, name: name.unwrap_or_else(|| format!("Mask {next}")), components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust, ..Default::default() }); *active = Some(next); @@ -245,11 +248,12 @@ pub fn specs() -> Vec<CommandSpec> { }; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); + let adjust = super::mask_effects::new_mask_adjust(s, "brush"); masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { Some(i) => i, None => { - masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() }); + masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() }); *active = Some(next); masks.len() - 1 } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 36b242741..2f27dc17c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -24,6 +24,7 @@ pub mod keywords; pub mod library; pub mod lut_profiles; pub mod manage; +pub mod mask_effect_files; pub mod mask_effects; mod masks; mod merge; diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index ec31130d4..5da980657 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -2,6 +2,7 @@ use serde_json::{Value, json}; +use super::mask_effect_files as mef; use super::{CommandSpec, always, bad, cmd, str_param}; use crate::preset_import::{group_from_dir, read_presets}; use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset}; @@ -18,28 +19,38 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { let mut imported = Vec::new(); let mut failed = Vec::new(); let mut skipped = 0usize; - // Lightroom local adjustment (brush) presets become mask effects + // local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our + // .lceffect, also inside zips — become mask effects let mut effects = Vec::new(); + let mut effect_failed = Vec::new(); for top in &paths { let from_dir = std::path::Path::new(top).is_dir(); for f in expand_preset_paths(std::slice::from_ref(top)) { - if f.to_ascii_lowercase().ends_with(".lrtemplate") { - let text = std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default(); - if super::mask_effects::is_lr_local_template(&text) { - let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); - match super::mask_effects::parse_lr_local_template(&text, &stem) { - Ok((e, unmapped)) => effects.push((e, unmapped, f.clone())), - Err(e) => failed.push(json!([f, e])), - } - continue; - } - } // presets in a folder go to a group named after it (unless the file names its own) // the path from the imported folder (inclusive) to the file's folder let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new("")); let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string()); - let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d)); - let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group)); + let rel = rel.filter(|_| from_dir); + let bytes = match mef::read_capped(&f) { + Ok(b) => b, + Err(e) => { + failed.push(json!([f, e])); + continue; + } + }; + let lower = f.to_ascii_lowercase(); + let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x)); + let before = effects.len() + effect_failed.len(); + let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed); + // a zip may hold develop presets as well + if local && !lower.ends_with(".zip") { + continue; + } + let dir_group = rel.and_then(|d| group_from_dir(&d)); + let parsed = read_presets(&f, &bytes, dir_group); + if local && parsed.is_err() && effects.len() + effect_failed.len() > before { + continue; + } match parsed { Ok(list) => { for mut it in list { @@ -58,12 +69,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { } } } - let effects = if dry { - effects.iter().map(|(e, unmapped, file)| json!({"name": e.name, "file": file, "unmapped": unmapped})).collect() - } else { - super::mask_effects::add_effects(s, effects)? - }; - Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects})) + failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e]))); + let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?; + Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers})) } fn export(s: &mut Session, p: &Value) -> Result<Value> { @@ -101,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; Lightroom local adjustment presets (.lrtemplate) become mask effects (`mask.effects`) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,file,unmapped}]}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}", always, import ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8114eaf2..b88156d9e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -259,6 +259,9 @@ pub struct Session { pub curve_presets: Vec<cmd::curves::CurvePreset>, /// Mask effect presets (the Masking panel's Effect menu). pub mask_effects: Vec<cmd::mask_effects::MaskEffect>, + /// The effect a new mask of each kind (`brush`, `linear`…) starts with: the one last applied + /// to a mask of that kind. + pub mask_tool_effects: std::collections::BTreeMap<String, String>, /// Saved colour-label name sets. pub label_sets: Vec<cmd::manage::LabelSet>, /// Look-alike signatures by content key (Find Similar). @@ -376,6 +379,7 @@ impl Session { filter_presets: Vec::new(), curve_presets: Vec::new(), mask_effects: Vec::new(), + mask_tool_effects: Default::default(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), @@ -1014,6 +1018,8 @@ mod tests_library; #[cfg(test)] mod tests_lightroom_folder; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_merge; #[cfg(test)] mod tests_organize; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 36a7378f6..5d6af424f 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -169,6 +169,8 @@ struct PrefsFile { curve_presets: Vec<crate::cmd::curves::CurvePreset>, /// Mask effect presets. mask_effects: Vec<crate::cmd::mask_effects::MaskEffect>, + /// The effect new masks of each kind start with. + mask_tool_effects: std::collections::BTreeMap<String, String>, /// Colour-label name sets. label_sets: Vec<crate::cmd::manage::LabelSet>, /// Imported LUT profiles. @@ -399,6 +401,7 @@ impl Session { self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; self.mask_effects = prefs.mask_effects; + self.mask_tool_effects = prefs.mask_tool_effects; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -635,6 +638,7 @@ impl Session { filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), mask_effects: self.mask_effects.clone(), + mask_tool_effects: self.mask_tool_effects.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 8c36aa742..a1958286d 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -88,7 +88,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String pub fn expand_preset_paths(paths: &[String]) -> Vec<String> { let preset_file = |p: &Path| { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) + ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack", crate::cmd::mask_effect_files::EFFECT_EXT].contains(&ext.as_str()) }; let mut out = Vec::new(); for p in paths { diff --git a/crates/engine/src/tests_mask_effects.rs b/crates/engine/src/tests_mask_effects.rs new file mode 100644 index 000000000..18572449d --- /dev/null +++ b/crates/engine/src/tests_mask_effects.rs @@ -0,0 +1,293 @@ +//! Mask effect presets end to end: managing them (rename, update, duplicate, delete, stars, +//! groups), applying (several masks, other photos, new masks remembering the effect), saving +//! some sliders, and importing Lightroom / Camera Raw local presets from folders and zips. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-effects-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A Lightroom local preset (synthetic values) in the Lua shape Lightroom writes. +fn lr_local(title: &str, value: &str) -> String { + format!( + "s = {{\n\tid = \"0\",\n\tinternalName = \"{title}\",\n\ttitle = \"{title}\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {{\n{value}\n\t}},\n\tversion = 0,\n}}" + ) +} + +const ZERO: &str = "\t\texposure2012 = 0,\n\t\ttoningHue = 240,\n\t\ttoningSaturation = 0,"; + +fn names(v: &Value) -> Vec<String> { + v["effects"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect() +} + +fn mask_adjust(s: &Session, id: u32) -> lightcraft_develop::LocalAdjustments { + s.develop_of(s.active().unwrap()).unwrap().masks.iter().find(|m| m.id == id).unwrap().adjust +} + +fn with_effect(s: &mut Session, name: &str, values: Value) { + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.adjust", &json!({"values": values})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": name})).unwrap(); + s.execute("mask.delete", &json!({})).unwrap(); +} + +#[test] +fn managing_effects() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Lift", json!({"shadows": 30})); + with_effect(&mut s, "Dark", json!({"exposure": -1})); + // rename (not onto another one's name) + assert!(s.execute("mask.renameEffect", &json!({"name": "lift", "to": "dark"})).is_err()); + assert!(s.execute("mask.renameEffect", &json!({"name": "nope", "to": "x"})).is_err()); + let v = s.execute("mask.renameEffect", &json!({"name": "lift", "to": "Lift Shadows"})).unwrap(); + assert_eq!(names(&v), ["Lift Shadows", "Dark"]); + // duplicate: "<name> copy", then "<name> copy 2" + let v = s.execute("mask.duplicateEffect", &json!({"name": "Dark"})).unwrap(); + assert_eq!(v["name"], "Dark copy"); + let v = s.execute("mask.duplicateEffect", &json!({"name": "Dark"})).unwrap(); + assert_eq!(v["name"], "Dark copy 2"); + assert!(s.execute("mask.duplicateEffect", &json!({"name": "Dark", "to": "dark copy"})).is_err()); + // stars and groups + s.execute("mask.favoriteEffect", &json!({"name": "Dark copy"})).unwrap(); + assert!(s.mask_effects[2].favorite); + s.execute("mask.favoriteEffect", &json!({"name": "Dark copy"})).unwrap(); + assert!(!s.mask_effects[2].favorite, "toggles"); + s.execute("mask.setEffectGroup", &json!({"names": ["Dark", "Dark copy"], "group": "Burn"})).unwrap(); + s.execute("mask.setEffectGroup", &json!({"name": "Dark copy 2", "group": "burn"})).unwrap(); + assert_eq!(s.mask_effects[3].group, "Burn", "an existing group's spelling wins"); + let v = s.execute("mask.effects", &json!({})).unwrap(); + assert_eq!(v["groups"], json!([{"name": "", "effects": ["Lift Shadows"]}, {"name": "Burn", "effects": ["Dark", "Dark copy", "Dark copy 2"]}])); + s.execute("mask.renameEffectGroup", &json!({"group": "BURN", "to": "Darken"})).unwrap(); + assert!(s.mask_effects.iter().skip(1).all(|e| e.group == "Darken")); + assert!(s.execute("mask.renameEffectGroup", &json!({"group": "Burn", "to": "x"})).is_err()); + // update with the selected mask's sliders + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"exposure": -2, "clarity": 10}})).unwrap(); + s.execute("mask.updateEffect", &json!({"name": "Dark"})).unwrap(); + let dark = &s.mask_effects[1]; + assert_eq!((dark.adjust.exposure, dark.adjust.clarity, dark.group.as_str()), (-2.0, 10.0, "Darken")); + // delete several (the menu asks first) + s.execute("mask.deleteEffect", &json!({"names": ["Dark copy", "Dark copy 2"]})).unwrap(); + assert!(s.execute("mask.deleteEffect", &json!({"names": ["Dark", "gone"]})).is_err(), "nothing deleted when one is missing"); + assert_eq!(s.mask_effects.len(), 2); + for bad in [json!({}), json!({"name": ""}), json!({"name": 3}), json!({"names": [1, 2]})] { + assert!(s.execute("mask.deleteEffect", &bad).is_err(), "{bad}"); + assert!(s.execute("mask.favoriteEffect", &bad).is_err()); + } +} + +#[test] +fn saving_some_sliders_and_amount() { + let mut s = Session::with_demo(); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"shadows": 25, "texture": -10, "amount": 60}})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Only", "fields": "changed", "amount": true, "brush": {"size": 0.1, "flow": 40}})).unwrap(); + let e = &s.mask_effects[0]; + assert_eq!(e.fields, ["shadows", "texture"]); + assert!(e.with_amount); + assert_eq!(e.brush.map(|b| (b.size, b.flow)), Some((0.1, 40.0))); + s.execute("mask.saveEffect", &json!({"name": "Pick", "fields": ["exposure", "bogus"]})).unwrap(); + assert_eq!(s.mask_effects[1].fields, ["exposure"]); + for bad in [ + json!({"name": "x", "fields": []}), + json!({"name": "x", "fields": 3}), + json!({"name": "x", "fields": "some"}), + json!({"name": "x", "brush": 7}), + ] { + assert!(s.execute("mask.saveEffect", &bad).is_err(), "{bad}"); + } + // applying a partial effect leaves the other sliders and sets Amount; it brings its brush + s.execute("mask.adjust", &json!({"values": {"shadows": 0, "texture": 0, "clarity": 50, "amount": 100}})).unwrap(); + let mid = s.active_mask.unwrap(); + let r = s.execute("mask.applyEffect", &json!({"name": "only"})).unwrap(); + assert_eq!(r["brush"]["flow"], 40.0); + let a = mask_adjust(&s, mid); + assert_eq!((a.shadows, a.texture, a.clarity, a.amount), (25.0, -10.0, 50.0, 60.0)); + // a mask that changes nothing has nothing to save as "changed" + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + assert!(s.execute("mask.saveEffect", &json!({"name": "Empty", "fields": "changed"})).is_err()); +} + +#[test] +fn applying_to_several_masks_and_photos_is_one_undo_step() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Bright", json!({"exposure": 1})); + let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + for _ in 0..2 { + s.execute("mask.add", &json!({"kind": "radial", "name": "Face"})).unwrap(); + } + s.execute("mask.add", &json!({"kind": "linear", "name": "Sky"})).unwrap(); + let all: Vec<u32> = s.develop_of(s.active().unwrap()).unwrap().masks.iter().map(|m| m.id).collect(); + let face = all[all.len() - 3]; + s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": [face, all[all.len() - 2]], "remember": false})).unwrap(); + assert_eq!(mask_adjust(&s, face).exposure, 1.0); + assert_eq!(mask_adjust(&s, *all.last().unwrap()).exposure, 0.0, "only the masks named"); + assert!(s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": [999]})).is_err()); + assert!(s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": []})).is_err()); + s.execute("mask.applyEffect", &json!({"name": "Bright", "all": true, "remember": false})).unwrap(); + assert_eq!(mask_adjust(&s, *all.last().unwrap()).exposure, 1.0); + // the other selected photo's "Face" mask gets the effect too, in the same undo step + s.execute("library.select", &json!({"ids": ids, "active": ids[1]})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial", "name": "face"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial", "name": "Other"})).unwrap(); + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + with_effect(&mut s, "Warm", json!({"temp": 20})); + let r = s.execute("mask.applyEffect", &json!({"name": "Warm", "id": face, "photos": "selected", "remember": false})).unwrap(); + assert_eq!(r["photos"], 2); + let other = s.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!(other.masks.iter().map(|m| m.adjust.temp).collect::<Vec<_>>(), [20.0, 0.0]); + s.execute("edit.undo", &json!({})).unwrap(); + let other = s.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!(other.masks[0].adjust.temp, 0.0, "undone on every photo"); + assert_eq!(mask_adjust(&s, face).temp, 0.0); +} + +#[test] +fn new_masks_start_with_the_last_effect_of_their_tool() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Dodge", json!({"exposure": 0.5})); + with_effect(&mut s, "Grad", json!({"highlights": -40})); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Dodge"})).unwrap(); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Grad"})).unwrap(); + assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["toolEffects"], json!({"brush": "Dodge", "linear": "Grad"})); + // a new brush mask (painted with no mask selected) starts as Dodge, a new gradient as Grad + s.execute("mask.select", &json!({"id": null})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]]})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).exposure, 0.5); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).highlights, -40.0); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).exposure, 0.0, "radial has none"); + // set / clear explicitly; renaming and deleting the effect follow + s.execute("mask.toolEffect", &json!({"tool": "radial", "name": "dodge"})).unwrap(); + assert!(s.execute("mask.toolEffect", &json!({"tool": "radial", "name": "nope"})).is_err()); + s.execute("mask.renameEffect", &json!({"name": "Dodge", "to": "Dodge 2"})).unwrap(); + assert_eq!(s.mask_tool_effects.get("brush").map(String::as_str), Some("Dodge 2")); + s.execute("mask.deleteEffect", &json!({"name": "Dodge 2"})).unwrap(); + assert!(!s.mask_tool_effects.contains_key("brush") && !s.mask_tool_effects.contains_key("radial")); + s.execute("mask.toolEffect", &json!({"tool": "linear"})).unwrap(); + assert!(s.mask_tool_effects.is_empty()); +} + +#[test] +fn lightroom_folder_import_groups_by_dividers_and_round_trips() { + let lib = temp_dir("folder"); + let src = lib.join("Local Adjustment Presets"); + std::fs::create_dir_all(src.join("Kit")).unwrap(); + let files: [(&str, String); 7] = [ + ("01 Burn.lrtemplate", lr_local("01 Burn", "\t\texposure2012 = -0.25,")), + ("10 ----- DETAIL ----- 10.lrtemplate", lr_local("10 ----- DETAIL ----- 10", ZERO)), + ("12 Sky.lrtemplate", lr_local("12 Sky | Cloud", "\t\thighlights2012 = -1,\n\t\tgrain = 0.06,")), + ("20 ----- RETOUCHING ----- 20.lrtemplate", lr_local("20 ----- RETOUCHING ----- 20", ZERO)), + ("21 Skin.lrtemplate", lr_local("21 Skin", "\t\tclarity2012 = -1,\n\t\tsharpness = -0.25,")), + ("Kit/Glow.lrtemplate", lr_local("Glow", "\t\tdehaze = -0.2,")), + ("Look.lrtemplate", "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }".to_string()), + ]; + for (f, text) in &files { + std::fs::write(src.join(f), text).unwrap(); + } + std::fs::write(src.join("broken.lrtemplate"), "s = { type = \"LocalizedAdjustmentPreset\", value = 5 }").unwrap(); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + // an earlier import that kept the dividers as effects + s.mask_effects.push(crate::cmd::mask_effects::MaskEffect { name: "10 ----- DETAIL ----- 10".into(), ..Default::default() }); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); + assert_eq!(r["effectDividers"].as_array().unwrap().len(), 2); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{r}"); + let groups: Vec<(String, String)> = s.mask_effects.iter().map(|e| (e.name.clone(), e.group.clone())).collect(); + assert_eq!( + groups, + [ + ("01 Burn".to_string(), "".to_string()), + ("12 Sky | Cloud".into(), "Detail".into()), + ("21 Skin".into(), "Retouching".into()), + ("Glow".into(), "Kit".into()), + ], + "the old divider effect is gone; folders and dividers give groups" + ); + let sky = &r["effects"].as_array().unwrap().iter().find(|e| e["name"] == "12 Sky | Cloud").unwrap()["unmapped"]; + assert_eq!(sky, &json!(["Grain"])); + // the effects' own import: a dry run changes nothing + let r = s.execute("mask.importEffects", &json!({"path": src.to_string_lossy(), "dryRun": true, "group": "X"})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 4); + assert!(s.mask_effects.iter().all(|e| e.group != "X")); + // export one group, everything; re-import keeps groups, stars and the kept grain + s.execute("mask.favoriteEffect", &json!({"name": "21 Skin"})).unwrap(); + let out = lib.join("backup.lceffect"); + let r = s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy(), "group": "retouching"})).unwrap(); + assert_eq!(r["count"], 1); + assert!(s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy(), "group": "none"})).is_err()); + s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy()})).unwrap(); + let before = s.mask_effects.clone(); + s.execute("mask.deleteEffect", &json!({"names": before.iter().map(|e| e.name.clone()).collect::<Vec<_>>()})).unwrap(); + // through the general import too (File ▸ Import Profiles & Presets…, drag & drop) + let r = s.execute("preset.import", &json!({"paths": [out.to_string_lossy()]})).unwrap(); + assert_eq!(r["effects"].as_array().unwrap().len(), 4); + assert_eq!(s.mask_effects, before, "round trip"); + assert_eq!(s.mask_effects[1].extra.get("grain"), Some(&json!(0.06))); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.mask_effects.len(), 4, "persisted"); + assert!(s.mask_effects[2].favorite); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn zips_and_camera_raw_xmp_presets_import() { + let lib = temp_dir("zip"); + let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local" crs:LocalSaturation="-0.5"> + <crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Mute</rdf:li></rdf:Alt></crs:Name> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let develop_xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.5" crs:Name="Bright"/></rdf:RDF></x:xmpmeta>"#; + let brush = lr_local("Brush A", "\t\tshadows2012 = 0.5,"); + let zip = crate::preset_import::tests::zip( + &[("Skin Kit/Brush A.lrtemplate", brush.as_bytes()), ("Skin Kit/Mute.xmp", xmp.as_bytes()), ("Looks/Bright.xmp", develop_xmp.as_bytes())], + true, + ); + let path = lib.join("bundle.zip"); + std::fs::write(&path, zip).unwrap(); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop XMP: {r}"); + assert_eq!(r["effects"].as_array().unwrap().len(), 2); + let got: Vec<(String, String)> = s.mask_effects.iter().map(|e| (e.name.clone(), e.group.clone())).collect(); + assert_eq!(got, [("Brush A".to_string(), "Skin Kit".to_string()), ("Mute".into(), "Skin Kit".into())]); + assert_eq!(s.mask_effects[1].adjust.saturation, -50.0); + // the effects' own import reads the zip and a lone XMP; a develop XMP named on its own fails + let mut s = Session::with_demo(); + let lone = lib.join("Mute.xmp"); + std::fs::write(&lone, xmp).unwrap(); + let dev = lib.join("Bright.xmp"); + std::fs::write(&dev, develop_xmp).unwrap(); + let r = s.execute("mask.importEffects", &json!({"paths": [path.to_string_lossy(), lone.to_string_lossy(), dev.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 3); + assert_eq!(r["failed"].as_array().unwrap().len(), 1); + assert_eq!(s.mask_effects.len(), 2, "same names replaced"); + // unreadable things are errors, never panics + std::fs::write(lib.join("junk.zip"), b"PK\x03\x04\xff\xff").unwrap(); + std::fs::write(lib.join("junk.lceffect"), b"{\"effects\": [{\"name\": \"x\", \"adjust\": {\"exposure\": \"high\"}}]}").unwrap(); + for f in ["junk.zip", "junk.lceffect", "missing.lrtemplate"] { + let r = s.execute("mask.importEffects", &json!({"path": lib.join(f).to_string_lossy()})).unwrap(); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{f}: {r}"); + let _ = s.execute("preset.import", &json!({"paths": [lib.join(f).to_string_lossy()]})); + } + assert!(s.execute("mask.importEffects", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 494b17ede..30f39a708 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -285,7 +285,7 @@ fn mask_effects_import_apply_save_export_and_persist() { let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); assert_eq!(r["effects"][0]["name"], "Brighten"); - assert_eq!(r["effects"][0]["unmapped"], json!(["grain"])); + assert_eq!(r["effects"][0]["unmapped"], json!(["Grain"])); assert_eq!(s.mask_effects.len(), 1); // apply to a mask: every slider set, Amount kept s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..5df4b66bd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1385,7 +1385,7 @@ pub fn is_model_file(path: &str) -> bool { /// Files dropped on the window that are presets rather than photos. pub fn is_preset_file(path: &str) -> bool { let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str()) + ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack", "lceffect"].contains(&ext.as_str()) } /// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same diff --git a/crates/ui-egui/src/pick.rs b/crates/ui-egui/src/pick.rs index 1032a5cd9..7ee3853ed 100644 --- a/crates/ui-egui/src/pick.rs +++ b/crates/ui-egui/src/pick.rs @@ -58,7 +58,7 @@ impl PickRequest { } /// What File ▸ Import Profiles & Presets… opens. -pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]; +pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube", "lceffect"]; /// What Import Point Curve Presets… opens. pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"]; From 1477f892b544c0bd4ab1b7a4d492fa511b43e176 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 03:45:14 +1100 Subject: [PATCH 016/315] LR-IMP-FORMATS: verify 22 more CR3 bodies; EOS R6 Mark III white balance All 110 CC0 CR3s on raw.pixls.us decode from sensor data; none uses a refused CRX variant. One CC0 file per further body (1D X III, 90D, 250D, 850D, M200, M50 II, M6 II, R, R3, R5, R6, R6 II, R6 III, R7, R10, RP, R50, R50 V, G5 X II, G7 X III, SX70, V1) is added to RAW_SAMPLES and pinned in cr3_corpus.rs, checked against the camera's embedded JPEG. The R6 Mark III writes ColorData version 66, laid out as 64/65; it lost its as-shot WB and white level. Render cache 61 -> 62. Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint. Docs: evidence.tsv + coverage re-measure (257 verified, 92% use-weighted), gaps 2/10, checklist rows, raw-parity, target-app-parity, ROADMAP, README. Sources: own black-box analysis of CC0 files; ExifTool Canon tag tables. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 2 +- ROADMAP.md | 24 ++++--- crates/engine/src/media.rs | 2 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/src/vendor/cr3.rs | 4 +- crates/raw/tests/cr3_corpus.rs | 90 +++++++++++++++++++++++++++ docs/cr3.md | 30 ++++++++- docs/gaps.md | 7 ++- docs/parity-checklist.md | 7 ++- docs/raw-parity.md | 32 +++++----- docs/raw/coverage.tsv | 44 ++++++------- docs/raw/evidence.tsv | 22 +++++++ docs/target-app-parity.md | 36 ++++++----- xtask/src/main.rs | 110 +++++++++++++++++++++++++++++++++ 14 files changed, 338 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 8884f2bd3..8f6643335 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det - **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). -- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for +- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..a70527b4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (22 more CR3 bodies verified; RAW decoding 83%; mainstream ~51%, essentials ~65%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -15,8 +15,8 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are | **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | | **Feature breadth** (weighted by use) | **~77%** | estimated | | **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | -| **Mainstream practitioner** | **~49%** (44–54%) | estimated | -| **Essentials user** | **~64%** (59–69%) | estimated | +| **Mainstream practitioner** | **~51%** (46–56%) | estimated | +| **Essentials user** | **~65%** (60–70%) | estimated | | Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated | | Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated | @@ -25,15 +25,15 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%): | Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---:|---:|---| | Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | -| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | -| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | +| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is -estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF +estimated rather than measured, CR3 decodes on 26 verified Canon bodies (rarer CRX variants still open as previews), compressed ORF and HE NEF open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below -the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of -raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows +the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (compressed ORF and HE NEF open as previews; Canon, +~30% of raw users, decodes CR3 on 26 verified bodies). It passes the alpha gate: all six core workflows (import, cull, develop, save and reopen, batch looks, export) work end to end on macOS ([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user). @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~60% | 81% of 1,446 Adobe models decode, 18% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -114,6 +114,11 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: 22 more Canon CR3 bodies verified (EOS R, RP, R3, R5, R6, R6 Mark II / III, R7, R10, R50, + R50 V, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, 1D X Mark III, PowerShot G5 X II, G7 X III, SX70, + V1): one CC0 file each pinned and checked against the camera JPEG; all 110 CC0 CR3s on raw.pixls.us + decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 257 models verified; RAW + decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -161,6 +166,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 5c9f8ee9a..15e913924 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -141,7 +141,7 @@ fn camera_levels(maker: Option<&Ifd>, bits: u8) -> CameraLevels { Some(16..=19) => (71, 329, 797), Some(32 | 33) => (85, 343, 811), Some(34 | 48) => (105, 363, 641), - Some(64 | 65) => (105, 383, 661), + Some(64..=66) => (105, 383, 661), _ => return CameraLevels::default(), }; let full = ((1u32 << u32::from(bits.clamp(1, 16))) - 1) as f32; @@ -518,7 +518,7 @@ mod tests { #[test] fn color_data_uses_versioned_as_shot_offsets() { for (version, wb_at, black_at, white_at) in - [(16, 71, 329, 797), (19, 71, 329, 797), (33, 85, 343, 811), (48, 105, 363, 641), (64, 105, 383, 661)] + [(16, 71, 329, 797), (19, 71, 329, 797), (33, 85, 343, 811), (48, 105, 363, 641), (64, 105, 383, 661), (66, 105, 383, 661)] { let m = maker(version, wb_at, black_at, white_at); let levels = camera_levels(Some(&m), 14); diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index ebf669307..910ae9596 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -124,3 +124,93 @@ fn canon_m50_craw_crop_is_the_shot_aspect() { let raw = decode(&bytes).unwrap(); assert_eq!((raw.crop.x, raw.crop.y, raw.crop.width, raw.crop.height), (1012, 12, 4000, 4000)); } + +/// Green of the decoded mosaic against the camera's embedded JPEG, on a `gw × gh` grid of the active area +/// (gamma-encoded, Pearson). A misread CRX stream (wrong tile, plane, band or quantiser) leaves a picture that +/// no longer follows the camera's own rendering; lens-corrected compact JPEGs keep it well above 0.6. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("Bayer layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.pattern[(y % 2) * 2 + x % 2] == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +/// One CC0 file per further CR3 body (raw.pixls.us; URLs and SHA-256 in `RAW_SAMPLES`, xtask/src/main.rs). +/// No external decoder was run on these: the sensor hashes pin LightCraft's own decode, every CRX stream +/// decodes, and the mosaic follows the camera's own embedded JPEG (`green_vs_camera_jpeg` on 24 × 16 cells). +/// The renders were also inspected at 100 % for tile, band and quantiser artefacts (none). See docs/cr3.md. +#[test] +fn canon_cr3_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples) + let cases: &[(&str, (usize, usize), u64)] = &[ + ("cr3-canon-m200.cr3", (6288, 4056), 0x4edf_3f62_a6ce_b2ba), + ("cr3-canon-r10.cr3", (6192, 4060), 0xc140_2394_bf17_c2a2), + ("cr3-canon-r50v.cr3", (6192, 4060), 0xd60f_f557_0928_8d27), + ("cr3-canon-r6m3.cr3", (7144, 4760), 0x47a4_cb40_1233_cc80), + ("cr3-canon-powershot-v1-craw.cr3", (5928, 3944), 0xbc54_037c_de0f_472a), + ("cr3-canon-1dxm3-craw.cr3", (5568, 3708), 0x3472_dbe7_a666_1a61), + ("cr3-canon-250d-craw.cr3", (6288, 4056), 0xf4c4_dbf8_1c93_2118), + ("cr3-canon-850d.cr3", (6288, 4056), 0xedac_1b6a_43cf_4204), + ("cr3-canon-90d-craw.cr3", (7128, 4732), 0xfaac_50ca_858e_5563), + ("cr3-canon-m50m2.cr3", (6288, 4056), 0xf36f_717a_9250_ec89), + ("cr3-canon-m6m2-craw.cr3", (7128, 4732), 0x6dc0_4c24_7f9b_6673), + ("cr3-canon-r3.cr3", (6192, 4152), 0x4728_af6f_18b1_cba1), + ("cr3-canon-r50-craw.cr3", (6192, 4060), 0x0354_0075_5fe5_a2a7), + ("cr3-canon-r5-craw.cr3", (8352, 5586), 0xdb64_0502_1fd7_ba92), + ("cr3-canon-r6-craw.cr3", (5568, 3708), 0xd3e0_567e_1ffb_07f4), + ("cr3-canon-r6m2-craw.cr3", (6188, 4120), 0x5b1d_6de8_9562_00d4), + ("cr3-canon-r7.cr3", (7128, 4732), 0xb725_758b_4410_f4af), + ("cr3-canon-r-craw.cr3", (6888, 4546), 0xf9cd_81a3_249f_cfc7), + ("cr3-canon-rp-craw.cr3", (6384, 4224), 0xf48d_f06c_a1d6_7382), + ("cr3-canon-g5xm2-craw.cr3", (5632, 3710), 0xfed8_f701_f231_442f), + ("cr3-canon-g7xm3.cr3", (5632, 3710), 0x902e_be92_2fe7_8046), + ("cr3-canon-sx70.cr3", (5344, 3950), 0x000c_7fe9_fbec_f4c2), + ]; + let mut seen = 0; + for &(name, sensor, hash) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + check_against(name, hash, sensor, None, "the pinned LightCraft decode"); + let raw = decode(&bytes).unwrap(); + let jpeg = lightcraft_raw::embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:34} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("CR3 bodies checked on {seen} files"); +} diff --git a/docs/cr3.md b/docs/cr3.md index 147a83081..0f82367a2 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -24,7 +24,7 @@ are the public prose descriptions and patents cited in embedded-JPEG fallback and its visible warning when a JPEG is present. So does any other CR3 decode error (a corrupt file, or a body whose CRX stream the decoder misreads): every CR3 opened from its embedded JPEG before this decoder, -and only four bodies are verified, so a CR3 never fails to import or load +and not every body's variants are known, so a CR3 never fails to import or load where a preview exists. HEVC-only preview tracks are recognized but are not decoded. @@ -121,6 +121,34 @@ tile count, marker headers and temporary allocations. The full sensor buffer is allocated only after the first plane decodes, and plane buffers grow row by row, so a small crafted file fails before reserving memory for the image it claims. +## Further bodies: one CC0 file each + +All 110 CR3 files on raw.pixls.us (2026-10-12, every one CC0, 31 folders) decode from sensor data; none +falls back to the preview, so none of them uses a CRX variant the decoder refuses (vertical C-RAW tiles, +partial-subband or rounded-plane coding, multi-tile 0x200, adaptive low-frequency gains) or a CR3 +sRAW / mRAW. HEIF stills are separate `.HIF` files, not CR3. One file per body is pinned in +`canon_cr3_bodies_decode_like_the_camera_jpeg` (`crates/raw/tests/cr3_corpus.rs`; URLs and SHA-256 in +`RAW_SAMPLES`, `xtask/src/main.rs`): EOS-1D X Mark III, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, +R, R3, R5, R6, R6 Mark II, R6 Mark III, R7, R10, RP, R50, R50 V and PowerShot G5 X Mark II, G7 X Mark III, +SX70 HS and V1, C-RAW where the body has a CC0 C-RAW sample. No external decoder was run: the sensor +hashes pin LightCraft's own decode, and the check is black-box against the camera: + +- the green sites of the active area, on a 24 × 16 grid, follow the camera's own embedded JPEG + (gamma-encoded Pearson r 0.93–0.99; 0.83 for the M6 Mark II and 0.71 for the G7 X Mark III, whose + JPEGs are strongly distortion-corrected; a misread stream gives r near 0); +- each render was inspected at 100 % for tile seams, band or quantiser artefacts (none); +- where raw.pixls.us has RAW and C-RAW shots of one scene, their black-subtracted sensor means correlate + on 16 × 16 blocks at 0.99–0.995 (R, R6, 250D, R50, G5 X Mark II, R5 crop mode) and 0.97 (RP); the + two R5 full-frame shots correlate at only 0.83, though each follows its own camera JPEG at r 0.98, so + the shots differ, not the decodes. The shots differ in exposure, so the means are not compared. + +The EOS R6 Mark III writes `ColorData` version 66 (3,778 words, as version 65 of the R50 V and V1). Its +as-shot white balance, per-channel black and white level sit at the version 64/65 offsets (105, 383, +661): on the CC0 files they read R, G, G, B = 2330, 1024, 1024, 1454, black 512 and white 13995, and the +render's white trim is neutral. Before, the unknown version left the R6 Mark III without as-shot white +balance (renders were green) and with the bit-depth fallback for the white level. Render cache version +62 redraws those thumbnails. + ## Colour limits CR3 carries as-shot white balance but no measured camera matrix is imported diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..52c03b53b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gaps 2 and 10: 22 more CR3 bodies verified, every CC0 CR3 decodes, EOS R6 Mark III white balance; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -16,7 +16,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| | 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | -| 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | +| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) | | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 257 of 1,446 Adobe models verified (18%), ~59% of photographers on a verified body; Nikon 5 bodies verified, Canon 34. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gaps 2 and 10 shrink: 22 more CR3 bodies verified on one CC0 file each (every CC0 CR3 on raw.pixls.us decodes), EOS R6 Mark III white balance read; Canon 34 verified, 257 models, ~59% on a verified body; CR3 gain +6.2 → +0.3 points | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..956459613 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: 22 more CR3 bodies verified on CC0 files, EOS R6 Mark III white balance; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,8 +80,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. 22 more bodies (EOS-1D X Mark III, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, R, R3, R5, R6, R6 Mark II, R6 Mark III, R7, R10, RP, R50, R50 V, PowerShot G5 X Mark II, G7 X Mark III, SX70 HS, V1) are pinned on one CC0 file each and follow their camera JPEGs; all 110 CC0 CR3s on raw.pixls.us decode from sensor data; the R6 Mark III's `ColorData` version 66 gives its as-shot white balance and levels. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases, 4 pinned EOS R5 Mark II files and 22 pinned CR3 files from 22 more Canon bodies (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: 22 more CR3 bodies verified on one CC0 file each (all 110 CC0 CR3s decode); EOS R6 Mark III `ColorData` 66 white balance; status unchanged (🟡: CRX variants without public samples remain refused) | | 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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..262abdad5 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (22 more CR3 bodies verified on one CC0 file each; EOS R6 Mark III white balance) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,16 +14,16 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| Decoded from sensor data and **verified** on a real file | **257** (18%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **920** (64%) | measured from the format rules (632 of them DNG, mostly phones) | | **Preview-only** (embedded JPEG) | **233** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | -| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 104 / 618 / 24 / 8 of 754 | measured | +| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~92%** | estimated: measured classes × written usage weights × per-format confidence | +| …of which on a body we have verified | **~59%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | -| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | +| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) | Re-measure with `python3 docs/raw/coverage.py` (standard library only): it classifies every Adobe model from the format rules in the script (which mirror `crates/raw/src`) plus the per-model evidence, @@ -43,7 +43,8 @@ committed table is stale. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; - the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference + the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files; 22 more bodies pinned on one CC0 + file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the @@ -57,7 +58,7 @@ committed table is stale. ~8 years (listed in the file), 20% to the bucket's other Adobe models. - **Per-format confidence** for unverified bodies (the chance their files decode correctly): DNG, CR2, RAF, RW2 0.95; NEF, ARW, PEF 0.9; ORF, SRW, SR2 0.8; **CR3 0.6** (the CRX decoder refuses several - coding variants and only four bodies are verified; maintainers report the R7 working). + coding variants; 26 bodies are verified, every CC0 CR3 on raw.pixls.us decodes). ## By maker (measured) @@ -81,13 +82,13 @@ committed table is stale. | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **257 (18%)** | **920 (64%)** | **233 (16%)** | **36 (2%)** | | ## By use (estimated) | Bucket | Weight | Camera fully works | On a verified body | Why | |---|---:|---:|---:|---| -| Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | +| Canon | 33% | 94% | 72% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | | Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | | Fujifilm | 10% | 91% | 45% | | @@ -98,7 +99,7 @@ committed table is stale. | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | | Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **85%** | **42%** | | +| **Weighted** | 100% | **92%** | **59%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually checked, and Canon, the largest bucket, is the weakest of the big four. @@ -224,10 +225,10 @@ drone DNG users (~9% of photographers by the weights above); Adobe reaches nearl | Part | Weight | Score | Basis | |---|---:|---:|---| -| Decoding | 60% | **77%** | use-weighted 85% × 0.9 (76.5%) for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | +| Decoding | 60% | **83%** | use-weighted 92% × 0.9 (82.8%) for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | | Colour | 25% | **35%** | see [Colour](#colour) | | Lens profiles | 15% | **10%** | see [Lens profiles](#lens-profiles) | -| **RAW readiness** | 100% | **~56%** (range 50–60%) | 45.9 + 8.75 + 1.5; demosaic and highlight quality unmeasured | +| **RAW readiness** | 100% | **~60%** (range 54–64%) | 49.7 + 8.75 + 1.5; demosaic and highlight quality unmeasured | The previous estimate (earlier on 2026-10-10) was ~50%, with decoding at ~78% weighted by maker from the format list alone. Measured model by model, decoding comes out about the same; the change is @@ -243,7 +244,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | # | Camera / variant | Gain (points of photographers) | Hours | Gain per hour | Needs | |---:|---|---:|---:|---:|---| | 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) | -| 2 | CR3: remaining CRX variants, then verify the other 14 popular CR3 bodies | +6.2 | 25–45 | ~0.19 | samples per body (raw.pixls.us has most) | +| 2 | CR3: remaining CRX variants (no public file uses them) | +0.3 | 10–25 | ~0.02 | files that open as a preview, from users | | 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples | | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 22 more CR3 bodies verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; all 110 CC0 CR3s decode); EOS R6 Mark III ColorData 66 white balance: 257 verified, 920 unverified; use-weighted 92% (59% on verified bodies); Canon 73% → 94%; decoding 83%, RAW readiness ~56% → ~60%; CR3 gain +6.2 → +0.3 | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..c214a1832 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -124,7 +124,7 @@ Canon EOS-1Ds Mark II CR2 2005 unverified CR2 lossless JPEG decoder Canon EOS-1Ds Mark III CR2 2007 unverified CR2 lossless JPEG decoder Canon EOS-1D X CR2 2012 unverified CR2 lossless JPEG decoder Canon EOS-1D X Mark II CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS-1D X Mark III CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS-1D X Mark III CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS 5D CR2 2005 unverified CR2 lossless JPEG decoder Canon EOS 5D Mark II CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS-5D Mark III CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -146,9 +146,9 @@ Canon EOS 60Da CR2 2012 unverified CR2 lossless JPEG decoder Canon EOS 70D CR2 2013 unverified CR2 lossless JPEG decoder Canon EOS 77D (EOS 9000D) CR2 2015 unverified CR2 lossless JPEG decoder Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 90D CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 90D CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS D30 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS D60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 preview CRW is opened from its embedded JPEG only @@ -166,7 +166,7 @@ Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 unverified CR2 lossless JPE Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 unverified CR2 lossless JPEG decoder Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 unverified CR2 lossless JPEG decoder Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 unverified CR2 lossless JPEG decoder Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 unverified CR2 lossless JPEG decoder Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 unverified CR2 lossless JPEG decoder @@ -174,33 +174,33 @@ Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 unverified C Canon EOS C50 CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon EOS M CR2 2012 unverified CR2 lossless JPEG decoder Canon EOS M2 CR2 2013 unverified CR2 lossless JPEG decoder -Canon EOS M200 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M200 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS M3 CR2 2015 unverified CR2 lossless JPEG decoder Canon EOS M5 CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS M50 Mark II CR3 2020 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M50 Mark II CR3 2020 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS M6 CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS M6 Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M6 Mark II CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS M10 CR2 2015 unverified CR2 lossless JPEG decoder Canon EOS M50 (EOS Kiss M) CR3 2017 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon EOS M100 CR2 2017 unverified CR2 lossless JPEG decoder -Canon EOS R (** see note) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R (** see note) CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R1 CR3 2023 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R3 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R5 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R3 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R5 C CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon EOS R5 Mark II CR3 2023 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files -Canon EOS R6 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R6 V CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R6 Mark II CR3 2022 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R6 Mark III CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R7 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 Mark II CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 Mark III CR3 2025 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R7 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R8 Mark II CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R10 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R10 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS Ra CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS RP CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS RP CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R8 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS R50 CR3 2022 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R50 V CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R50 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 V CR3 2024 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R100 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon PowerShot 600 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A5 CRW 2003 preview CRW is opened from its embedded JPEG only @@ -214,11 +214,11 @@ Canon PowerShot G3 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G3 X CR2 2015 unverified CR2 lossless JPEG decoder Canon PowerShot G5 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G5 X CR2 2015 unverified CR2 lossless JPEG decoder -Canon PowerShot G5 X Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot G5 X Mark II CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon PowerShot G6 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G7 X CR2 2013 unverified CR2 lossless JPEG decoder Canon PowerShot G7 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder -Canon PowerShot G7 X Mark III CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot G7 X Mark III CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon PowerShot G9 CR2 2007 unverified CR2 lossless JPEG decoder Canon PowerShot G9X CR2 2015 unverified CR2 lossless JPEG decoder Canon PowerShot G9 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder @@ -245,8 +245,8 @@ Canon PowerShot S120 CR2 2013 unverified CR2 lossless JPEG decoder Canon PowerShot SX1 IS CR2 2008 unverified CR2 lossless JPEG decoder Canon PowerShot SX50 CR2 2012 unverified CR2 lossless JPEG decoder Canon PowerShot SX60 HS CR2 2013 unverified CR2 lossless JPEG decoder -Canon PowerShot SX70 HS CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon PowerShot V1 CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot SX70 HS CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot V1 CR3 2024 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Casio EXILIM EX-10 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-100 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-100F DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..2cb3f8c34 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -13,6 +13,28 @@ Canon EOS M50 (EOS Kiss M) verified corpus: crates/raw/tests/cr3_corpus.rs (exac Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon EOS R5 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS-1D X Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 90D verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M200 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M50 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M6 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R (** see note) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R3 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R7 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R10 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS RP verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 V verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G5 X Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G7 X Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot SX70 HS verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot V1 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..57e5a5e92 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (22 more CR3 bodies verified: RAW decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -15,8 +15,8 @@ have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md | **Feature breadth**, checklist | **81.1%** of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | | **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | | **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | -| **Mainstream practitioner** | **~49%** (range 44–54%) | estimated: weekly areas of a working photographer, depth 71.8% × 0.804 × colour 0.85 | -| **Essentials user** | **~64%** (range 59–69%) | estimated: core features at default settings, depth 77.4% × 0.830 | +| **Mainstream practitioner** | **~51%** (range 46–56%) | estimated: weekly areas of a working photographer, depth 74.0% × 0.804 × colour 0.85 | +| **Essentials user** | **~65%** (range 60–70%) | estimated: core features at default settings, depth 78.0% × 0.830 | | Remaining to **beta** | **~340–600 h** | estimated; ~60% parallelizes | | Remaining to **full parity** | **~1,000–1,800 h** | estimated; ~70% parallelizes | | **Stage** | **alpha** | see [Stage](#stage) | @@ -67,7 +67,7 @@ from the checklist sections and the deep-dive documents. | Area | Weight | Breadth | Ready | Remaining | Notes | |---|---:|---:|---:|---:|---| | Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | -| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | +| RAW decoding and camera coverage | 15% | 81% | 83% | 100–200 h | [raw-parity.md](raw-parity.md), measured model by model: 92% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | | Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | | Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | | Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | @@ -79,7 +79,7 @@ from the checklist sections and the deep-dive documents. | Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap | | Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ | | Video | 1% | 0% | 0% | 25–45 h | catalogued only | -| **Weighted** | 100% | **~77%** | **~61%** (60.6%) | **600–1,100 h** | | +| **Weighted** | 100% | **~77%** | **~61%** (61.5%) | **600–1,100 h** | | ## AI @@ -117,8 +117,8 @@ work. Each has its own hours to ~95%, calibrated as in [How hours are calibrated | Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---:|---:|---| | Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | -| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | -| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | +| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | ### Full target (ready for real work): ~61% @@ -129,7 +129,7 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): | Dimension | Weight | Ready | |---|---:|---:| | Features: the other 11 areas, weighted depth from [Features](#features) | 45% | 60.9% | -| RAW decoding and camera coverage | 9% | 77% (85% use-weighted × 0.9) | +| RAW decoding and camera coverage | 9% | 83% (92% use-weighted × 0.9) | | Camera colour and default rendering | 6% | 35% | | UI/UX fidelity | 8% | 70% | | File formats (non-raw) | 6% | 70% | @@ -139,7 +139,7 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): | Stability | 8% | 60% | | Platforms | 4% | 60% | | Ecosystem / plug-ins | 2% | 15% | -| **Weighted sum** | 100% | **60.7% ≈ 61%** (range 56–66%) | +| **Weighted sum** | 100% | **61.2% ≈ 61%** (range 56–66%) | Mainstream comes out lower than full: its scope is the raw-develop core, where LightCraft is weakest (camera colour, CR3 coverage), and its discounts are multiplicative, while the full sum is lifted by @@ -160,7 +160,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). | File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | -### Mainstream practitioner: ~49% +### Mainstream practitioner: ~51% A working photographer (wedding, portrait, events, landscape) shooting raw on one current camera, weekly: import, cull, develop, a few masks, sync a look, export. Excluded: Classic output modules, @@ -171,20 +171,20 @@ than the user's own. |---|---:|---:| | Import and library | 18% | 80% | | Cull: views, compare, zoom, ratings | 12% | 70% | -| Raw decoding for their camera | 27% | 85% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | +| Raw decoding for their camera | 27% | 92% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | | Global develop adjustments | 15% | 70% | | Crop, geometry, lens corrections | 6% | 50% | | Masking (manual, subject / sky; no generative) | 8% | 45% | | Heal / remove (no generative), denoise | 5% | 40% | | Presets, copy / paste / sync | 5% | 85% | | Export | 4% | 75% | -| **Weighted depth** | 100% | **72.1%** | +| **Weighted depth** | 100% | **74.0%** | Colour fidelity is a written discount, **×0.85**: most raws start near the camera's own look (median ΔE ~5), about 10% far off (ΔE ~18), none calibrated to Adobe Standard, so a working photographer -corrects colour by hand (33 open colour issues). 72.1% × 0.804 × 0.85 = **~49%** (range 44–54%). +corrects colour by hand (33 open colour issues). 74.0% × 0.804 × 0.85 = **~51%** (range 46–56%). -### Essentials user: ~64% +### Essentials user: ~65% A casual photographer with default settings: import, look through, rate, fix a few photos with the basic sliders, crop, apply a preset, undo, export JPEGs. @@ -199,11 +199,11 @@ basic sliders, crop, apply a preset, undo, export JPEGs. | Crop and straighten | 10% | 80% | | Presets and profiles | 8% | 80% | | Undo and history | 5% | 90% | -| Their camera's raws: decode × default look | 10% | 64% (85% whose camera fully works × colour factor 0.75) | +| Their camera's raws: decode × default look | 10% | 69% (92% whose camera fully works × colour factor 0.75) | | Export JPEG | 10% | 85% | -| **Weighted depth** | 100% | **77.5%** | +| **Weighted depth** | 100% | **78.0%** | -77.5% × 0.830 = **~64%** (range 59–69%). +78.0% × 0.830 = **~65%** (range 60–70%). ### User evidence (GitHub issues, excluding the four maintainers) @@ -274,6 +274,7 @@ most of its list; what remains is the data-heavy part (colour, lenses, models) i | Date | Checklist (all / P0 / P1 / P2) | Ready for real work | Remaining to full parity | Source | |---|---|---|---|---| +| 2026-10-12 | 81.1% (unchanged) | ~61% full · ~51% mainstream · ~65% essentials | unchanged | 22 more CR3 bodies verified (raw-parity.md) | | 2026-10-10 (RAW) | 81.1% (unchanged) | ~61% full · ~49% mainstream · ~64% essentials | unchanged | RAW measured model by model against Adobe's list; RAW decode and colour as explicit rows | | 2026-10-10 (final) | 81.1% (unchanged) | ~59% full (weighted sum) · ~51% mainstream · ~63% essentials | full 1,000–1,800 h · mainstream 450–840 h · essentials 160–300 h | method aligned with the standard | | 2026-10-10 (later) | 81.1% (unchanged) | ~46% full · ~51% mainstream · ~63% essentials | 1,000–1,800 h | full × discounts (superseded) | @@ -297,6 +298,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..e5b7c363b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -545,6 +545,116 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/7884/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20CRAW%20%283:2%29.CR3", "6a597497776a6abbfab8fcf12d0086ac7a3f8afea2879d61accf6bbf48d357d7", ), + ( + "cr3-canon-m200.cr3", + "https://raw.pixls.us/getfile.php/8469/nice/Canon%20-%20EOS%20M200%20-%20RAW%20%283:2%29.CR3", + "9b900c04ae37ba827b7894e66907657a0b174a74855c40039a8ca0c95583f141", + ), + ( + "cr3-canon-r10.cr3", + "https://raw.pixls.us/getfile.php/5871/nice/Canon%20-%20EOS%20R10%20-%203:2.CR3", + "5d2e076d38e7835d4c4f13dc2030a913cc573a31d7199b60777d23a25ccc3ef9", + ), + ( + "cr3-canon-r50v.cr3", + "https://raw.pixls.us/getfile.php/8403/nice/Canon%20-%20EOS%20R50%20V%20-%20RAW%20%283:2%29.CR3", + "955ff98710505498ce7f98cbcc5875793ef2d6941b3b5692ed3baf2817ee7c9f", + ), + ( + "cr3-canon-r6m3.cr3", + "https://raw.pixls.us/getfile.php/8961/nice/Canon%20-%20EOS%20R6%20Mark%20III%20-%20RAW%20%283:2%29.CR3", + "e491e4bb960961b5fa299361bf698310a80cbe7b15d30d8dad3bb21bc5457dab", + ), + ( + "cr3-canon-powershot-v1-craw.cr3", + "https://raw.pixls.us/getfile.php/8500/nice/Canon%20-%20PowerShot%20V1%20-%20CRAW%20%283:2%29.CR3", + "0a0abfd81b3a34fa513ac8ed24c809275160d65b07e892c2eb4353b77c535a31", + ), + ( + "cr3-canon-1dxm3-craw.cr3", + "https://raw.pixls.us/getfile.php/4654/nice/Canon%20-%20EOS-1D%20X%20Mark%20III%20-%203:2.CR3", + "e5755d1414a2dd51fb0120b272058f00b0199861646070c00304d927eb119560", + ), + ( + "cr3-canon-250d-craw.cr3", + "https://raw.pixls.us/getfile.php/3383/nice/Canon%20-%20EOS%20250D%20-%20C-RAW%20%283:2%29.CR3", + "69bdd267defb3d676f41f62a5e5ea65651669a04191cf4e89740917c59cedbcc", + ), + ( + "cr3-canon-850d.cr3", + "https://raw.pixls.us/getfile.php/4867/nice/Canon%20-%20EOS%20850D%20-%203:2.CR3", + "47f7747b74073515bdbd65015738bc31947504d68255c5377baa21f179df2486", + ), + ( + "cr3-canon-90d-craw.cr3", + "https://raw.pixls.us/getfile.php/4650/nice/Canon%20-%20EOS%2090D%20-%203:2.CR3", + "c8c918b2d1a53e64d7d5009ebac89a920dbba5f766c2f396bb4ec95312b21034", + ), + ( + "cr3-canon-m50m2.cr3", + "https://raw.pixls.us/getfile.php/4430/nice/Canon%20-%20EOS%20M50%20Mark%20II%20-%203:2.CR3", + "1a3c11d85f48b8c644664b505f69fbdd75eb51270bb8ab556dbc660f0487e957", + ), + ( + "cr3-canon-m6m2-craw.cr3", + "https://raw.pixls.us/getfile.php/3714/nice/Canon%20-%20EOS%20M6%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", + "94635c6f91e7d743cee6881cc2519992ae9f9031fd86b49e5d0f16edf5961b55", + ), + ( + "cr3-canon-r3.cr3", + "https://raw.pixls.us/getfile.php/5051/nice/Canon%20-%20EOS%20R3%20-%203:2.CR3", + "295d46a4d5383895e63126f67000d4352a5bb425ee4d8b16eb6b6c284bdf7994", + ), + ( + "cr3-canon-r50-craw.cr3", + "https://raw.pixls.us/getfile.php/6646/nice/Canon%20-%20EOS%20R50%20-%20CRAW%20%283:2%29.CR3", + "b79af9ffa184dd740e8af7a9f451f7150053d7e456b3435ca2e43bbbf5f65026", + ), + ( + "cr3-canon-r5-craw.cr3", + "https://raw.pixls.us/getfile.php/4698/nice/Canon%20-%20EOS%20R5%20-%203:2.CR3", + "2deae46b816e56ba91ae7a3892ca121290e12d5b22afb11f5b05a3d8f09f759f", + ), + ( + "cr3-canon-r6-craw.cr3", + "https://raw.pixls.us/getfile.php/4660/nice/Canon%20-%20EOS%20R6%20-%203:2.CR3", + "4775df76433b1e5844bc7a42b95d93efc8438c0a4cd9681e120e23fe3e3007e8", + ), + ( + "cr3-canon-r6m2-craw.cr3", + "https://raw.pixls.us/getfile.php/6409/nice/Canon%20-%20EOS%20R6%20Mark%20II%20-%203:2.cr3", + "409eb724212abde94e18fbd3170abf3264951fab13384c36cbf6fb4f22ea4201", + ), + ( + "cr3-canon-r7.cr3", + "https://raw.pixls.us/getfile.php/5633/nice/Canon%20-%20EOS%20R7%20-%203:2.CR3", + "887439cb1a45becd6a5c85fe75ae10e6c520a9f13a8b9ed077c6cf5d7c37700c", + ), + ( + "cr3-canon-r-craw.cr3", + "https://raw.pixls.us/getfile.php/4543/nice/Canon%20-%20EOS%20R%20-%203:2.CR3", + "1b3efe0664a28a0decb53e225cbfecdbc0dd27d85abdc4683fc2a4add5129b4d", + ), + ( + "cr3-canon-rp-craw.cr3", + "https://raw.pixls.us/getfile.php/4672/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", + "bd3270610593d37ff8a399908bb9406a4e32f9151d55bf19ac1cf8f10d787b46", + ), + ( + "cr3-canon-g5xm2-craw.cr3", + "https://raw.pixls.us/getfile.php/3506/nice/Canon%20-%20PowerShot%20G5%20X%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", + "16d7b944344004c866bf2f84c5673969e76adfe16e5fb4388da48c72bdd58058", + ), + ( + "cr3-canon-g7xm3.cr3", + "https://raw.pixls.us/getfile.php/6053/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20III%20-%203:2.CR3", + "63ce623055dd2ac7374e0b1c645a4eabc7e181cd12c2039625f64b4e63a5ee86", + ), + ( + "cr3-canon-sx70.cr3", + "https://raw.pixls.us/getfile.php/3276/nice/Canon%20-%20PowerShot%20SX70%20HS%20-%20RAW%2C%204:3%20%284:3%29.CR3", + "3b346078836f933f505338ba45965be17a39cac82bfffc7006db07e3af944839", + ), ( "dng-adobe-canon-5d3-linear-lj92.dng", "https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG", From 32819d4900806083f9ede6f2bae2f4a9ebf4c036 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 04:04:01 +1100 Subject: [PATCH 017/315] LR-MASK-BRUSH: pen pressure scales brush size and flow Brush strokes record a pen pressure (0..1) per point from egui touch force while Brush > Pen Pressure is on (default). Each dab's radius goes from a tenth to all of the brush, and its flow is proportional to the pressure, in the committed stroke and the live preview alike. A mouse stroke stores no pressure, so documents and renders are unchanged. The GPU mask kernel leaves pressure strokes to the CPU. mask.brushStroke takes `pressure`; bad lists are refused. Also satisfies clippy 1.99's chunks_exact_to_as_chunks in spots.rs. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/develop/src/settings.rs | 15 ++++- crates/engine/src/cmd/masks.rs | 18 +++++- crates/engine/src/tests.rs | 17 +++++ crates/gpu/src/render.rs | 3 +- crates/pipeline/src/masks.rs | 93 ++++++++++++++++++++++++++-- crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/locales/de.json | 2 + crates/ui-egui/locales/es.json | 2 + crates/ui-egui/locales/fr.json | 2 + crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/pt-br.json | 2 + crates/ui-egui/locales/ru.json | 2 + crates/ui-egui/locales/uk.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/brush_live.rs | 57 +++++++++++++---- crates/ui-egui/src/panels/detail.rs | 77 ++++++++++++++++++++--- crates/ui-egui/src/panels/masking.rs | 8 +++ crates/ui-egui/src/state.rs | 3 + docs/gaps.md | 5 +- docs/hardware-parity.md | 7 ++- docs/parity-checklist.md | 5 +- 22 files changed, 294 insertions(+), 36 deletions(-) diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 1895776b3..a40f094c2 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -900,11 +900,24 @@ pub struct BrushStroke { pub density: f64, pub erase: bool, pub auto_mask: bool, + /// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's + /// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the + /// end of a shorter list takes the last value. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pressure: Vec<f32>, +} + +impl BrushStroke { + /// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1). + pub fn pressure_at(&self, i: usize) -> f32 { + let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0); + if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) } + } } impl Default for BrushStroke { fn default() -> Self { - Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false } + Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() } } } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index ae8b862b6..c6a8eb286 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -223,7 +223,7 @@ pub fn specs() -> Vec<CommandSpec> { "Brush Stroke", [], None, - "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}", + "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full)}", has_active, |s, p| { let pts: Vec<Point> = p @@ -233,6 +233,7 @@ pub fn specs() -> Vec<CommandSpec> { .iter() .filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))) .collect(); + let pts_len = pts.len(); let d = BrushStroke::default(); let stroke = BrushStroke { points: pts, @@ -242,6 +243,7 @@ pub fn specs() -> Vec<CommandSpec> { density: f64_or(p, "density", d.density), erase: bool_or(p, "erase", false), auto_mask: bool_or(p, "autoMask", false), + pressure: pressure_list(p.get("pressure"), pts_len)?, }; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); @@ -999,3 +1001,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { c(&mut a.amount, 0.0, 200.0); a } + +/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null` +/// is no pressure data. +fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> { + let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?; + if list.len() > points { + return Err(bad("mask.brushStroke", "pressure has more values than points")); + } + list.iter() + .map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32)) + .collect::<Option<Vec<f32>>>() + .ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1")) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6aad89b31..9090bf5c5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -280,6 +280,23 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +#[test] +fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() { + let mut s = demo(); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": [0.25, 7.0]})).unwrap(); + let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") }; + assert_eq!(strokes[0].pressure, vec![0.25, 1.0], "kept, clamped to 0..1"); + for bad in [json!([0.5, 0.5, 0.5]), json!("hard"), json!([0.5, "x"])] { + assert!(s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": bad})).is_err(), "{bad}"); + } + s.execute("mask.brushStroke", &json!({"points": [[0.4, 0.4]]})).unwrap(); + let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") }; + assert!(strokes[1].pressure.is_empty(), "a mouse stroke has no pressure data"); + let json = serde_json::to_string(&strokes[1]).unwrap(); + assert!(!json.contains("pressure"), "and stores none: {json}"); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 44458350f..594e5068f 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -987,7 +987,8 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } MaskShape::Brush { strokes } => { let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect(); - if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS { + // pen pressure varies radius and flow per dab, which the kernel doesn't take: the CPU rasterizes it + if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS || dabs.iter().any(|d| !d.pressure.is_empty()) { None } else if dabs.iter().any(|d| d.auto) { // Auto Mask: stroke by stroke (each one refined before it's combined) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 0628a6a6f..984591fe1 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -438,6 +438,44 @@ pub struct BrushDabs { pub erase: bool, /// Auto Mask: paint only pixels like the one under each dab, then snap to edges. pub auto: bool, + /// Pen pressure (0..1) of each dab, parallel to `dabs`; empty when the stroke has no pressure data (full pressure). + pub pressure: Vec<f32>, +} + +impl BrushDabs { + /// Radius, hard core and flow of dab `i`, pen pressure applied ([`pressure_radius`], [`pressure_flow`]). + pub fn dab(&self, i: usize) -> (f64, f64, f32) { + match self.pressure.get(i) { + Some(&p) => { + let r = pressure_radius(self.r, p); + (r, if self.r > 0.0 { self.hard * r / self.r } else { self.hard }, pressure_flow(self.flow, p)) + } + None => (self.r, self.hard, self.flow), + } + } +} + +/// The radius of a dab of a brush of radius `r` (pixels) painted at pen pressure `p` (0..1): from a tenth of the +/// brush at the lightest touch to the full brush at full pressure, never below half a pixel. +#[inline] +pub fn pressure_radius(r: f64, p: f32) -> f64 { + let p = if p.is_nan() { 1.0 } else { f64::from(p.clamp(0.0, 1.0)) }; + (r * (0.1 + 0.9 * p)).max(0.5).min(r.max(0.5)) +} + +/// The flow (0..1) of a dab painted at pen pressure `p` (0..1): proportional to the pressure. +#[inline] +pub fn pressure_flow(flow: f32, p: f32) -> f32 { + let p = if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) }; + flow * p +} + +/// Pressures for the `added` dabs [`push_dabs`] just laid from a sample at pressure `from` to one at `to`: evenly +/// interpolated, the last one at `to`, as the dabs themselves are placed. +pub fn push_pressures(pressure: &mut Vec<f32>, added: usize, from: f32, to: f32) { + for k in 1..=added { + pressure.push(from + (to - from) * k as f32 / added as f32); + } } /// Output pixels a brush sample must move from the last dab to count as movement: below this the @@ -454,11 +492,22 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa // long the button is held: a sample where the pointer hasn't moved (a stationary hold records // one per frame) lays no dab, so waiting in place adds nothing (#518). let mut dabs: Vec<Point> = Vec::new(); + let mut pressure: Vec<f32> = Vec::new(); + let with_pressure = !s.pressure.is_empty(); let mut prev = None; - for p in &s.points { + let mut prev_p = 1.0; + for (i, p) in s.points.iter().enumerate() { let q = to_out.apply(*p); + let before = dabs.len(); if push_dabs(&mut dabs, prev, q, r) { + let pq = s.pressure_at(i); + if with_pressure { + // the first dab of the stroke has nothing to interpolate from + let from = if prev.is_some() { prev_p } else { pq }; + push_pressures(&mut pressure, dabs.len() - before, from, pq); + } prev = Some(q); + prev_p = pq; } } BrushDabs { @@ -469,6 +518,7 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase, auto: s.auto_mask, + pressure, } } @@ -594,9 +644,11 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i return; } for s in strokes { - let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h); + let bd = brush_dabs(s, frame, w, h); + let (dens, erase, auto) = (bd.density, bd.erase, bd.auto); let mut stroke_alpha = Plane::new(w, h); - for d in &dabs { + for (k, d) in bd.dabs.iter().enumerate() { + let (r, hard, flow) = bd.dab(k); let refc = auto.then(|| { let j = dab_pixel(*d, w, h); (log_l.data[j], chromaticity(img.data[j])) @@ -621,7 +673,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i } } if auto { - let (sigma, eps) = auto_refine(r); + let (sigma, eps) = auto_refine(bd.r); let q = guided_cross(log_l, &stroke_alpha, sigma, eps); // the refinement fills gaps along edges; it never takes paint away stroke_alpha = stroke_alpha.zip_map(&q, f32::max); @@ -792,6 +844,39 @@ mod tests { assert!(v.len() <= 1_000_002, "{}", v.len()); } + /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile + /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. + #[test] + fn pen_pressure_scales_dab_size_and_flow() { + let (w, h) = (400, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; + let alpha = |pressure: Vec<f32>| { + let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let full = alpha(vec![]); + assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); + // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right + let ramp = alpha(vec![0.1, 1.0]); + let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); + assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); + assert!(width(&ramp, 350) + 2 >= width(&full, 350)); + assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); + let faint = alpha(vec![0.2]); + assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); + assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); + for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { + let a = alpha(hostile); + assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } + assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); + assert_eq!(pressure_radius(10.0, 0.0), 1.0); + assert_eq!(pressure_flow(0.8, 2.0), 0.8); + } + #[test] fn brush_and_subtract() { let f = frame(200, 100); diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..494a17c6b 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -25,6 +25,7 @@ "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Ein Filter blendet die Fotos dieser Ansicht aus: Ändere ihn oder lösche ihn (Ansicht → Filter löschen)", "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Eine Bibliothek ist nur in einem Programm gleichzeitig geöffnet (Issue #99). Wenn LightCraft oder `lightcraft-cli` sie bereits geöffnet hat,", "A one-time download of about": "Ein einmaliger Download von etwa", + "A pen's pressure sets the size and flow of the brush": "Der Druck eines Stifts bestimmt Größe und Fluss des Pinsels", "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Ein teilweise fehlgeschlagener Speichervorgang durch einen vollen Datenträger oder eine getrennte Netzwerkfreigabe hinterlässt keinen scheinbar beschädigten Katalog", "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Ein schlafendes NAS, eine getrennte Netzwerkfreigabe oder ein anlaufendes USB-Laufwerk blockieren das Fenster nicht mehr (Issue #104).", "AI Mask Model Status": "Status des KI-Maskenmodells", @@ -1030,6 +1031,7 @@ "Paste from Previous": "Vom vorherigen Foto einfügen", "Paste pages": "Seiten einfügen", "Pastel Haze": "Pastelldunst", + "Pen Pressure": "Stiftdruck", "People": "Personen", "Percent": "Prozent", "Percentage": "Prozentsatz", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..cc6c62589 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -13,6 +13,7 @@ "A blank template keeps the original names.": "Una plantilla vacía conserva los nombres originales.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro oculta las fotos de esta vista: cambiar el filtro o borrarlo (Vista → Borrar filtros)", "A one-time download of about": "Una única descarga de aproximadamente", + "A pen's pressure sets the size and flow of the brush": "La presión de un lápiz determina el tamaño y el flujo del pincel", "AI Mask Model Status": "Estado del modelo de máscaras con IA", "About": "Acerca de", "About LightCraft": "Acerca de LightCraft", @@ -986,6 +987,7 @@ "Paste from Previous": "Pegar de la anterior", "Paste pages": "Pegar páginas", "Pastel Haze": "Bruma pastel", + "Pen Pressure": "Presión del lápiz", "People": "Personas", "Percent": "Porcentaje", "Percentage": "Porcentaje", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..98dcbf96b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -13,6 +13,7 @@ "A blank template keeps the original names.": "Un modèle vide conserve les noms d'origine.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtre masque les photos de cette vue : modifiez-le ou effacez-le (Affichage → Effacer les filtres)", "A one-time download of about": "Un téléchargement unique d'environ", + "A pen's pressure sets the size and flow of the brush": "La pression d'un stylet règle la taille et le flux du pinceau", "AI Mask Model Status": "État du modèle de masques IA", "About": "À propos", "About LightCraft": "À propos de LightCraft", @@ -981,6 +982,7 @@ "Paste from Previous": "Coller depuis la précédente", "Paste pages": "Coller les pages", "Pastel Haze": "Brume pastel", + "Pen Pressure": "Pression du stylet", "People": "Personnes", "Percent": "Pourcentage", "Percentage": "Pourcentage", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..b0e742b65 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "テンプレートが空の場合は元の名前のままです", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "フィルターで写真が非表示になっています。条件を変更するか、「表示 → フィルターを解除」を選んでください", "A one-time download of about": "1回限りのダウンロード:約", + "A pen's pressure sets the size and flow of the brush": "ペンの筆圧でブラシのサイズと流量が変わります", "AI Mask Model Status": "AIマスクモデルの状態", "About": "LightCraftについて", "About LightCraft": "LightCraftについて", @@ -636,6 +637,7 @@ "Paste Settings from Previous": "前の写真の設定を貼り付け", "Paste from Previous": "前の写真から貼り付け", "Pastel Haze": "パステルヘイズ", + "Pen Pressure": "筆圧", "People": "人物", "Person": "人物", "Pet Eye Catchlight": "ペットの目のキャッチライト", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..de2f44788 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "Um modelo em branco mantém os nomes originais.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Um filtro está ocultando as fotos desta exibição: altere-o ou limpe-o (Exibir → Limpar filtros)", "A one-time download of about": "Um download único de cerca de", + "A pen's pressure sets the size and flow of the brush": "A pressão de uma caneta define o tamanho e o fluxo do pincel", "AI Mask Model Status": "Status do modelo de máscaras de IA", "About": "Sobre", "About LightCraft": "Sobre o LightCraft", @@ -996,6 +997,7 @@ "Paste from Previous": "Colar da anterior", "Paste pages": "Colar páginas", "Pastel Haze": "Névoa pastel", + "Pen Pressure": "Pressão da caneta", "People": "Pessoas", "Percent": "Porcentagem", "Percentage": "Porcentagem", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..1b1913ef7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -4,6 +4,7 @@ "0: nothing is rejected.": "0: ничего не отклоняется.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фильтр скрывает фото в этом представлении: измените его или сбросьте (Вид → Сбросить фильтры)", "A one-time download of about": "Однократная загрузка около", + "A pen's pressure sets the size and flow of the brush": "Нажим пера задаёт размер и поток кисти", "About LightCraft": "О программе LightCraft", "Activity": "Активность", "Actual size": "Реальный размер", @@ -824,6 +825,7 @@ "Paste Settings from Previous": "Вставить настройки из предыдущего", "Paste pages": "Вставить страницы", "Pastel Haze": "Пастельная дымка", + "Pen Pressure": "Нажим пера", "Percent": "Проценты", "Percentage": "Процент", "Performance": "Производительность", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..95b545315 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1,5 +1,6 @@ { "% of all commits": "% усіх комітів", + "A pen's pressure sets the size and flow of the brush": "Натиск пера задає розмір і потік пензля", "About": "Про програму", "Add": "Додати", "Add to": "Додати до", @@ -121,6 +122,7 @@ "No contributor data was built into this copy.": "У цю копію не вбудовано дані про учасників.", "No matching commands": "Немає відповідних команд", "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", + "Pen Pressure": "Натиск пера", "Press keys…": "Натисніть клавіші…", "Preview unavailable": "Перегляд недоступний", "Radial": "Радіальний", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..e7ec79d67 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "模板留空则保留原始名称。", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", "A one-time download of about": "一次性下载,大小约", + "A pen's pressure sets the size and flow of the brush": "笔的压力决定画笔的大小和流量", "AI Mask Model Status": "AI 蒙版模型状态", "About": "关于", "About LightCraft": "关于 LightCraft", @@ -592,6 +593,7 @@ "Panoramas": "全景", "Paste Settings": "粘贴设置", "Paste from Previous": "粘贴上一张照片的设置", + "Pen Pressure": "笔压", "People": "人物", "Person": "人物", "Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..b4485eecb 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -15,6 +15,7 @@ "A blank template keeps the original names.": "範本留空時保留原始名稱。", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」", "A one-time download of about": "一次性下載,大小約", + "A pen's pressure sets the size and flow of the brush": "筆的壓力決定筆刷的大小和流量", "AI Mask Model Status": "AI 遮色片模型狀態", "About": "關於", "About LightCraft": "關於 LightCraft", @@ -592,6 +593,7 @@ "Panoramas": "全景", "Paste Settings": "貼上設定", "Paste from Previous": "貼上前一張照片的設定", + "Pen Pressure": "筆壓", "People": "人物", "Person": "人物", "Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)", diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs index 614b5bed3..83f6e186b 100644 --- a/crates/ui-egui/src/brush_live.rs +++ b/crates/ui-egui/src/brush_live.rs @@ -9,7 +9,7 @@ use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2}; use lightcraft_geom::Point; -use lightcraft_pipeline::masks::{accumulate, dab_alpha, push_dabs}; +use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs, push_pressures}; /// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution. const MAX_SIDE: usize = 4096; @@ -42,6 +42,8 @@ pub struct LiveStroke { /// Samples stamped so far, and the last one (buffer pixels). placed: usize, last: Option<Point>, + /// The pen pressure of the last stamped sample. + last_pressure: f32, /// Buffer pixels painted since the last upload: (x0, y0, x1, y1), exclusive ends. dirty: Option<(usize, usize, usize, usize)>, texture: Option<TextureHandle>, @@ -52,7 +54,7 @@ impl LiveStroke { let scale = buffer_scale(params.clip, params.scale); let params = LiveParams { scale, ..params }; let (w, h) = buffer_size(params.clip, scale); - LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, dirty: None, texture: None } + LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } } /// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside. @@ -69,8 +71,9 @@ impl LiveStroke { } /// Stamp the dabs of `points` (screen positions of the stroke's samples so far) not yet - /// painted. Returns `true` when anything new was painted. - pub fn stamp(&mut self, points: &[Pos2]) -> bool { + /// painted, at the pen pressures `pressure` (one per point; empty: full pressure), as the + /// pipeline scales them. Returns `true` when anything new was painted. + pub fn stamp(&mut self, points: &[Pos2], pressure: &[f32]) -> bool { let Some(new) = points.get(self.placed..) else { return false }; if new.is_empty() { return false; @@ -78,17 +81,28 @@ impl LiveStroke { let LiveParams { clip, scale, r, hard, flow, density, .. } = self.params; let (r, hard) = ((r * scale) as f64, (hard * scale) as f64); let mut dabs = Vec::new(); - for q in new { + let mut pressures = Vec::new(); + let first = self.placed; + for (k, q) in new.iter().enumerate() { let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64); + let before = dabs.len(); if push_dabs(&mut dabs, self.last, q, r) { + let pq = pressure.get(first + k).or(pressure.last()).copied().unwrap_or(1.0); + let from = if self.last.is_some() { self.last_pressure } else { pq }; + push_pressures(&mut pressures, dabs.len() - before, from, pq); self.last = Some(q); + self.last_pressure = pq; } } self.placed = points.len(); - for d in dabs { + let base = (r, hard); + for (d, p) in dabs.into_iter().zip(pressures) { if !(d.x.is_finite() && d.y.is_finite()) { continue; } + let r = pressure_radius(base.0, p); + let hard = if base.0 > 0.0 { base.1 * r / base.0 } else { base.1 }; + let flow = pressure_flow(flow, p); let x0 = (d.x - r).floor().clamp(0.0, self.w as f64) as usize; let x1 = (d.x + r).ceil().clamp(0.0, self.w as f64) as usize; let y0 = (d.y - r).floor().clamp(0.0, self.h as f64) as usize; @@ -168,7 +182,7 @@ fn buffer_size(clip: Rect, scale: f32) -> (usize, usize) { /// Paint the live stroke whose samples so far are `points` (screen positions) into `painter`, /// keeping its buffer in `slot` (a new stroke, or new parameters, start it afresh). -pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2]) { +pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2], pressure: &[f32]) { let fresh = match slot { Some(s) => { // the same stroke when painted with the same parameters (at the scale it settled on) @@ -181,7 +195,7 @@ pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui: *slot = Some(LiveStroke::new(params)); } let Some(s) = slot else { return }; - s.stamp(points); + s.stamp(points, pressure); let clip = s.params.clip; // the buffer's pixels may overhang the canvas by a fraction of a pixel: map them exactly let (w, h, scale) = (s.w as f32, s.h as f32, s.params.scale); @@ -214,8 +228,8 @@ mod tests { #[test] fn a_fast_segment_is_painted_continuously() { let mut s = LiveStroke::new(params()); - s.stamp(&[pos2(20.0, 100.0)]); - s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)]); + s.stamp(&[pos2(20.0, 100.0)], &[]); + s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)], &[]); for x in 20..380 { assert!(s.coverage(x, 100) > 0.99, "a gap at x = {x}: {}", s.coverage(x, 100)); } @@ -231,10 +245,10 @@ mod tests { fn incremental_stamping_matches_painting_the_whole_path() { let pts = [pos2(30.0, 40.0), pos2(200.0, 150.0), pos2(210.0, 152.0), pos2(390.0, 20.0)]; let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); - once.stamp(&pts); + once.stamp(&pts, &[]); let mut steps = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); for n in 1..=pts.len() { - steps.stamp(&pts[..n]); + steps.stamp(&pts[..n], &[]); } assert_eq!(once.alpha, steps.alpha); } @@ -242,10 +256,27 @@ mod tests { #[test] fn hostile_input_paints_nothing_and_does_not_panic() { let mut s = LiveStroke::new(params()); - s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)]); + s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)], &[]); assert!(s.alpha.iter().all(|a| *a == 0.0)); // an enormous canvas is painted at a capped resolution let big = LiveStroke::new(LiveParams { clip: Rect::from_min_size(Pos2::ZERO, vec2(1e6, 10.0)), scale: 2.0, ..params() }); assert!(big.w <= MAX_SIDE && big.h >= 1); } + + /// The live stroke scales its dabs by pen pressure as the committed one does: a light touch paints a thinner, + /// fainter line, and a pressure list shorter than the points (or hostile values) is handled. + #[test] + fn pen_pressure_thins_and_fades_the_live_stroke() { + let pts = [pos2(20.0, 100.0), pos2(380.0, 100.0)]; + let mut full = LiveStroke::new(params()); + full.stamp(&pts, &[]); + let mut light = LiveStroke::new(params()); + light.stamp(&pts, &[0.2, 0.2]); + let rows = |s: &LiveStroke| (0..s.h).filter(|y| s.coverage(200, *y) > 0.01).count(); + assert!(rows(&light) < rows(&full), "{} vs {}", rows(&light), rows(&full)); + assert!(light.coverage(200, 100) < full.coverage(200, 100)); + let mut odd = LiveStroke::new(params()); + odd.stamp(&pts, &[f32::NAN]); + assert!(odd.alpha.iter().all(|v| v.is_finite())); + } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..09b475c25 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -19,6 +19,9 @@ use crate::widgets::register; pub enum Gesture { Brush { points: Vec<Point>, + /// Pen pressure (0..1) at each point; empty when no pen pressure was reported (a mouse) or the + /// brush's Pen Pressure switch is off. + pressure: Vec<f32>, }, Spot { points: Vec<Point>, @@ -1831,16 +1834,24 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons && (resp.drag_started() || resp.dragged() || resp.clicked()) { let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; match &mut app.gesture { // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points }) => points.push(n), - _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } } } // the stroke being painted (the render shows it once it's committed), with the committed // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points }) = &app.gesture { + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect(); let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; let params = crate::brush_live::LiveParams { @@ -1853,12 +1864,12 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons color: app.ui.mask_overlay_color, opacity: opacity.clamp(0.0, 1.0), }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen); + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); } else { app.brush_live = None; } if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points }) = app.gesture.take() + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() { app.brush_live = None; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); @@ -1867,7 +1878,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = app.run( "mask.brushStroke", json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask}), + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure}), ); } return; @@ -2510,3 +2521,55 @@ mod wheel_zoom_tests { assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0); } } + +/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit +/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. +pub(crate) fn pen_force(i: &egui::InputState) -> Option<f32> { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) +} + +/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last +/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. +pub(crate) fn record_pressure(pressure: &mut Vec<f32>, index: usize, force: Option<f32>) { + match (force, pressure.last().copied()) { + (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), + (Some(f), _) => pressure.push(f), + (None, Some(last)) => pressure.push(last), + (None, None) => {} + } +} + +#[cfg(test)] +mod pen_tests { + use super::*; + + #[test] + fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { + let mut p = Vec::new(); + record_pressure(&mut p, 0, None); + record_pressure(&mut p, 1, None); + assert!(p.is_empty(), "a mouse: no pressure list"); + // the first force covers the points before it, a frame without one repeats the last + record_pressure(&mut p, 2, Some(0.4)); + record_pressure(&mut p, 3, None); + record_pressure(&mut p, 4, Some(0.9)); + assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); + + let mut i = egui::InputState::default(); + assert_eq!(pen_force(&i), None); + let touch = |force| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase: egui::TouchPhase::Move, + pos: egui::pos2(1.0, 1.0), + force, + }; + i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; + assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); + i.events = vec![touch(Some(4.0))]; + assert_eq!(pen_force(&i), Some(1.0)); + } +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..055388715 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -831,6 +831,14 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { { app.ui.brush_auto_mask = auto; } + let mut pressure = app.ui.brush_pressure; + if ui + .checkbox(&mut pressure, crate::i18n::tr("Pen Pressure")) + .on_hover_text(crate::i18n::tr("A pen's pressure sets the size and flow of the brush")) + .changed() + { + app.ui.brush_pressure = pressure; + } }); for (id, label, min, max, get) in [ ("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..62d72af37 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -659,6 +659,8 @@ pub struct UiState { pub brush_erase: bool, /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. pub brush_auto_mask: bool, + /// Brush Pen Pressure: a pen's pressure scales each dab's size and flow (a mouse paints at full pressure). + pub brush_pressure: bool, /// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100). pub remove_size: f32, pub remove_feather: f32, @@ -1100,6 +1102,7 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, brush_auto_mask: false, + brush_pressure: true, remove_size: 0.02, remove_feather: 50.0, remove_opacity: 100.0, diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..cc01a6b22 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 22: pen pressure drives brush size and flow where winit reports it); previously minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -36,7 +36,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 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) | +| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), 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** 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) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 22: pen pressure scales brush size and flow (Brush ▸ Pen Pressure) where winit reports a pen force | | 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 3f1defc57..11b11855f 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 (pen pressure for brush masks); 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), @@ -14,7 +14,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | 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 | | Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | -| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | +| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ winit reports no tablet pressure on macOS | 🟡 pen force from winit touch events (untested on a tablet) | ⬜ winit has no X11 / Wayland tablet input | 🟡 pointer-event force where winit passes it | 🟡 brush size and flow follow pen pressure wherever egui gets a touch force (Brush ▸ Pen Pressure); no tilt; macOS / Linux tablets wait on winit | | Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | | Card readers / cameras as import sources | ✅ (devices panel, eject after import) | ✅ mounted volumes with DCIM | ✅ | ✅ | — | 🟡 no eject-after-import check, no MTP / PTP phones | | Tethered capture | ✅ Classic (Canon, Nikon, Sony, Leica) | ⬜ | ⬜ | ⬜ | — | ⬜ | @@ -36,7 +36,7 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. |---|---:|---| | 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 | -| Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | +| Pen pressure for brush masks: done where winit reports a touch force; macOS and Linux tablets need winit tablet events (or a pure-Rust tablet layer) | 3–8 | 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) | | Printing with colour management | 15–25 | printers (human) | @@ -47,4 +47,5 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Pen pressure: strokes record per-point pressure from egui touch force, scaling dab size and flow; per-platform status | | 2026-10-10 | major | Created | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..6db8e85d9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-MASK-BRUSH: pen pressure); previously minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -292,7 +292,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-MASK-BRUSH: pen pressure scales brush size and flow | | 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 | From c3be1ceeada5ce14768c5aaf441d8b2196d89e0e Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 04:13:49 +1100 Subject: [PATCH 018/315] LR-BEHAV-ACCESS: canvas tools by keyboard; name hand-drawn widgets; Tab order test canvas_keys.rs gives every handle on the photo a focusable stand-in with an AccessKit name and position: crop corners, edges and box (crop.drag), spot targets and sources plus a New spot point (Return adds), radial and linear gradient handles and mask pins. Arrows move by 0.5% (Shift 5%), one undo step per press; the stand-in keeps arrows/Return/Space from the shortcuts. The audit now counts any focusable or clickable node; ~50 hand-drawn icons, tiles and rows are announced as buttons. A test walks Tab through the Edit/Crop/Remove/Masking panels top to bottom. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 5 +- crates/ui-egui/src/canvas_keys.rs | 99 +++++++++++++ crates/ui-egui/src/import.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/activity.rs | 1 + crates/ui-egui/src/panels/bottombar.rs | 12 ++ crates/ui-egui/src/panels/chips.rs | 1 + crates/ui-egui/src/panels/compare.rs | 2 + crates/ui-egui/src/panels/detail.rs | 170 ++++++++++++++++++++++ crates/ui-egui/src/panels/edit.rs | 7 + crates/ui-egui/src/panels/filterbar.rs | 2 + crates/ui-egui/src/panels/grid.rs | 1 + crates/ui-egui/src/panels/keyword_list.rs | 4 + crates/ui-egui/src/panels/left.rs | 1 + crates/ui-egui/src/panels/masking.rs | 5 + crates/ui-egui/src/panels/people.rs | 1 + crates/ui-egui/src/panels/person.rs | 1 + crates/ui-egui/src/panels/presets.rs | 2 + crates/ui-egui/src/panels/profiles.rs | 2 + crates/ui-egui/src/panels/right.rs | 3 + crates/ui-egui/src/panels/topbar.rs | 6 +- crates/ui-egui/src/panels/unnamed.rs | 1 + crates/ui-egui/src/shortcuts.rs | 6 + crates/ui-egui/src/tests_canvas_keys.rs | 144 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 17 +++ crates/ui-egui/tests/accessibility.rs | 64 +++++++- docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 36 ++++- 30 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/canvas_keys.rs create mode 100644 crates/ui-egui/src/tests_canvas_keys.rs diff --git a/ROADMAP.md b/ROADMAP.md index 51e45d11b..dd4d6ad1c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 32–54 h after canvas tools by keyboard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | | ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | | Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | diff --git a/crates/ui-egui/src/canvas_keys.rs b/crates/ui-egui/src/canvas_keys.rs new file mode 100644 index 000000000..dadbb1577 --- /dev/null +++ b/crates/ui-egui/src/canvas_keys.rs @@ -0,0 +1,99 @@ +//! Keyboard and screen-reader access to the handles drawn on the photo (crop corners and edges, +//! spot removals, gradient handles, mask pins). Each handle gets an invisible, focusable stand-in +//! at its place on the canvas: Tab reaches it, a screen reader announces its name and position, +//! and the arrow keys move it (Shift: ten times further). The pointer still grabs the handle as +//! before; the stand-in senses no clicks or drags. + +use egui::{Id, Key, Pos2, Rect, Ui, vec2}; + +/// One arrow press moves a handle this far, as a fraction of the photo's width or height. +pub const STEP: f64 = 0.005; +/// With Shift held. +pub const BIG_STEP: f64 = 0.05; + +fn focused_key() -> Id { + Id::new("lc-canvas-handle-focused") +} + +/// The egui id of the stand-in registered as `key` (to focus it, or find it in tests). +pub fn id(key: &str) -> Id { + Id::new("lc-canvas-handle").with(key) +} + +/// Whether a canvas handle holds the keyboard focus: it keeps the arrow keys and Return from the +/// app's shortcuts (next photo, and so on). +pub fn has_focus(ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused.is_some() && focused == ctx.data(|d| d.get_temp::<Id>(focused_key())) +} + +/// What the keys did to a focused handle this frame. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct HandleKeys { + /// The move asked for, in normalized photo units (x right, y down). + pub delta: Option<(f64, f64)>, + /// Return or Space was pressed. + pub activated: bool, +} + +/// `(x, y)` normalized, as a screen reader reads it: "x 30%, y 60%". +pub fn position_text(x: f64, y: f64) -> String { + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + format!("x {}%, y {}%", pct(x), pct(y)) +} + +/// The focusable stand-in for a handle drawn at `at`, named `name` with `value` (its position or +/// size), registered for the control channel as `canvasHandle:<key>`. +pub fn handle(ui: &mut Ui, key: &str, name: &str, value: String, at: Pos2) -> HandleKeys { + let id = id(key); + let rect = Rect::from_center_size(at, vec2(14.0, 14.0)); + let resp = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); + let label = name.to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + ui.ctx().accesskit_node_builder(id, |node| { + node.set_value(value); + }); + crate::widgets::register(ui.ctx(), format!("canvasHandle:{key}"), rect); + let mut out = HandleKeys::default(); + if !resp.has_focus() { + return out; + } + ui.data_mut(|d| d.insert_temp(focused_key(), id)); + // a ring shows where the focus is + ui.painter().circle_stroke(at, 9.0, egui::Stroke::new(2.0, ui.visuals().selection.stroke.color)); + ui.painter().circle_stroke(at, 11.0, egui::Stroke::new(1.0, egui::Color32::BLACK)); + ui.input_mut(|i| { + let shift = i.modifiers.shift; + let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + let step = if shift { BIG_STEP } else { STEP }; + let (mut dx, mut dy) = (0.0, 0.0); + if i.consume_key(m, Key::ArrowLeft) { + dx -= step; + } + if i.consume_key(m, Key::ArrowRight) { + dx += step; + } + if i.consume_key(m, Key::ArrowUp) { + dy -= step; + } + if i.consume_key(m, Key::ArrowDown) { + dy += step; + } + if dx != 0.0 || dy != 0.0 { + out.delta = Some((dx, dy)); + } + out.activated = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::NONE, Key::Space); + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_read_as_percentages_and_survive_nan() { + assert_eq!(position_text(0.304, 0.6), "x 30%, y 60%"); + assert_eq!(position_text(f64::NAN, f64::INFINITY), "x 0%, y 0%"); + } +} diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..804c74af6 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1252,6 +1252,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial let ok = d.importable(i); let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); let resp = ui.interact(img, egui::Id::new(("import-cell", i)), Sense::click()); + crate::widgets::announce(&resp, c.name.clone()); register(ui.ctx(), format!("import:{i}"), img); let p = ui.painter(); p.rect_filled(img, 3.0, Color32::from_gray(30)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..72d66fd35 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod canvas_keys; pub mod control; pub mod credits; pub mod date_picker; @@ -48,6 +49,8 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_canvas_keys; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 7b0d449c6..2e4137fda 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -130,6 +130,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { }); if cross { let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Cancel")); paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); clicked = resp.on_hover_text(tr("Cancel")).clicked(); diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 65c25c6b5..a5b5c8f01 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -112,6 +112,15 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] { let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce_selected( + &resp, + crate::i18n::tr(match f { + Flag::None => "Unflagged", + Flag::Pick => "Pick", + Flag::Reject => "Reject", + }), + flag == f, + ); register(pill_ui.ctx(), format!("flag:{id}"), r); let on = flag == f && f != Flag::None; let c = if on { @@ -134,6 +143,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0)); let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); + crate::widgets::announce(&cresp, label.to_string()); register(ui.ctx(), "button:copySettings", copy_r); ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); @@ -144,6 +154,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); + crate::widgets::announce(&gresp, crate::i18n::tr("Copy Settings…")); register(ui.ctx(), "icon:copyGear", gear_r); ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); @@ -235,6 +246,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo } else { // thumbnail size slider let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Thumbnail size")); register(child.ctx(), "slider:thumbSize", r); let p = child.painter(); let y = r.center().y; diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index d1acd15a5..89d56fa7b 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -101,6 +101,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &label, font.clone(), t.text); let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0)); let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter")); + crate::widgets::announce(&resp, format!("{}: {label}", crate::i18n::tr("Remove this filter"))); register(ui.ctx(), format!("chip:{i}"), xr); let col = if resp.hovered() { t.text } else { t.text_dim }; let m = 3.5; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..2922be012 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -172,6 +172,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + crate::widgets::announce(&resp, app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_default()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; let caption_h = 26.0; let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h)); @@ -482,6 +483,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.hovered() && photos.len() > 1 { let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey")); + crate::widgets::announce(&xresp, crate::i18n::tr("Remove from survey")); ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); if xresp.clicked() { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..cf79feb07 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -421,6 +421,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // tool's overlays need the frame's own geometry. let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Photo")); let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); let mut navigating = false; @@ -872,6 +873,7 @@ fn region_overlay( for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(*cursor); } @@ -916,6 +918,7 @@ fn region_overlay( if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); register(ui.ctx(), format!("regionRemove:{index}"), xr); let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); p.rect_filled(xr, 2.0, fill); @@ -965,6 +968,7 @@ fn region_overlay( if is_face && live.is_none() { // a click on the label opens the name box, filled with the name or the guess let resp = ui.interact(label, egui::Id::new(("region-label", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); register(ui.ctx(), format!("regionLabel:{index}"), label); if resp.on_hover_text("Click to name this face").clicked() { let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); @@ -1000,6 +1004,7 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0)); let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0)); let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Filters")); register(ui.ctx(), "loupe:filters", r); p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); @@ -1065,6 +1070,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); register(ui.ctx(), "canvas:navigator", frame); let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); if (resp.clicked() || resp.dragged()) && let Some(q) = resp.interact_pointer_pos() { @@ -1290,6 +1296,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); } + // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move + if crop_keys(app, ui, map, &handles, &pts, d, frame) { + return; + } let inside = |q: Pos2| { let n = map.norm(q); let s = to_straight(n, d.crop.geometry.angle, frame); @@ -1399,6 +1409,64 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = id; } +/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 +/// edges (top, right, bottom, left), 8 the whole box. +const CROP_HANDLE_NAMES: [&str; 9] = [ + "Crop top-left corner", + "Crop top-right corner", + "Crop bottom-right corner", + "Crop bottom-left corner", + "Crop top edge", + "Crop right edge", + "Crop bottom edge", + "Crop left edge", + "Crop box", +]; + +/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one +/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the +/// frame is over (the crop was applied). +fn crop_keys( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + map: &CanvasMap, + handles: &[Pos2], + pts: &[Pos2], + d: &DevelopSettings, + frame: &Frame, +) -> bool { + let r = d.crop.geometry.rect; + let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); + for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { + let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; + let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); + if keys.activated { + let _ = app.run("tool.done", json!({})); + return true; + } + let Some((dx, dy)) = keys.delta else { continue }; + // the drag runs in the straightened crop frame, as a pointer drag from the handle would; + // the arrows follow the screen + let angle = d.crop.geometry.angle; + let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); + let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); + let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); + if let Err(e) = started { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return false; + } + let _ = app.run( + "crop.drag", + json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), + ); + let _ = app.run("develop.endInteraction", json!({})); + } + false +} + /// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. pub(crate) fn crop_angle_label(angle: f64) -> String { let shown = @@ -1769,6 +1837,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } } + mask_keys(app, ui, map, d, &grips); // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude // (clicks still on their way to the model included) let pending = app.session.segmenter.pending_clicks().cloned(); @@ -1930,6 +1999,48 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } +/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial +/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. +fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { + let part = match (shape, handle) { + (_, 0) => "pin", + (MaskShape::Radial { .. }, 1) => "right edge", + (MaskShape::Radial { .. }, 2) => "bottom edge", + (MaskShape::Radial { .. }, 3) => "left edge", + (MaskShape::Radial { .. }, 4) => "top edge", + (MaskShape::Radial { .. }, _) => "rotation", + (MaskShape::Linear { .. }, 1) => "start", + (MaskShape::Linear { .. }, _) => "end", + _ => "handle", + }; + format!("{mask} {}", crate::i18n::tr(part)) +} + +/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a +/// step (one undo step), as dragging it would; Return on a pin selects its mask. +fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { + for &(mid, ci, handle, q) in grips { + let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; + let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; + let at = map.norm(q); + let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; + let name = mask_handle_name(&mask_name, shape, handle); + let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); + if keys.activated && app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let Some((dx, dy)) = keys.delta else { continue }; + if app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let to = Point::new(at.x + dx, at.y + dy); + let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); + let _ = app.run("develop.endInteraction", json!({})); + } +} + /// Long-edge units → normalized units for x and y. fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { // derive from the mapping: 1 normalized unit in x and y in screen points @@ -1978,6 +2089,9 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); grips.push((i, false, tq, r)); } + if spot_keys(app, ui, map, d, &grips) { + return; + } let r = (app.ui.remove_size as f64 * long) as f32; // a target or source under `q`: the selected spot's first, then the nearest pin let hit = |q: Pos2| { @@ -2062,6 +2176,61 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } } +/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves +/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move +/// and Return turns into a new spot of the brush's size there. True when a spot was added. +fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { + for &(spot, source, q, _) in grips { + let at = map.norm(q); + let name = + format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); + let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); + let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); + let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); + if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { + let _ = app.run("spot.select", json!({"index": spot})); + } + if let Some((dx, dy)) = keys.delta { + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); + let _ = app.run("develop.endInteraction", json!({})); + } + } + // where a new spot goes: the photo's centre at first, then wherever the arrows took it + let key = egui::Id::new("lc-spot-placement"); + let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); + let q = map.screen(Point::new(place.0, place.1)); + let keys = crate::canvas_keys::handle( + ui, + "spot:new", + crate::i18n::tr("New spot (Return adds it here)"), + crate::canvas_keys::position_text(place.0, place.1), + q, + ); + if let Some((dx, dy)) = keys.delta { + let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); + ui.data_mut(|m| m.insert_temp(key, moved)); + } + if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { + let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; + ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); + } + if keys.activated { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + return true; + } + false +} + // ------------------------------------------------------------------------ red eye /// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. @@ -2160,6 +2329,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { continue; } let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click()); + crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default()); register(ui.ctx(), format!("film:{}", id.0), cr); let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..dd4e2c9d8 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -537,6 +537,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let hdr = hist.as_ref().and_then(|h| h.hdr); let legend = if hdr.is_some() { 26.0 } else { 0.0 }; let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); register(ui.ctx(), "histogram", r); let p = ui.painter(); p.rect_filled(r, 0.0, t.button); @@ -753,6 +754,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev ("blue", hex("#3377ee")), ] { let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); register(ui.ctx(), format!("curveChannel:{ch}"), r); let sel = app.ui.curve_channel == ch; if ch == "parametric" { @@ -792,6 +794,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); register(ui.ctx(), "curve", r); resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); @@ -1073,6 +1076,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { ui.spacing_mut().item_spacing.x = 5.0; for (i, b) in bands.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(b)); register(ui.ctx(), format!("mixerBand:{b}"), r); ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); if i == sel { @@ -1156,6 +1160,7 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for (i, p) in d.point_colors.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); register(ui.ctx(), format!("pointColor:{i}"), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); if i == sel { @@ -1289,6 +1294,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings let zone = r.expand(10.0); let hovering = ui.rect_contains_pointer(zone); let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); register(ui.ctx(), format!("wheel:{key}"), r); paint_wheel(ui.painter(), c, rad); if hidden { @@ -1360,6 +1366,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if hovering || hidden { let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); let er = ui.interact(eye, egui::Id::new(("wheelEye", key)), Sense::click()); + crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); register(ui.ctx(), format!("wheelEye:{key}"), eye); paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c0962b59f..9b5106ad1 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -32,6 +32,7 @@ fn caption(ui: &mut egui::Ui, text: &str) { fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + crate::widgets::announce_selected(&resp, crate::i18n::tr(tip), on); register(ui.ctx(), format!("filter:{id}"), r); let p = ui.painter(); if on { @@ -131,6 +132,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); for i in 1..=5u8 { let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); + crate::widgets::announce(&resp, format!("{i} ★")); register(ui.ctx(), format!("filter:star{i}"), r); let filled = i <= f.rating; let c = if filled { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..c4fd7e0f8 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -415,6 +415,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: &[PhotoId], r: Rect, pinned: bool) { let t = Tokens::get(ui.ctx()); let resp = ui.interact(r, egui::Id::new(("group-header", run.start, pinned)), Sense::click()); + crate::widgets::announce(&resp, if run.key.is_empty() { crate::i18n::tr("Unknown date").to_string() } else { run.key.clone() }); if !pinned { register(ui.ctx(), format!("group:{}", if run.key.is_empty() { "unknown" } else { &run.key }), r); } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..b668a2be8 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -161,6 +161,7 @@ fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, par fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], ticks: &Ticks, can_fold: bool) { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + crate::widgets::announce(&resp, r.path.clone()); register(ui.ctx(), format!("keywordRow:{}", r.path), rect); if resp.drag_started() { app.ui.dragging_keyword = Some(r.path.clone()); @@ -180,6 +181,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let c = pos2(x + 6.0, cy); let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); + crate::widgets::announce(&tr, crate::i18n::tr(if r.open { "Collapse" } else { "Expand" })); register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); let pts = if r.open { vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] @@ -205,6 +207,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // (it senses drags too, so a press there that moves doesn't pick the keyword up) let sense = if on { Sense::click_and_drag() } else { Sense::hover() }; let tb = ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), sense); + crate::widgets::announce(&tb, format!("{} {}", crate::i18n::tr("Keyword"), r.path)); register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr); let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) }; match state { @@ -241,6 +244,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId ui.painter().galley(count_rect.min, count, t.text_dim); let arrow = Rect::from_center_size(pos2(count_rect.left() - 12.0, cy), vec2(16.0, 16.0)); let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); + crate::widgets::announce(&ar, crate::i18n::tr("Show photos")); register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); if resp.hovered() || ar.hovered() { ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim }); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..47cf012fd 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -565,6 +565,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 0.0; // the title offers the sections' menu too: where hidden sections come back from let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); + crate::widgets::announce(&title, crate::i18n::tr("My Photos")); register(ui.ctx(), "sidebarTitle", hr); title.context_menu(|ui| sections_menu(app, ui, None)); // the open library's name under the title, so nobody edits in the wrong one (issue #902) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..dd5655066 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -117,6 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| { for (i, (kind, label, icon)) in tiles.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(label)); register(ui.ctx(), format!("maskNew:{kind}"), r); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset }); paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label); @@ -225,6 +226,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { continue; } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + crate::widgets::announce_selected(&resp, m.name.clone(), Some(m.id) == app.session.active_mask); register(ui.ctx(), format!("mask:{}", m.id), r); ui.painter().rect_filled( r, @@ -253,6 +255,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0)); register(ui.ctx(), format!("maskVisible:{}", m.id), er); let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click()); + crate::widgets::announce_selected(&eye, crate::i18n::tr("Show mask"), m.visible); paint( ui.painter(), er.shrink(3.0), @@ -453,6 +456,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for c in OVERLAY_COLORS { let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} #{:02x}{:02x}{:02x}", crate::i18n::tr("Overlay color"), c[0], c[1], c[2])); register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); if app.ui.mask_overlay_color == c { @@ -498,6 +502,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape let (lo, hi) = (*lo, *hi); let w = ui.available_width().min(240.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Select Luminance Range")); register(ui.ctx(), format!("lumRange:{comp}"), rect); let p = ui.painter(); // dark → light ramp, the selected span outlined diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..f3d421ed4 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -156,6 +156,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed let t = Tokens::get(ui.ctx()); let face = Rect::from_min_size(r.min, vec2(edge, edge)); let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click()); + crate::widgets::announce(&resp, person.name.clone()); register(ui.ctx(), format!("person:{}", person.name), r); let view = app.session.face_view(person.photo, person.face); let p = ui.painter(); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..19611f461 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -230,6 +230,7 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::new((salt, f.photo, f.index)), Sense::click()); + crate::widgets::announce(&resp, name.to_string()); register(ui.ctx(), format!("{salt}:{}:{}", f.photo, f.index), r); // the × is added after the tile, so it wins where they overlap let x_rect = Rect::from_min_size(pos2(r.right() - 24.0, r.top() + 4.0), vec2(20.0, 20.0)); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67d..3a27b92b8 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -86,6 +86,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + crate::widgets::announce(&resp, g.to_string()); register(ui.ctx(), format!("presetGroup:{g}"), r); paint( ui.painter(), @@ -114,6 +115,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { for pr in items { let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + crate::widgets::announce(&resp, name.clone()); register(ui.ctx(), format!("preset:{pid}"), r); let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); if is_last { diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 6294d1e64..458847014 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -79,6 +79,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let open_id = egui::Id::new(("profile-group", imported, title)); let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + crate::widgets::announce(&resp, title.to_string()); register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); paint( ui.painter(), @@ -157,6 +158,7 @@ fn cell_ui( let t = Tokens::get(ui.ctx()); let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") }; let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); + crate::widgets::announce(&resp, p_name.to_string()); register(ui.ctx(), key, cell); let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); let s = with_profile_id(d, p_id); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 84e3ae8ff..0d264f6c1 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -554,6 +554,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { for l in lightcraft_catalog::ColorLabel::ALL { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); let key = format!("{l:?}").to_lowercase(); register(ui.ctx(), format!("label:{key}"), r); let on = p.label == Some(l); @@ -1030,6 +1031,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // newest first for (i, v) in shown.into_iter().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); register(ui.ctx(), format!("version:{i}"), r); let current = *v.settings == *p.develop; if resp.hovered() { @@ -1118,6 +1120,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); register(ui.ctx(), format!("history:{i}"), r); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index be9704ed4..211ed882b 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -16,6 +16,7 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l register(ui.ctx(), "region:titlebar", bar); let handle = egui::Id::new("titlebar-handle"); let resp = ui.interact(bar, handle, Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); // a press that began on a button or the search field is theirs: dragging off it must not move the window // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) let ctx = ui.ctx().clone(); @@ -117,6 +118,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // filter icon right of the search field let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); + crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); register(ui.ctx(), "icon:filter", fr); let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( @@ -168,6 +170,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(tip); + crate::widgets::announce(&resp, crate::i18n::tr(tip)); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { @@ -193,7 +196,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + crate::widgets::announce(&resp, tip.clone()); register(ui.ctx(), "icon:appearance", r); paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/unnamed.rs b/crates/ui-egui/src/panels/unnamed.rs index 0b730db78..25ba4105f 100644 --- a/crates/ui-egui/src/panels/unnamed.rs +++ b/crates/ui-egui/src/panels/unnamed.rs @@ -229,6 +229,7 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize let key = (f.photo, f.index); let selected = app.ui.unnamed_selected.contains(&key); let resp = ui.interact(r, egui::Id::new(("unnamed-face", f.photo, f.index)), Sense::click()); + crate::widgets::announce_selected(&resp, crate::i18n::tr("Unnamed face"), selected); register(ui.ctx(), format!("unnamed-face:{}:{}", f.photo, f.index), r); // a suggested name along the bottom: a click accepts it for this face (added after the tile, so it wins there) let strip = Rect::from_min_max(pos2(r.left(), r.bottom() - 20.0), r.right_bottom()); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..0a26f2788 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -312,6 +312,12 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { if crate::widgets::slider_has_focus(ctx) { return ctx.input(|input| [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown].iter().any(|key| input.key_pressed(*key))); } + // a handle on the photo keeps the arrows (it moves) and Return / Space (it acts) + if crate::canvas_keys::has_focus(ctx) { + return ctx.input(|input| { + [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Space].iter().any(|key| input.key_pressed(*key)) + }); + } let chrome = app .ui .docking diff --git a/crates/ui-egui/src/tests_canvas_keys.rs b/crates/ui-egui/src/tests_canvas_keys.rs new file mode 100644 index 000000000..111473643 --- /dev/null +++ b/crates/ui-egui/src/tests_canvas_keys.rs @@ -0,0 +1,144 @@ +//! The canvas tools by keyboard: crop handles, spot removals, gradient handles and mask pins are +//! reached with Tab or focus, moved with the arrow keys (Shift: further), one undo step a press. + +use std::time::Duration; + +use lightcraft_develop::MaskShape; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services, canvas_keys}; + +const T: Duration = Duration::from_secs(20); + +fn detail(panel: &str) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +fn focus(h: &mut Headless, key: &str) { + h.step(); + h.view.ctx.memory_mut(|m| m.request_focus(canvas_keys::id(key))); + h.step(); + h.step(); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(canvas_keys::id(key)), "{key} takes the focus"); +} + +fn key(h: &mut Headless, name: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": name, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +#[test] +fn crop_corner_moves_with_the_arrows_and_shift_moves_further() { + let mut h = detail("panel.crop"); + let photo = h.app.session.active(); + let r0 = develop(&h).crop.geometry.rect; + focus(&mut h, "crop:2"); + key(&mut h, "left", false); + let r1 = develop(&h).crop.geometry.rect; + assert!(r1.x1 < r0.x1 - 0.001 && (r1.x0 - r0.x0).abs() < 1e-9, "{r0:?} -> {r1:?}"); + key(&mut h, "up", true); + let r2 = develop(&h).crop.geometry.rect; + assert!(r2.y1 < r1.y1 - 0.03, "Shift is a big step: {r1:?} -> {r2:?}"); + // the arrows moved the handle, not to another photo + assert_eq!(h.app.session.active(), photo); + // one undo step a press + exec(&mut h, "edit.undo", json!({})); + assert!((develop(&h).crop.geometry.rect.y1 - r1.y1).abs() < 1e-9); + // the whole box moves with the box's handle + focus(&mut h, "crop:8"); + let before = develop(&h).crop.geometry.rect; + key(&mut h, "right", false); + let after = develop(&h).crop.geometry.rect; + assert!((after.x1 - after.x0 - (before.x1 - before.x0)).abs() < 1e-6, "{before:?} -> {after:?}"); +} + +#[test] +fn tab_reaches_the_crop_handles() { + let mut h = detail("panel.crop"); + let handles: Vec<egui::Id> = (0..9).map(|i| canvas_keys::id(&format!("crop:{i}"))).collect(); + let mut reached = false; + for _ in 0..200 { + key(&mut h, "tab", false); + if h.view.ctx.memory(|m| m.focused()).is_some_and(|f| handles.contains(&f)) { + reached = true; + break; + } + } + assert!(reached, "Tab reaches a crop handle"); +} + +#[test] +fn spots_are_added_and_moved_by_keyboard() { + let mut h = detail("panel.remove"); + assert!(develop(&h).spots.is_empty()); + focus(&mut h, "spot:new"); + key(&mut h, "right", true); + key(&mut h, "enter", false); + let spots = develop(&h).spots; + assert_eq!(spots.len(), 1, "Return adds a spot"); + let p = spots[0].points[0]; + assert!((p.x - 0.55).abs() < 1e-6 && (p.y - 0.5).abs() < 1e-6, "{p:?}"); + focus(&mut h, "spot:0:target"); + key(&mut h, "down", false); + let q = develop(&h).spots[0].points[0]; + assert!((q.y - p.y - canvas_keys::STEP).abs() < 1e-6, "{p:?} -> {q:?}"); + if develop(&h).spots[0].source_offset.is_some() { + let o = develop(&h).spots[0].source_offset; + focus(&mut h, "spot:0:source"); + key(&mut h, "left", false); + assert_ne!(develop(&h).spots[0].source_offset, o, "the source moves"); + } +} + +#[test] +fn gradient_handles_and_pins_move_by_keyboard() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); + h.step(); + let mid = h.app.session.active_mask.expect("a selected mask"); + focus(&mut h, &format!("mask:{mid}:0:2")); + key(&mut h, "down", false); + let MaskShape::Linear { start, end } = develop(&h).masks[0].components[0].shape.clone() else { panic!("linear") }; + assert!((end.y - 0.7 - canvas_keys::STEP).abs() < 1e-3 && (start.y - 0.2).abs() < 1e-6, "{start:?} {end:?}"); + // the pin moves the whole gradient + let r = h.request("ui.set", json!({"maskPins": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + focus(&mut h, &format!("mask:{mid}:0:0")); + key(&mut h, "right", true); + let MaskShape::Linear { start: s2, end: e2 } = develop(&h).masks[0].components[0].shape.clone() else { panic!("linear") }; + assert!((s2.x - start.x - canvas_keys::BIG_STEP).abs() < 1e-3 && (e2.x - end.x - canvas_keys::BIG_STEP).abs() < 1e-3); + // a radial gradient: its right edge widens it + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.2, "ry": 0.1})); + h.step(); + let rid = h.app.session.active_mask.expect("the radial mask"); + let rx = |h: &Headless| match develop(h).masks.iter().find(|m| m.id == rid).map(|m| m.components[0].shape.clone()) { + Some(MaskShape::Radial { rx, .. }) => rx, + other => panic!("{other:?}"), + }; + let rx0 = rx(&h); + focus(&mut h, &format!("mask:{rid}:0:1")); + key(&mut h, "right", true); + assert!(rx(&h) > rx0, "{rx0} -> {}", rx(&h)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e17e812fc..93f1774f0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -209,10 +209,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } }); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); + crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone())); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one (over the label, so it takes the click) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() }); + crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value"))); register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; @@ -436,6 +438,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O { let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0)); let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); + crate::widgets::announce_selected(&er, crate::i18n::tr("Show / hide"), on); register(ui.ctx(), format!("sectionEye:{id}"), eye); paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled }); if !on { @@ -520,6 +523,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u let label = crate::i18n::tr(label); let i = row_i * per_row + j; let (r, resp) = ui.allocate_exact_size(vec2(seg_w, 24.0), Sense::click()); + crate::widgets::announce_selected(&resp, label.to_string(), active == Some(i)); register(ui.ctx(), format!("button:{id}-{key}"), r); let fill = if active == Some(i) { t.pressed @@ -576,6 +580,7 @@ pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> { let mut rects = [Rect::NOTHING; 5]; for (i, slot) in (0..5u8).zip(rects.iter_mut()) { let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + crate::widgets::announce_selected(&resp, format!("{} ★", i + 1), i < rating); register(ui.ctx(), format!("star:{id}:{}", i + 1), r); if resp.hovered() { hover_n = Some(i + 1); @@ -729,6 +734,18 @@ pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str) ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string())); } +/// A hand-drawn clickable area (an icon, a tile, a row) announced as a button named `name`. +pub(crate) fn announce(resp: &egui::Response, name: impl Into<String>) { + let name = name.into(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), name.clone())); +} + +/// The same for one that is on or off (a toggle, a selected row or swatch). +pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into<String>, selected: bool) { + let name = name.into(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, resp.enabled(), selected, name.clone())); +} + /// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn /// beside it, or whose hint is all it shows. pub(crate) fn named(r: egui::Response, name: &str) -> egui::Response { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index e28d060bb..04d64f3e5 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -55,7 +55,13 @@ fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> { for node in h.root().children_recursive() { let n = node.accesskit_node(); let role = n.role(); - if !interactive(role) { + // egui's own scroll bars are announced by their role, as native ones are + if role == Role::ScrollBar { + continue; + } + // a widget that takes the focus or a click is interactive whatever its role + let acts = |a| n.data().supports_action(a); + if !interactive(role) && !acts(egui::accesskit::Action::Focus) && !acts(egui::accesskit::Action::Click) { continue; } count += 1; @@ -187,3 +193,59 @@ fn canvas_tool_sliders_are_reachable_with_tab() { assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}"); } } + +/// The canvas handles are in the tree by name, with their position as the value. +#[test] +fn canvas_handles_are_named_with_their_position() { + let h = harness(ViewMode::Detail, RightPanel::Crop); + let found: Vec<(String, String)> = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::Button) + .map(|n| (n.label().unwrap_or_default(), n.value().unwrap_or_default())) + .collect(); + for name in ["Crop top-left corner", "Crop right edge", "Crop box"] { + assert!(found.iter().any(|(l, v)| l == name && v.contains('%')), "{name}: {found:?}"); + } + let h = harness(ViewMode::Detail, RightPanel::Remove); + assert!(h.root().children_recursive().any(|n| n.accesskit_node().label().is_some_and(|l| l.starts_with("New spot")))); +} + +/// Tab walks each right panel from top to bottom (rows left to right): the order a sighted user +/// reads it in. Each step down may not jump back up by more than a row. +#[test] +fn tab_walks_the_right_panels_top_to_bottom() { + for right in [RightPanel::Edit, RightPanel::Crop, RightPanel::Remove, RightPanel::Masking] { + let mut h = harness(ViewMode::Detail, right); + let panel_left = 1500.0 - h.state().ui.right_width - 10.0; + // the tool strip beside the panel is a column of its own, walked before it + let strip = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .find(|n| n.label().as_deref() == Some("Edit (E)")) + .and_then(|n| n.bounding_box()) + .map(|b| b.x0 as f32) + .unwrap_or(f32::INFINITY); + let mut seen: Vec<(String, f32)> = Vec::new(); + for _ in 0..120 { + h.key_press(egui::Key::Tab); + h.run_steps(1); + let Some(n) = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused()) else { continue }; + let Some(b) = n.bounding_box() else { continue }; + if (b.x0 as f32) < panel_left || (b.x0 as f32) >= strip - 1.0 { + continue; + } + let name = n.label().unwrap_or_default(); + if seen.iter().any(|(s, y)| *s == name && (*y - b.y0 as f32).abs() < 1.0) && !name.is_empty() { + break; + } + seen.push((name, b.y0 as f32)); + } + assert!(seen.len() > 3, "{right:?}: Tab reaches the panel: {seen:?}"); + for w in seen.windows(2) { + assert!(w[1].1 >= w[0].1 - 30.0, "{right:?}: Tab jumps up from {:?} to {:?}\n{seen:?}", w[0], w[1]); + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 4f5f1d188..3a34b57fc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 23: AccessKit audit test landed, estimate 10–20 h → 4–8 h) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 23: canvas tools by keyboard, 4–8 h → 2–4 h) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -37,7 +37,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 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) | +| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [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) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 23 (accessibility): crop handles, spots, gradient handles and mask pins by keyboard; 4–8 h → 2–4 h; gap not closed | | 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-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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1ab1572ba..e7a53c72b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-BEHAV-ACCESS: AccessKit audit test) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-BEHAV-ACCESS: canvas tools by keyboard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -472,7 +472,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | -| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | | LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-BEHAV-ACCESS: canvas handles by keyboard with AccessKit names and values; Tab order test; status stays 🟡 | | 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | | 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) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba62abb40..ad6bcf06b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 32–54 h after canvas tools by keyboard) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -48,7 +48,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | | ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | -| UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | | Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..ff2463e63 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -32,11 +32,11 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | | Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | | Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | -| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab | 55% | crop handles, spot and mask placement on the canvas still need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | +| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab; crop handles, spots, gradient handles and mask pins by keyboard | 75% | brush strokes and red-eye ellipses need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | | Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | | **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) | -Remaining: **34–58 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 4–8 +Remaining: **32–54 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 2–4 h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md). @@ -96,15 +96,37 @@ Found and fixed (2026-10-12): 47 problems in the views and tools, 11 more in the - Auto Stack's time slider had no name. Keyboard reach: Crop (angle, offsets, scale, aspect) and Remove (size, feather, opacity) settings -are reached with Tab and adjusted with the arrow keys (tested). Still pointer-only: dragging crop -handles, placing a spot or a brush stroke, and moving a mask pin on the canvas. Not done: a session -with a real screen reader on each platform, and focus order (egui follows drawing order: top bar, -left panel, canvas, right panel, which is sensible but not tuned). +are reached with Tab and adjusted with the arrow keys (tested). + +Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, tests in `tests_canvas_keys.rs`): +every handle drawn on the photo has an invisible stand-in that takes the focus. Tab reaches it, a +screen reader reads its name and position ("Crop bottom-right corner", "left 0%, top 0%, right +100%, bottom 100%"; "Spot 1", "x 30%, y 60%, size 5%"), and the arrow keys move it by 0.5% of the +photo (Shift: 5%), one undo step per press. While it has the focus it keeps the arrows, Return +and Space, and the app's other shortcuts still work. +- Crop: four corners, four edges and the whole box (`crop.drag`, so the aspect lock and image + bounds apply as they do for a drag); Return applies the crop. +- Remove: each spot's target and source; a "New spot" point that starts at the centre, moves + with the arrows, and turns into a spot of the brush's size when you press Return. +- Masking: the four edges and the rotation handle of a radial gradient, the start and end of a + linear one, and each mask pin (moving one selects its mask). + +Focus order: the audit now also counts any widget that takes the focus or a click, whatever its +role. That found about 50 hand-drawn icons, tiles and rows with no name: top-bar icons, flags and +stars, filmstrip and survey tiles, preset and profile rows, curve channels, colour wheels, mask +tiles and rows, slider labels and readouts. They are now announced as buttons. A test walks Tab +through the Edit, Crop, Remove and Masking panels and checks that it goes top to bottom (the tool +strip beside them is its own column, walked first). No reordering was needed once nothing +unnamed was in the way. + +Not done: brush strokes and red-eye ellipses still need a pointer. Nobody has tried the app with +a real screen reader on any platform yet. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | From a44a4baa3107ccef85306ea464b293757ea24cb2 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 04:28:43 +1100 Subject: [PATCH 019/315] LR-IMP-CAMERA-COVERAGE: verify 117 Nikon and Fujifilm bodies on CC0 files One CC0 raw.pixls.us file per Nikon and Fujifilm body that decodes is added to RAW_SAMPLES and pinned in crates/raw/tests/body_corpus.rs: own sensor hash plus a black-box check that the decoded green follows the camera's embedded JPEG (r 0.77-1.00). The sweep found COOLPIX 5700 / 8400 / 8800 decoding wrongly (two stacked half-height fields, CMYG read as RGB): recorded as unsupported. #702 is fixed, so the A100 evidence row is preview, not unsupported. Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint. Docs: evidence.tsv + coverage re-measure (352 verified, 86% use-weighted, 58% on verified bodies), gap 10, checklist, raw-parity, target-app-parity, ROADMAP. Headline numbers unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 8 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/tests/body_corpus.rs | 204 +++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 38 +- docs/raw/coverage.tsv | 244 ++++++------- docs/raw/evidence.tsv | 124 ++++++- docs/target-app-parity.md | 19 +- xtask/src/main.rs | 590 ++++++++++++++++++++++++++++++++ 10 files changed, 1082 insertions(+), 159 deletions(-) create mode 100644 crates/raw/tests/body_corpus.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..9af65b55c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (117 Nikon and Fujifilm bodies verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 24% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -114,6 +114,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked + against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 + decoding wrongly. 352 models verified, ~58% of photographers on a verified body; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -161,6 +164,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (352 models, 24%); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/tests/body_corpus.rs b/crates/raw/tests/body_corpus.rs new file mode 100644 index 000000000..109f567fa --- /dev/null +++ b/crates/raw/tests/body_corpus.rs @@ -0,0 +1,204 @@ +//! One CC0 file per Nikon and Fujifilm body from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.77–1.00 on these files, a misread layout or stream gives r near 0). +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn nikon_and_fujifilm_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples) + let cases: &[(&str, (usize, usize), u64)] = &[ + ("dng-nikon-d80.dng", (3900, 2611), 0x0f79_2821_2660_abae), + ("nef-nikon-1-aw1.nef", (4620, 3084), 0xbe33_8eec_3c72_fa8f), + ("nef-nikon-1-j1.nef", (3904, 2606), 0x29e0_7678_537c_7bc8), + ("nef-nikon-1-j2.nef", (3904, 2606), 0xd7b6_ca84_0c3f_1260), + ("nef-nikon-1-j3.nef", (4620, 3084), 0x83de_9d80_3aaa_ccf0), + ("nef-nikon-1-j4.nef", (5248, 3504), 0xf226_1ee3_578c_e841), + ("nef-nikon-1-j5.nef", (5584, 3726), 0x525e_c8ca_df29_13f1), + ("nef-nikon-1-s1.nef", (3948, 2604), 0x0464_ed24_fdd0_772d), + ("nef-nikon-1-v1.nef", (3904, 2606), 0xedfd_ff98_70dd_9e89), + ("nef-nikon-1-v2.nef", (4620, 3084), 0x99af_e262_e1d0_6ccb), + ("nef-nikon-1-v3.nef", (5248, 3504), 0x0951_aa90_ad1f_50ae), + ("nef-nikon-coolpix-a.nef", (4992, 3280), 0x875a_ed86_a3ad_09c6), + ("nef-nikon-d1h.nef", (2012, 1324), 0x27ce_4372_4d64_8bc5), + ("nef-nikon-d200.nef", (3904, 2616), 0xd33f_f00a_a90f_0f78), + ("nef-nikon-d2h.nef", (2496, 1648), 0x0ca6_7d68_03aa_4ae1), + ("nef-nikon-d2hs.nef", (2496, 1648), 0x9f70_b976_d7d0_a5a8), + ("nef-nikon-d2x.nef", (4320, 2868), 0xd5b9_d3b6_1efc_6cb8), + ("nef-nikon-d2xs.nef", (4320, 2868), 0x4f84_d44d_0d9f_c09f), + ("nef-nikon-d3000.nef", (3904, 2616), 0x9311_0df8_1dca_1e6a), + ("nef-nikon-d300.nef", (4352, 2868), 0xc7f3_1049_d8f8_17db), + ("nef-nikon-d300s.nef", (4352, 2868), 0x9c8c_97ba_c369_e6ea), + ("nef-nikon-d3100.nef", (4672, 3084), 0x916d_09e7_01cf_0314), + ("nef-nikon-d3200.nef", (6080, 4012), 0xd267_603b_2d71_6fe0), + ("nef-nikon-d3300.nef", (6016, 4016), 0x93ea_fe7f_2bba_3f5c), + ("nef-nikon-d3400.nef", (6016, 4016), 0xa55c_8f4a_662f_f9af), + ("nef-nikon-d3500.nef", (6016, 4016), 0xb1ee_bbd1_346a_3d92), + ("nef-nikon-d3.nef", (4288, 2844), 0x722b_6aaa_4347_1793), + ("nef-nikon-d3s.nef", (4288, 2844), 0x2703_65d8_5629_1ea0), + ("nef-nikon-d3x.nef", (6080, 4044), 0x52c3_e70b_7199_951e), + ("nef-nikon-d40.nef", (3040, 2014), 0x8536_6f21_fbb0_5eb9), + ("nef-nikon-d40x.nef", (3904, 2616), 0xffe9_2bb5_14ca_534f), + ("nef-nikon-d4.nef", (4992, 3292), 0xd8c0_c164_e77e_3d65), + ("nef-nikon-d4s.nef", (4936, 3288), 0xfb04_e701_dab5_189e), + ("nef-nikon-d5000.nef", (4352, 2868), 0xb8f6_8efb_0254_6fbc), + ("nef-nikon-d500.nef", (5600, 3728), 0x9482_f4ef_7856_ad4e), + ("nef-nikon-d50.nef", (3040, 2014), 0xe23b_8f5e_4b96_b285), + ("nef-nikon-d5200.nef", (6036, 4020), 0xd148_2f59_a727_a1e2), + ("nef-nikon-d5300.nef", (6016, 4016), 0xb141_e60b_754f_0ffc), + ("nef-nikon-d5500.nef", (6016, 4016), 0x2ce0_0d79_4dac_db43), + ("nef-nikon-d5600.nef", (6016, 4016), 0x2a20_0cfc_45f1_5db0), + ("nef-nikon-d5.nef", (5584, 3728), 0xfc01_902d_7732_c5ba), + ("nef-nikon-d600.nef", (6080, 4028), 0x9936_16eb_56f3_7729), + ("nef-nikon-d60.nef", (3904, 2616), 0x314f_043e_4a1e_0428), + ("nef-nikon-d610.nef", (6080, 4028), 0x5948_ec20_f2c9_f1ff), + ("nef-nikon-d700.nef", (4288, 2844), 0x507c_19fc_f74a_ce38), + ("nef-nikon-d70.nef", (3040, 2014), 0xb148_5e4c_0948_bcf7), + ("nef-nikon-d70s.nef", (3040, 2014), 0xcd2e_6f87_1fbe_8c5e), + ("nef-nikon-d7100.nef", (6036, 4020), 0x0cbd_07b1_c8d0_5524), + ("nef-nikon-d7200.nef", (6016, 4016), 0xe0a0_2fd9_5e26_c48b), + ("nef-nikon-d750.nef", (6032, 4032), 0xc229_3104_e9c0_8a7f), + ("nef-nikon-d780.nef", (6064, 4040), 0x9394_1205_66c4_0394), + ("nef-nikon-d800e.nef", (7424, 4924), 0x2ebe_8c60_c0fd_0c98), + ("nef-nikon-d800.nef", (7424, 4924), 0x205d_3756_412d_9312), + ("nef-nikon-d810.nef", (7380, 4928), 0x7065_5115_76ac_80d5), + ("nef-nikon-d850.nef", (6216, 4136), 0x7735_4e78_daec_87f0), + ("nef-nikon-d90.nef", (4352, 2868), 0x648c_5bdf_7287_4b88), + ("nef-nikon-df.nef", (4992, 3292), 0x563f_1897_96a9_31df), + ("nef-nikon-z-30.nef", (5600, 3728), 0x949f_f3eb_d96e_2444), + ("nef-nikon-z50-2.nef", (5600, 3728), 0xb896_6b21_ac27_ad07), + ("nef-nikon-z-50.nef", (5600, 3728), 0x3409_8168_87db_9bee), + ("nef-nikon-z-5.nef", (6040, 4032), 0x214b_cc7b_5954_7ad2), + ("nef-nikon-z-6-2.nef", (6064, 4040), 0xd40b_c647_373d_c2f8), + ("nef-nikon-z-7-2.nef", (8288, 5520), 0xde38_97ec_16cc_c71e), + ("nef-nikon-z-9.nef", (8280, 5520), 0x7be1_80fd_f125_bc18), + ("nef-nikon-z-fc.nef", (5600, 3728), 0xa65e_5bf4_6937_3625), + ("nrw-nikon-coolpix-a1000.nrw", (4608, 3456), 0x2ab8_7617_000c_0a7d), + ("nrw-nikon-coolpix-p1000.nrw", (4624, 3470), 0xb512_6adb_9e72_6cae), + ("nrw-nikon-coolpix-p1100.nrw", (4624, 3470), 0x7262_1601_b15b_4bba), + ("nrw-nikon-coolpix-p950.nrw", (4624, 3470), 0xdc59_8f4d_a414_edfb), + ("raf-fujifilm-finepix-f550exr.raf", (3328, 2464), 0x4247_157c_9d70_b359), + ("raf-fujifilm-finepix-f770exr.raf", (3328, 2464), 0x6df4_5810_a581_1e9b), + ("raf-fujifilm-finepix-hs30exr.raf", (3328, 2464), 0x5d4c_2901_70fa_83c3), + ("raf-fujifilm-finepix-hs33exr.raf", (3328, 2464), 0x7172_f98c_0034_0dde), + ("raf-fujifilm-finepix-hs50exr.raf", (3328, 2464), 0xad53_aa23_e481_4fcc), + ("raf-fujifilm-finepix-s1.raf", (4624, 3470), 0xde37_e148_27f2_4305), + ("raf-fujifilm-finepix-sl1000.raf", (4624, 3470), 0xcbb1_b0f1_9dce_bdbc), + ("raf-fujifilm-finepix-x100.raf", (4448, 2870), 0x8c7b_bdea_47c3_01bb), + ("raf-fujifilm-finepix-x10.raf", (2944, 2144), 0xb1a0_13ee_0889_65ef), + ("raf-fujifilm-gfx100-ii.raf", (11808, 8754), 0x5677_9820_a59b_43d8), + ("raf-fujifilm-gfx100s-ii.raf", (11808, 8754), 0x9bb6_d871_b488_1a2b), + ("raf-fujifilm-gfx-50r.raf", (9216, 6210), 0x2da2_1f02_2188_01e9), + ("raf-fujifilm-gfx50s-ii.raf", (8472, 6210), 0x0657_0932_cc9b_04a9), + ("raf-fujifilm-x100f.raf", (6048, 4038), 0x44d7_06fd_e255_f55c), + ("raf-fujifilm-x100s.raf", (4992, 3296), 0x8bb0_be8f_8e2f_0114), + ("raf-fujifilm-x100t.raf", (4992, 3296), 0x83a0_fc55_ba4a_8a34), + ("raf-fujifilm-x100vi.raf", (7872, 5196), 0xe0bd_5ff9_a791_3b1e), + ("raf-fujifilm-x100v.raf", (6384, 4182), 0x02ee_e2eb_3f29_017e), + ("raf-fujifilm-x20.raf", (4096, 3012), 0xc13a_34e9_bedb_7b65), + ("raf-fujifilm-x30.raf", (4096, 3012), 0x9d28_789e_b37a_d23a), + ("raf-fujifilm-x70.raf", (4992, 3296), 0xb49d_8575_ed65_191c), + ("raf-fujifilm-x-a10.raf", (4912, 3278), 0xb681_5176_24ae_ea80), + ("raf-fujifilm-x-a1.raf", (5120, 3288), 0xbfe5_5bc3_818d_aff0), + ("raf-fujifilm-x-a3.raf", (6016, 4014), 0xab8b_9cf0_25dc_29dd), + ("raf-fujifilm-x-a7.raf", (6016, 4014), 0x3c75_95af_4bdb_5d1e), + ("raf-fujifilm-x-e2.raf", (4992, 3296), 0x18b2_ac1b_a7ec_e687), + ("raf-fujifilm-x-e2s.raf", (4992, 3296), 0x334a_900e_4d2e_6f7a), + ("raf-fujifilm-x-e3.raf", (6160, 4032), 0x9dae_1182_a6ff_f11a), + ("raf-fujifilm-x-e4.raf", (6384, 4182), 0x05ea_0600_153c_af50), + ("raf-fujifilm-xf10.raf", (6016, 4014), 0xc8fb_db43_3419_aee8), + ("raf-fujifilm-xf1.raf", (2944, 2144), 0x9860_bc05_28ca_7b68), + ("raf-fujifilm-x-h1.raf", (6048, 4038), 0x235f_cef5_1ece_f7e0), + ("raf-fujifilm-x-m1.raf", (5120, 3288), 0xe6a2_89ab_2a70_7f59), + ("raf-fujifilm-x-pro1.raf", (5120, 3288), 0xf5eb_1273_c2d4_0d05), + ("raf-fujifilm-x-pro2.raf", (6160, 4032), 0xf268_21e1_9eac_3aaf), + ("raf-fujifilm-x-pro3.raf", (6384, 4182), 0x08d9_6383_c9c5_68da), + ("raf-fujifilm-xq1.raf", (4096, 3012), 0x5e6f_2478_974a_775f), + ("raf-fujifilm-xq2.raf", (4096, 3012), 0x755d_a621_b230_a779), + ("raf-fujifilm-x-s10.raf", (6384, 4182), 0xc987_263e_0cb0_99ee), + ("raf-fujifilm-x-s1.raf", (2944, 2144), 0xfeb2_062d_7766_3ed6), + ("raf-fujifilm-x-s20.raf", (6336, 4182), 0x2b15_357d_7db0_1e0a), + ("raf-fujifilm-x-t100.raf", (6016, 4014), 0xfc92_1c91_2075_1751), + ("raf-fujifilm-x-t10.raf", (4992, 3296), 0x84cf_2c71_7140_74d0), + ("raf-fujifilm-x-t1.raf", (4992, 3296), 0x944d_128e_65c7_6375), + ("raf-fujifilm-x-t200.raf", (6016, 4014), 0xc235_b051_f6e0_8ccc), + ("raf-fujifilm-x-t30-iii.raf", (6336, 4182), 0x635b_00d6_8d24_d41e), + ("raf-fujifilm-x-t30-ii.raf", (6384, 4182), 0x88b7_2d0c_92bc_4d9b), + ("raf-fujifilm-x-t30.raf", (6384, 4182), 0xd589_f85d_366e_6e2a), + ("raf-fujifilm-x-t3.raf", (6384, 4182), 0x8d50_a172_374f_a578), + ]; + let mut seen = 0; + for &(name, sensor, hash) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Nikon / Fujifilm bodies checked on {seen} files"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..d23d7644a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 10: 117 Nikon and Fujifilm bodies verified, COOLPIX 5700 / 8400 / 8800 found decoding wrongly; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 352 of 1,446 Adobe models verified (24%), ~58% of photographers on a verified body; Nikon 73 bodies verified, Fujifilm 65 (one CC0 file each in `crates/raw/tests/body_corpus.rs`), Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671; the same refusal hits the CC0 Z 6, Z 7, D6, D100, D1X, P330, P7700 and P7800 files), DSLR-A200…A390 12-bit ARW (#535: Sony's version-1 compression, no public description), COOLPIX 5700 / 8400 / 8800 decode wrongly (stacked half-height fields, CMYG read as RGB), A100 raw (#702's regression is fixed: it opens as a preview) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 10 shrinks: 117 Nikon and Fujifilm bodies verified on one CC0 file each (352 models, ~58% on a verified body); COOLPIX 5700 / 8400 / 8800 decode wrongly; #702 is fixed | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..686fac811 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-CAMERA-COVERAGE: 117 Nikon and Fujifilm bodies pinned on CC0 files; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases, 4 pinned EOS R5 Mark II files and one pinned CC0 file for each of 118 Nikon and Fujifilm bodies checked against its camera JPEG (`crates/raw/tests/body_corpus.rs`; COOLPIX 5700 / 8400 / 8800 decode wrongly: two stacked half-height fields, CMYG read as RGB) (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: 117 Nikon and Fujifilm Adobe models verified on one CC0 file each; COOLPIX 5700 / 8400 / 8800 found decoding wrongly; status unchanged (🟡) | | 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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..4ad380f47 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (117 Nikon and Fujifilm bodies verified on one CC0 file each; COOLPIX 5700 / 8400 / 8800 found decoding wrongly) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,13 +14,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | -| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | -| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | +| Decoded from sensor data and **verified** on a real file | **352** (24%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **825** (57%) | measured from the format rules (632 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **231** (16%) | measured | +| **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 116 / 606 / 24 / 8 of 754 | measured | +| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~86%** | estimated: measured classes × written usage weights × per-format confidence | +| …of which on a body we have verified | **~58%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | @@ -43,7 +43,8 @@ committed table is stale. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; - the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference + the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), `crates/raw/tests/body_corpus.rs` (117 Nikon and Fujifilm bodies, one CC0 raw.pixls.us file each, pinned and + checked against the camera JPEG), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the @@ -63,13 +64,13 @@ committed table is stale. | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | +| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported | | Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | | Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | -| Nikon | 99 | 5 | 94 | 0 | 0 | — (HE / HE★ is a per-shot setting, preview-only) | -| Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | +| Nikon | 99 | 73 | 23 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only | +| Fujifilm | 97 | 65 | 9 | 23 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | | Google | 88 | 1 | 87 | 0 | 0 | — | | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | | Leica | 61 | 13 | 48 | 0 | 0 | — | @@ -81,7 +82,7 @@ committed table is stale. | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **352 (24%)** | **825 (57%)** | **231 (16%)** | **38 (3%)** | | ## By use (estimated) @@ -89,8 +90,8 @@ committed table is stale. |---|---:|---:|---:|---| | Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | -| Fujifilm | 10% | 91% | 45% | | +| Nikon | 16% | 98% | 83% | decoders cover every NEF coding but HE; 73 bodies verified | +| Fujifilm | 10% | 94% | 92% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | | Phones | 3% | 93% | 1% | DNG | @@ -98,7 +99,7 @@ committed table is stale. | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | | Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **85%** | **42%** | | +| **Weighted** | 100% | **86%** | **58%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually checked, and Canon, the largest bucket, is the weakest of the big four. @@ -224,7 +225,7 @@ drone DNG users (~9% of photographers by the weights above); Adobe reaches nearl | Part | Weight | Score | Basis | |---|---:|---:|---| -| Decoding | 60% | **77%** | use-weighted 85% × 0.9 (76.5%) for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | +| Decoding | 60% | **77%** | use-weighted 86% × 0.9 (77.4%) for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | | Colour | 25% | **35%** | see [Colour](#colour) | | Lens profiles | 15% | **10%** | see [Lens profiles](#lens-profiles) | | **RAW readiness** | 100% | **~56%** (range 50–60%) | 45.9 + 8.75 + 1.5; demosaic and highlight quality unmeasured | @@ -244,7 +245,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). |---:|---|---:|---:|---:|---| | 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) | | 2 | CR3: remaining CRX variants, then verify the other 14 popular CR3 bodies | +6.2 | 25–45 | ~0.19 | samples per body (raw.pixls.us has most) | -| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples | +| 3 | Verify popular Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks; Nikon and Fujifilm done) | +1.0 | 4–8 | ~0.17 | public samples | | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | | 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `body_corpus.rs`); COOLPIX 5700 / 8400 / 8800 decode wrongly (now unsupported); #702 fixed, A100 preview-only: 352 verified, 825 unverified, 231 preview, 38 unsupported; use-weighted 86% (58% on verified bodies); decoding 77% unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..723e47ed6 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -299,36 +299,36 @@ DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only Epson R-D1s ERF 2005 preview ERF is opened from its embedded JPEG only Epson R-D1x ERF 2008 preview ERF is opened from its embedded JPEG only -Fujifilm GFX 50R RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 50R RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 50S II RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 50S II RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 RAF 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100 II RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100 II RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 II IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm GFX 100 IR RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm GFX 100S RAF 2020 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100S II RAF 2023 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100S II RAF 2023 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100RF (Final Support) RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm FinePix E900 RAF 2005 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F505 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix F550 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F550 EXR RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix F600 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F605 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F700 RAF 2003 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix F770 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F770 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix F775 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F800 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F900 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix HS10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm FinePix HS20 EXR RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm FinePix HS22 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS30 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS33 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS30 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS33 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix HS35 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS50 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS50 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix IS-1 RAF 2007 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix IS Pro RAF 2008 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix S1 RAF 2013 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix S100fs RAF 2007 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S2 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S20 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD @@ -342,59 +342,59 @@ Fujifilm FinePix S6000fd (FinePix S6500fd) RAF 2005 preview pre-X FinePix RAF wi Fujifilm FinePix S7000 Z RAF 2003 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S9000 (FinePix S9500) RAF 2005 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S9100 (FinePix S9600) RAF 2005 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix SL1000 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X100 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X-M1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X-S1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100F RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100S RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100T RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100V** RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100VI RAF 2023 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X20 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X30 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X70 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-A1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix SL1000 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X10 RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X100 RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-M1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-S1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100F RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100S RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100T RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100V** RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100VI RAF 2023 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X20 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X30 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X70 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A3 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A3 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A5 RAF 2017 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A7 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-A10 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A7 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A10 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A20 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-E1 RAF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-E2 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E2S RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E3 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E4 RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E2 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2S RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E3 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E4 RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-E5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-H1 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-H1 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-H2 IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-H2S RAF 2021 verified docs/raf-compression.md (supplied files, exact sensor arrays) Fujifilm X-M5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-Pro1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-Pro2 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-Pro3** RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-S10 RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-S20 RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-Pro1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro2 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro3** RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S10 RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S20 RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T1 IR RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-T2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T3 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T3 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T4** RAF 2019 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T5 RAF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T10 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T10 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T20 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T30 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T30 II RAF 2021 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T30 III RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T30 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 II RAF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 III RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T50 RAF 2023 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T100 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T200 RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XF1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XF10 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XQ1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XQ2 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T100 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T200 RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF10 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ2 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-H2 RAF 2021 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Google Pixel DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel XL DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -677,103 +677,103 @@ Mamiya ZD MEF 2005 unsupported MEF is not imported Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Nikon 1 J1 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J2 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J3 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J4 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J5 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 S1 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 J1 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J2 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J3 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J4 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J5 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 S1 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon 1 S2 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V1 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V2 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V3 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 V1 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V2 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V3 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX 5000 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX 5400 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX 5700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX 8400 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 5700 NEF 2003 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX 8400 NEF 2005 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Nikon COOLPIX 8700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX 8800 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX A NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX A1000 NRW 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 8800 NEF 2005 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX A NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A1000 NRW 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX B700 NRW 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon COOLPIX P330 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P340 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P1000 NRW 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P1100 NRW 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P1000 NRW 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1100 NRW 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX P6000 NRW 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7000 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7100 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7700 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7800 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P950 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P950 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D1 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D1H NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D1H NEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D1X NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2H NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2Hs NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2X NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2Xs NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3s NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3X NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D4 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D4S NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D40 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D40x NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D50 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D60 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D2H NEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Hs NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2X NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Xs NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3s NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3X NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4S NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40x NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D50 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D60 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D6 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D70 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D70S NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D80 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D90 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D70 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70S NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D80 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D90 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D100 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D200 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D300 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D300s NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D500 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D600 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D610 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D700 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D750 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D780 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D800 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D800E NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D810 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D200 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300s NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D500 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D600 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D610 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D700 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D750 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D780 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800E NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D810 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D810A NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D850 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3000 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3100 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3200 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3300 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3400 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3500 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5000 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D850 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3000 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3100 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3200 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3300 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3400 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3500 NEF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5000 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D5100 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D5200 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5300 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5500 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5600 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5200 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5300 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5500 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5600 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D7000 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D7100 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D7200 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D7100 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7200 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D7500 NEF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon Df NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 30 NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 50 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 50 II NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 5 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Df NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 30 NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 II NEF 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 5 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 6 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 7 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 8 NEF 2022 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 9 (*See note) NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z fc NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 9 (*See note) NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z fc NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon ZR NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nokia Lumia 1020 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1278,7 +1278,7 @@ Sony A7R V (ILCE-7RM5) ARW 2022 verified issue #535: raw.pixls.us bulk check of Sony A7R VI (ILCE-7RM6) (** see note) ARW 2025 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony A99 (SLT-A99V) ARW 2012 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A99 II (ILCA-99M2) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony A100 (DSLR-A100) ARW 2005 unsupported issue #702: regression, refused as not a readable photo +Sony A100 (DSLR-A100) ARW 2005 preview issue #702 fixed (#774): opens from its embedded JPEG; its raw (no CFA image IFD) is not decoded Sony A200 (DSLR-A200) ARW 2007 preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) ARW 2008 preview issue #535: preview-only on the raw.pixls.us sample Sony A290 (DSLR-A290) ARW 2010 preview issue #535: preview-only on the raw.pixls.us sample @@ -1306,7 +1306,7 @@ Sony A6400 (ILCE-6400) ARW 2018 verified issue #535: raw.pixls.us bulk check of Sony A6500 (ILCE-6500) ARW 2015 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A6600 (ILCE-6600) ARW 2019 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A6700 (ILCE-6700) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 SRF 2003 unsupported SRF not in the import list (the probe accepts it again since #774, issue #702); raw not decoded Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-V3 SRF 2005 unsupported SRF not in the import list diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..07e5a9acb 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -49,6 +49,126 @@ Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7500 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D700 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100S II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D610 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2H verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 6 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Xs verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D780 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D850 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50R verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Df verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3s verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z fc verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D810 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800E verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100V** verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D80 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-H1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS30 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S20 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D60 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5600 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix SL1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro3** verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100VI verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F550 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 9 (*See note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Hs verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D90 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100T verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D600 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS50 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100F verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40x verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X20 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50S II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D1H verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS33 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 7 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300s verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D750 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3400 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-M1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P950 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F770 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX 5700 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX 8400 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX 8800 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus Stylus XZ-2 iHS verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -150,7 +270,7 @@ Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo +Sony A100 (DSLR-A100) preview issue #702 fixed (#774): opens from its embedded JPEG; its raw (no CFA image IFD) is not decoded Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample Sony A290 (DSLR-A290) preview issue #535: preview-only on the raw.pixls.us sample @@ -211,7 +331,7 @@ Sony A900 (DSLR-A900) verified issue #535: raw.pixls.us bulk check of all 100 So Sony A99 (SLT-A99V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A99 II (ILCA-99M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony Alpha 1 (ILCE-1)* verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 unsupported SRF not in the import list (the probe accepts it again since #774, issue #702); raw not decoded Sony DSC-HX95 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-V3 unsupported SRF not in the import list diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..20bb6602e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (117 Nikon and Fujifilm bodies verified: use-weighted decode share 85% → 86%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -67,7 +67,7 @@ from the checklist sections and the deep-dive documents. | Area | Weight | Breadth | Ready | Remaining | Notes | |---|---:|---:|---:|---:|---| | Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | -| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | +| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 86% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | | Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | | Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | | Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | @@ -129,7 +129,7 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): | Dimension | Weight | Ready | |---|---:|---:| | Features: the other 11 areas, weighted depth from [Features](#features) | 45% | 60.9% | -| RAW decoding and camera coverage | 9% | 77% (85% use-weighted × 0.9) | +| RAW decoding and camera coverage | 9% | 77% (86% use-weighted × 0.9) | | Camera colour and default rendering | 6% | 35% | | UI/UX fidelity | 8% | 70% | | File formats (non-raw) | 6% | 70% | @@ -171,18 +171,18 @@ than the user's own. |---|---:|---:| | Import and library | 18% | 80% | | Cull: views, compare, zoom, ratings | 12% | 70% | -| Raw decoding for their camera | 27% | 85% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | +| Raw decoding for their camera | 27% | 86% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | | Global develop adjustments | 15% | 70% | | Crop, geometry, lens corrections | 6% | 50% | | Masking (manual, subject / sky; no generative) | 8% | 45% | | Heal / remove (no generative), denoise | 5% | 40% | | Presets, copy / paste / sync | 5% | 85% | | Export | 4% | 75% | -| **Weighted depth** | 100% | **72.1%** | +| **Weighted depth** | 100% | **72.4%** | Colour fidelity is a written discount, **×0.85**: most raws start near the camera's own look (median ΔE ~5), about 10% far off (ΔE ~18), none calibrated to Adobe Standard, so a working photographer -corrects colour by hand (33 open colour issues). 72.1% × 0.804 × 0.85 = **~49%** (range 44–54%). +corrects colour by hand (33 open colour issues). 72.4% × 0.804 × 0.85 = **~49%** (range 44–54%). ### Essentials user: ~64% @@ -199,11 +199,11 @@ basic sliders, crop, apply a preset, undo, export JPEGs. | Crop and straighten | 10% | 80% | | Presets and profiles | 8% | 80% | | Undo and history | 5% | 90% | -| Their camera's raws: decode × default look | 10% | 64% (85% whose camera fully works × colour factor 0.75) | +| Their camera's raws: decode × default look | 10% | 65% (86% whose camera fully works × colour factor 0.75) | | Export JPEG | 10% | 85% | -| **Weighted depth** | 100% | **77.5%** | +| **Weighted depth** | 100% | **77.6%** | -77.5% × 0.830 = **~64%** (range 59–69%). +77.6% × 0.830 = **~64%** (range 59–69%). ### User evidence (GitHub issues, excluding the four maintainers) @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..190a160d8 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -885,6 +885,596 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "nef-nikon-d700.nef", + "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "00c3314f2ccf92aff17644943e6149e8793b517c24bb2c38a15ec41f3af109cb", + ), + ( + "raf-fujifilm-xf10.raf", + "https://raw.pixls.us/getfile.php/3124/nice/Fujifilm%20-%20XF10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "0486676fdbbdc7f3f4c63cd45aa76581cbc1316f4c9baef8781bced0fbc137db", + ), + ( + "raf-fujifilm-x-a10.raf", + "https://raw.pixls.us/getfile.php/3570/nice/Fujifilm%20-%20X-A10%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "05309c5434500cc53d5782072cc0dcdd947d2f0a34f0ddcd4656c82cc7803272", + ), + ( + "nef-nikon-d5300.nef", + "https://raw.pixls.us/getfile.php/8365/nice/Nikon%20-%20D5300%20-%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0ae88fc81f082929d76dd1b932aacf7815cd6bc670b96ef55a81928e11084a6c", + ), + ( + "raf-fujifilm-x-t100.raf", + "https://raw.pixls.us/getfile.php/2676/nice/Fujifilm%20-%20X-T100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "0af086b9569b59317da42b8e136013245486073b6b8d409fc997a71788538c4d", + ), + ( + "raf-fujifilm-x-pro1.raf", + "https://raw.pixls.us/getfile.php/1191/nice/Fujifilm%20-%20X-Pro1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "0b1046c299d9eb222a8f003bd047f2a34f507da0d1704ac32cefbc82f799d121", + ), + ( + "nef-nikon-1-v1.nef", + "https://raw.pixls.us/getfile.php/2210/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0dc76bc274f9cb776eb930683a4e53a3ebede1719abafd1cba4b0c800f13f7e4", + ), + ( + "nef-nikon-1-j4.nef", + "https://raw.pixls.us/getfile.php/5922/nice/Nikon%20-%201%20J4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0e2887892b8cfc80891b2c510721088fcac6d77eff9ba68089f382a0115b274c", + ), + ( + "raf-fujifilm-gfx100s-ii.raf", + "https://raw.pixls.us/getfile.php/7772/nice/Fujifilm%20-%20GFX100S%20II%20-%2016bit%20uncompressed%20%284:3%29.RAF", + "10e007f3d1aef2ac9bf4da063ea1983e44515af818bf0ab63e3703cd67803810", + ), + ( + "nef-nikon-d610.nef", + "https://raw.pixls.us/getfile.php/888/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "11f0a71bb4141a8ed44c7e1c64f9a12571f5f67a11571f2bde8516aa95a7bea3", + ), + ( + "nef-nikon-d5.nef", + "https://raw.pixls.us/getfile.php/1636/nice/Nikon%20-%20D5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "13191560a6a9d9ed4156ec5d20863277c9618916aaba934758c5ae2be6654f6a", + ), + ( + "nef-nikon-d2h.nef", + "https://raw.pixls.us/getfile.php/5227/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "155edb938f884ea7372ce98d4ff5f965c3e413b43b95bc9923da6e92082cf914", + ), + ( + "nef-nikon-z-6-2.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), + ( + "nrw-nikon-coolpix-p1000.nrw", + "https://raw.pixls.us/getfile.php/3381/nice/Nikon%20-%20COOLPIX%20P1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "1d9470ee083f914e51aa0be782161b432f27f87dbef551a59ec435f5dbc289dd", + ), + ( + "raf-fujifilm-finepix-x100.raf", + "https://raw.pixls.us/getfile.php/2392/nice/Fujifilm%20-%20FinePix%20X100%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "1f4aea4fa43dd314f8b90954d7823655d4906d3e468a9ea014652b872a68db16", + ), + ( + "raf-fujifilm-x30.raf", + "https://raw.pixls.us/getfile.php/1144/nice/Fujifilm%20-%20X30%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "1f65f680600c85a6f561d2b0ad7cd1f9d93ee7f733aafe4ecd37667d984dd6c4", + ), + ( + "raf-fujifilm-finepix-x10.raf", + "https://raw.pixls.us/getfile.php/1344/nice/Fujifilm%20-%20FinePix%20X10%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "206448688c49b0a9da74dd94f12aba143c2d8842cb5bbf64d4ecdc501874451b", + ), + ( + "nef-nikon-d2xs.nef", + "https://raw.pixls.us/getfile.php/2871/nice/Nikon%20-%20D2Xs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "2204bc2e2178bf594e17886ca92a50a94350403a110846ca6e83bdc5f3352706", + ), + ( + "nef-nikon-d4.nef", + "https://raw.pixls.us/getfile.php/1974/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "23aa2dd3c409b3cbbfeea3e3f7b6752180efa15430d6e059e87404e969875cd7", + ), + ( + "nef-nikon-d800.nef", + "https://raw.pixls.us/getfile.php/750/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "26d58c21ed3019af5a22ef6bc02f017b863aad9eafcf84252ed375cd495c0efb", + ), + ( + "nef-nikon-d780.nef", + "https://raw.pixls.us/getfile.php/3825/nice/Nikon%20-%20D780%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "3178d0656e3b5fbe90176e38a1cd608d0b69292299445932dddc9a0b2d196b36", + ), + ( + "nef-nikon-d850.nef", + "https://raw.pixls.us/getfile.php/1835/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "31b7fe298a5c91694c6ca8b3284a0a016e572a02c3964b502d4732e8e9283da8", + ), + ( + "raf-fujifilm-x-e2s.raf", + "https://raw.pixls.us/getfile.php/745/nice/Fujifilm%20-%20X-E2S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "330fc84b597e7be6d7477aad5715705efaa99d4663eb05aa47e1250d6f421549", + ), + ( + "nef-nikon-d500.nef", + "https://raw.pixls.us/getfile.php/2504/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "33c96a2ba7167f780928767c0545719737d601883e5f98e5edba4f71bdc99d83", + ), + ( + "raf-fujifilm-gfx-50r.raf", + "https://raw.pixls.us/getfile.php/2979/nice/Fujifilm%20-%20GFX%2050R%20-%2014bit%2014bit%20compressed%20%283:2%29.raf", + "35109fc20a849026c43c47674902431ef5a2d54b09b616995263dc75df17912f", + ), + ( + "nef-nikon-df.nef", + "https://raw.pixls.us/getfile.php/1385/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "38ea7aba6b7e0cdbd338fe6703d3c98291f4a9a28b0d1a81bfb1f77b21712c63", + ), + ( + "raf-fujifilm-x-s10.raf", + "https://raw.pixls.us/getfile.php/4188/nice/Fujifilm%20-%20X-S10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "3d080f9ce8ac65c4beab1e9c0f49f7c72cdec71f31fe9e27dd1d88e551a1c262", + ), + ( + "nef-nikon-1-v2.nef", + "https://raw.pixls.us/getfile.php/1693/nice/Nikon%20-%201%20V2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3d29dfa6397b0aa4e72969bb5b85d040e8bebf2e02f61f6364f580ce39f30bf2", + ), + ( + "nef-nikon-1-v3.nef", + "https://raw.pixls.us/getfile.php/2802/nice/Nikon%20-%201%20V3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3f5376481f9ae59e9712b537dcc4eaffa72e311a8768c5396414506ae2eb1d69", + ), + ( + "nef-nikon-d3s.nef", + "https://raw.pixls.us/getfile.php/5203/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "402898e57007a4176b8686019ef2878c28481f7520c6136127f00148de9960b5", + ), + ( + "nef-nikon-z-fc.nef", + "https://raw.pixls.us/getfile.php/4812/nice/Nikon%20-%20Z%20fc%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", + "4099ecc46ebaca26e2a66a96a8313fed5e8c707b3836ccf0e77a134844b041c1", + ), + ( + "nef-nikon-d810.nef", + "https://raw.pixls.us/getfile.php/2029/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "40a9df99865ad25a68b1131014cf954fb72da8bc32991b6a5e19d14937e21bc9", + ), + ( + "raf-fujifilm-x-a3.raf", + "https://raw.pixls.us/getfile.php/1371/nice/Fujifilm%20-%20X-A3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4187cb5caa72320a74e9b4355db01e8e21edd43f3084873f9b9b67169d8d4ad8", + ), + ( + "nef-nikon-d40.nef", + "https://raw.pixls.us/getfile.php/3618/nice/Nikon%20-%20D40%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "44e88bc77b7a531b22647bcd07b9393c4568062e8f0906d3bbdecb42fbe29e03", + ), + ( + "nef-nikon-d800e.nef", + "https://raw.pixls.us/getfile.php/1303/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "4559b9614ab6d558192d9d30e1db4a4ec44752553e45b2bd26c1569c1820441a", + ), + ( + "nef-nikon-d3x.nef", + "https://raw.pixls.us/getfile.php/3708/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "48dc0339ef86541aabcc6bb68a6452f603e23a7d7a5597dad0d267f69e3e79d4", + ), + ( + "raf-fujifilm-x100v.raf", + "https://raw.pixls.us/getfile.php/3812/nice/Fujifilm%20-%20X100V%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "48f2055ab497c687347eee84103d4f48deb016fff711d2810eaf1317e506f621", + ), + ( + "nef-nikon-d4s.nef", + "https://raw.pixls.us/getfile.php/2220/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "49067c81374549465f3c621acd249bf05dbdef4e44b9201fef1fb11c93572983", + ), + ( + "raf-fujifilm-x-t30-iii.raf", + "https://raw.pixls.us/getfile.php/8769/nice/Fujifilm%20-%20X-T30%20III%20-%2014bit%20compressed%20%283:2%29.RAF", + "49f77d6162abfa5c94d2d8b90e4e926b7386c42bcf7e84a152c9ffe1ebd584da", + ), + ( + "raf-fujifilm-xq2.raf", + "https://raw.pixls.us/getfile.php/1844/nice/Fujifilm%20-%20XQ2%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "4bce88593f5ae8fc45aeeec8a6dee9a84c773493cc6db9dabdc28744c8ac4b4d", + ), + ( + "raf-fujifilm-x-t10.raf", + "https://raw.pixls.us/getfile.php/1192/nice/Fujifilm%20-%20X-T10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4c09401975cc6f95b1284693aac39fef85498f70b55e35bf458a133c6e61885d", + ), + ( + "nef-nikon-d2x.nef", + "https://raw.pixls.us/getfile.php/9064/nice/Nikon%20-%20D2X%20-%2012bit%20uncompressed%20%283:2%29.NEF", + "4d4b89f85bdfef233ef9b8aa7ecfeb609874ab592cb44e777f5b36814df5883c", + ), + ( + "raf-fujifilm-x70.raf", + "https://raw.pixls.us/getfile.php/1301/nice/Fujifilm%20-%20X70%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4f9c33fde578addebda991c37d26551b46fab8f592c74f7192560a05ec08d577", + ), + ( + "dng-nikon-d80.dng", + "https://raw.pixls.us/getfile.php/1988/nice/Nikon%20-%20D80%20-%2016bit%20%283:2%29.dng", + "5095ca264e763624cd3aa62940286dd1bcff4504eefa1ce61cbb72f50f8426bb", + ), + ( + "nef-nikon-1-aw1.nef", + "https://raw.pixls.us/getfile.php/1171/nice/Nikon%20-%201%20AW1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "519d354ec40907f1cd30329fc1a641ddec5b46e08e88883f3cf2fddc4ddd9974", + ), + ( + "raf-fujifilm-x-h1.raf", + "https://raw.pixls.us/getfile.php/2537/nice/Fujifilm%20-%20X-H1%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "5210bf896d2757cecda88bab0a87ef0b534d4b36c31dc08d5db7cd8f5bbf9b4e", + ), + ( + "nef-nikon-1-j5.nef", + "https://raw.pixls.us/getfile.php/1342/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", + "56efc4f426fa489383a967102c6d30fa9d6b198ca0484c4725c91f0b0d47c063", + ), + ( + "nef-nikon-1-j1.nef", + "https://raw.pixls.us/getfile.php/1017/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "5965935729931b72ca8398041f8fc15cb0a226004db941d1f0626ccc2c5bb5f3", + ), + ( + "nef-nikon-d7200.nef", + "https://raw.pixls.us/getfile.php/832/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "5dea8ff6f95f372e4eb0852f75e728f182d7aa4057b2b76631f0fd3066c6a4b5", + ), + ( + "raf-fujifilm-x-t30-ii.raf", + "https://raw.pixls.us/getfile.php/5064/nice/Fujifilm%20-%20X-T30%20II%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "5e2678038377cc7f146ceb7693c08535fa1af162cec705d06b0446c3d3017d47", + ), + ( + "nef-nikon-z-50.nef", + "https://raw.pixls.us/getfile.php/3647/nice/Nikon%20-%20Z%2050%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "5e93735923229117abee2779d81f114f180f361e8ae553c9dd9bdaaedb0d9e02", + ), + ( + "nef-nikon-d5000.nef", + "https://raw.pixls.us/getfile.php/1341/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "5f8e8005be87366bbf7746d35c3c88f82a12a1dd18206c939d8f1254c5922360", + ), + ( + "raf-fujifilm-xq1.raf", + "https://raw.pixls.us/getfile.php/1741/nice/Fujifilm%20-%20XQ1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "663c0bfcb8819c3931444acd9d1491de4d0c0aa1fd745b99e1faa68cc8de033b", + ), + ( + "raf-fujifilm-finepix-hs30exr.raf", + "https://raw.pixls.us/getfile.php/2613/nice/Fujifilm%20-%20FinePix%20HS30EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "6b95bb5133d026dbbb7361985e81d9a2b93000e2aea88bcd3f120412d0061da7", + ), + ( + "nef-nikon-d70s.nef", + "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692", + ), + ( + "nef-nikon-d5200.nef", + "https://raw.pixls.us/getfile.php/1691/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "6c49ca47d189f9d6766f94be7c9d7d779ce45c79c0c6e48a0eba7d92f320fe0e", + ), + ( + "nrw-nikon-coolpix-p1100.nrw", + "https://raw.pixls.us/getfile.php/7689/nice/Nikon%20-%20Coolpix%20P1100%20-%2012bit%20uncompressed%20%284:3%29.NRW", + "706840f7a36c3852092d4aad0465e75920e04f1aa653adf31626c0c05ab41258", + ), + ( + "raf-fujifilm-x-s20.raf", + "https://raw.pixls.us/getfile.php/6666/nice/Fujifilm%20-%20X-S20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "737c1551843282d4963ccb351a4d6409f3eaf172928d563bbaf72fe0f1f74fdd", + ), + ( + "nef-nikon-z-30.nef", + "https://raw.pixls.us/getfile.php/5812/nice/Nikon%20-%20Z%2030%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "75249c11a3a845367c7a322a1c4e7076f33c785ec2518fc6594c34c185b41161", + ), + ( + "raf-fujifilm-x-e2.raf", + "https://raw.pixls.us/getfile.php/937/nice/Fujifilm%20-%20X-E2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "7588c70af7fd6be3abcec52546aabcd04e0aafc6541e7f7f50bdc60b60513c57", + ), + ( + "nef-nikon-d60.nef", + "https://raw.pixls.us/getfile.php/3698/nice/Nikon%20-%20D60%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "780ce4867e997143acbd1734a889575a5eaeeb40c6afb33b6006c266831caaa6", + ), + ( + "nef-nikon-1-j2.nef", + "https://raw.pixls.us/getfile.php/923/nice/Nikon%20-%201%20J2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "81051ee36135883acea55bd1e609d6d31212c0bf03640b7ae9fe177a1ca51219", + ), + ( + "nef-nikon-d5600.nef", + "https://raw.pixls.us/getfile.php/1415/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "82e15f02c4037820e80f14bacfacdc52ea29721b3c70d2cf08b237b50ce8db05", + ), + ( + "nef-nikon-d3.nef", + "https://raw.pixls.us/getfile.php/3516/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8", + ), + ( + "nef-nikon-d300.nef", + "https://raw.pixls.us/getfile.php/924/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "8b989f53c96995f7014ddd16124530cf6d8f3341ea9570d6312d9da1eb0a117a", + ), + ( + "raf-fujifilm-finepix-sl1000.raf", + "https://raw.pixls.us/getfile.php/5760/nice/Fujifilm%20-%20FinePix%20SL1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "8bc9903cb1bff93376eb73b64f35861dcf3c66e2ca936cfac2d7c62435689dec", + ), + ( + "raf-fujifilm-x-pro3.raf", + "https://raw.pixls.us/getfile.php/3700/nice/Fujifilm%20-%20X-Pro3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8c50e965d7627932414e8790a5ef1d673fcb44b548b64130df6b9be42c418554", + ), + ( + "raf-fujifilm-x-pro2.raf", + "https://raw.pixls.us/getfile.php/1680/nice/Fujifilm%20-%20X-Pro2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8cc12566e5c0fc676b713bb1612ce0510d47b1dc691e9a5a0bcf3799ad9b8af1", + ), + ( + "nef-nikon-1-s1.nef", + "https://raw.pixls.us/getfile.php/5661/nice/Nikon%20-%201%20S1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "8e540d40b842f61de69161cb0b8be72b024629c752fd82b6f8d10e460bce88cb", + ), + ( + "raf-fujifilm-x100vi.raf", + "https://raw.pixls.us/getfile.php/7300/nice/Fujifilm%20-%20X100VI%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8e5e4d8d2e0a865ca79b3d40734b6abc2f4b00281afea8b9bd3d8c29808f2fb3", + ), + ( + "raf-fujifilm-x-t200.raf", + "https://raw.pixls.us/getfile.php/3899/nice/Fujifilm%20-%20X-T200%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8fc8dfa5b4741246287c430010d9699fe6f1e512ac03e14abea72e63cb022051", + ), + ( + "nef-nikon-d3200.nef", + "https://raw.pixls.us/getfile.php/743/nice/Nikon%20-%20D3200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "91657dd8e9c7a086826683f6d20799685657dd7ca2056702b145851465f57cd3", + ), + ( + "raf-fujifilm-x-t3.raf", + "https://raw.pixls.us/getfile.php/2783/nice/Fujifilm%20-%20X-T3%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "95b33021160b239ceb1a09d46a1b29cb60cb7dd47581feb126b337c219091754", + ), + ( + "raf-fujifilm-finepix-f550exr.raf", + "https://raw.pixls.us/getfile.php/3869/nice/Fujifilm%20-%20FinePix%20F550EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "95b6c8a9d5d885f60bbc965ddfd899d325f2225c99ab087d02739543a3ff7dd6", + ), + ( + "nef-nikon-d5500.nef", + "https://raw.pixls.us/getfile.php/1469/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "96d5b85dae24e190691c504781d8e7ee58cb9b63ea715760de9af3d345dc4c13", + ), + ( + "raf-fujifilm-x-a7.raf", + "https://raw.pixls.us/getfile.php/3879/nice/Fujifilm%20-%20X-A7%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "97db251dd129663da00d33e8eb4d7932d222f58d99ff7311c09cc277de5b6f18", + ), + ( + "nef-nikon-d3300.nef", + "https://raw.pixls.us/getfile.php/1734/nice/Nikon%20-%20D3300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "97dfc95aecc00383d6da6197cb759bacb1e43c86071b4ca2d94748e498620f2f", + ), + ( + "nef-nikon-z-5.nef", + "https://raw.pixls.us/getfile.php/4136/nice/Nikon%20-%20Z%205%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "9870248c45532080c2459f3b52cdcc4a07bc8846ad50cbb81c7c1c6dc5e36d91", + ), + ( + "nef-nikon-1-j3.nef", + "https://raw.pixls.us/getfile.php/1814/nice/Nikon%20-%201%20J3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "996d65705bf1ac9f83f055817ea92618ea44c822109196d7d97a803a0268b576", + ), + ( + "nef-nikon-z-9.nef", + "https://raw.pixls.us/getfile.php/5146/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "99d0fe24a38f0f7623aeab21440d266a5ac09f9665cf5283f590337e84323c38", + ), + ( + "nrw-nikon-coolpix-a1000.nrw", + "https://raw.pixls.us/getfile.php/4337/nice/Nikon%20-%20Coolpix%20A1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "9b4edd03bbb17d66eacc2a72b30f044969e67666446f3f1ed25e88bb947d4f8a", + ), + ( + "raf-fujifilm-x100s.raf", + "https://raw.pixls.us/getfile.php/2115/nice/Fujifilm%20-%20X100S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "9cea5b0d4dd3682f8850dc34e93f0045e0e28fa69f9715576d40c5a8a59e8ae9", + ), + ( + "nef-nikon-d2hs.nef", + "https://raw.pixls.us/getfile.php/2852/nice/Nikon%20-%20D2Hs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "9ea1c3d5d6c8a11f33239079559f1f0c86bbdb9440a2c7d2578368647abf7ea4", + ), + ( + "nef-nikon-d90.nef", + "https://raw.pixls.us/getfile.php/1325/nice/Nikon%20-%20D90%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "9eb3daa4e3d1c1614bccc4c6d136c4166960f936f17c437cc4d11a5786b2df06", + ), + ( + "nef-nikon-d50.nef", + "https://raw.pixls.us/getfile.php/1282/nice/Nikon%20-%20D50%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "9f94c27672a63f946c92d43910ef0c266dffaa14dae0007ae00ac72d2c684935", + ), + ( + "nef-nikon-d3000.nef", + "https://raw.pixls.us/getfile.php/2161/nice/Nikon%20-%20D3000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "a07ee8a0b1ff7a1ad06085306ef8f9a197d95fec639994467317c56108eeb435", + ), + ( + "raf-fujifilm-x100t.raf", + "https://raw.pixls.us/getfile.php/2137/nice/Fujifilm%20-%20X100T%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "a12bb7fa3585b636b7fa487d767ba0ba1ae10fe6aaa74db4147dc0ec2b180a0a", + ), + ( + "nef-nikon-z50-2.nef", + "https://raw.pixls.us/getfile.php/7762/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", + "a3314a026524396e603b461afc4fe61a8a5a3958232f3687bb1c8eca0dcbed93", + ), + ( + "nef-nikon-d600.nef", + "https://raw.pixls.us/getfile.php/916/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "a70c3e54501efa7e4391ec4cf5705912a10c356a029e984ee13be5f68bc7482d", + ), + ( + "raf-fujifilm-xf1.raf", + "https://raw.pixls.us/getfile.php/3209/nice/Fujifilm%20-%20XF1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "b17c389a59228881af8d50c22bdd8ce2295a54290d9a18447dd1e56d0743e913", + ), + ( + "nef-nikon-d7100.nef", + "https://raw.pixls.us/getfile.php/1855/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "b241cb95b1008f25b8ed7d1443354e8cc7dee9c4d4e1edd0a5856d92063a48d0", + ), + ( + "raf-fujifilm-x-e3.raf", + "https://raw.pixls.us/getfile.php/1864/nice/Fujifilm%20-%20X-E3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "b2aeb7fd72a9ea116b1dcc2c8a8cf5462b15757ba3aca33bad8ce36339a1642f", + ), + ( + "raf-fujifilm-x-a1.raf", + "https://raw.pixls.us/getfile.php/1214/nice/Fujifilm%20-%20X-A1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "b2ddb5fe31a3b976d12961c72a2689de8742739df3d8f757614eca65f8df2a61", + ), + ( + "nef-nikon-d3500.nef", + "https://raw.pixls.us/getfile.php/2902/nice/Nikon%20-%20D3500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "b53395f74674588a63f6b667325c5a2b400f26cba72ff402a65ddd02c5f841d5", + ), + ( + "raf-fujifilm-finepix-hs50exr.raf", + "https://raw.pixls.us/getfile.php/6018/nice/Fujifilm%20-%20FinePix%20HS50EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "b5c4f3658dc686e535eeaf639729f2cb6b54e60a3e1ab206eabba1024c4dce83", + ), + ( + "raf-fujifilm-gfx100-ii.raf", + "https://raw.pixls.us/getfile.php/7174/nice/Fujifilm%20-%20GFX100%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", + "b7b329bcbfcc07c5523a2b172a5173f4de4b841c8315078a1b69150ddaa16f30", + ), + ( + "raf-fujifilm-x100f.raf", + "https://raw.pixls.us/getfile.php/1936/nice/Fujifilm%20-%20X100F%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "bca8ad027b0ee0038107c01792ccb5c6b776512b77b584c6f93c6f2ced5f15a3", + ), + ( + "nef-nikon-d40x.nef", + "https://raw.pixls.us/getfile.php/804/nice/Nikon%20-%20D40X%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "c3e4956f7e2d1f1b3f846b6ec9552678073db0bcaeb51132ec4c60bd2229d56e", + ), + ( + "nef-nikon-coolpix-a.nef", + "https://raw.pixls.us/getfile.php/2238/nice/Nikon%20-%20Coolpix%20A%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "c5ca0333a7cda8a2fc33fb2157d18dfa89f1844548dc49c2fd2e67f502f72e25", + ), + ( + "raf-fujifilm-x20.raf", + "https://raw.pixls.us/getfile.php/1549/nice/Fujifilm%20-%20X20%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "ccb4fad836f1561b52859c3ee01db0fef7db770980d4a84e95595da1eda72b2a", + ), + ( + "raf-fujifilm-x-e4.raf", + "https://raw.pixls.us/getfile.php/4446/nice/Fujifilm%20-%20X-E4%20-%2014bit%2014bit%20compressed%20lossless%20%283:2%29.RAF", + "ce4215079e4c6c8a86d493e15f6e574e4aa7684b346ef3439026c835ed2c8948", + ), + ( + "raf-fujifilm-gfx50s-ii.raf", + "https://raw.pixls.us/getfile.php/5002/nice/Fujifilm%20-%20GFX50S%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", + "cf53a9018d5a77cd854ea0a91afee2a35ac8fab8f814020d79f728f99ba99892", + ), + ( + "nef-nikon-d1h.nef", + "https://raw.pixls.us/getfile.php/5364/nice/Nikon%20-%20D1H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "d110b80a8b10a3a67b5cd1c15ef33f1a6397557b73d5d3f4e565739f0c072571", + ), + ( + "nef-nikon-d200.nef", + "https://raw.pixls.us/getfile.php/824/nice/Nikon%20-%20D200%20-%2012bit%2012bit%20uncompressed%20%283:2%29.nef", + "d468895b0b30a60d05a7f6468d69f2f15830dd49b2d82698b34245072a22e747", + ), + ( + "raf-fujifilm-finepix-hs33exr.raf", + "https://raw.pixls.us/getfile.php/2907/nice/Fujifilm%20-%20FinePix%20HS33EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "d72d5b67d67379fa310fc5a4615016d9456d086a8bfd32c74a3b256e0dfecee8", + ), + ( + "raf-fujifilm-finepix-s1.raf", + "https://raw.pixls.us/getfile.php/1651/nice/Fujifilm%20-%20FinePix%20S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "d77f104a9fa4ce11c78b5078ac40acf8b150d67ea837d187e0903d208bf2dbae", + ), + ( + "nef-nikon-z-7-2.nef", + "https://raw.pixls.us/getfile.php/4213/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "dc83d3e49db54ca9c3a1e41dc847afd213c822d482c40158e42d01ee8968f33b", + ), + ( + "nef-nikon-d70.nef", + "https://raw.pixls.us/getfile.php/2060/nice/Nikon%20-%20D70%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "dd6405aeb33b0cd5bf66c98ba98ccbb478a765450cfd130810e470dab8d1f4b4", + ), + ( + "nef-nikon-d300s.nef", + "https://raw.pixls.us/getfile.php/3049/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "de950a2263c87f085ee611a21e69fc4e0ecb6e417fff61ff6a23ea3121e21a81", + ), + ( + "raf-fujifilm-x-s1.raf", + "https://raw.pixls.us/getfile.php/997/nice/Fujifilm%20-%20X-S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "e4652f3710cc972712a25370988ad8d72c8bf368956215348ea77b17d95f36f7", + ), + ( + "nef-nikon-d750.nef", + "https://raw.pixls.us/getfile.php/895/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "e4a3f85e014275fc0a47600a373af95ec850872a5f6cf87649bcaa42b77ef2e4", + ), + ( + "nef-nikon-d3100.nef", + "https://raw.pixls.us/getfile.php/4229/nice/Nikon%20-%20D3100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "e8113b6e7285155f9f2b88f81566dab63a59cf5559d6915113b74e8c3a8680f6", + ), + ( + "raf-fujifilm-x-t1.raf", + "https://raw.pixls.us/getfile.php/2421/nice/Fujifilm%20-%20X-T1%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "e994a1fd6e87e392432fe146a35b0b88584dc2bd50bee2c8c7e886ac2b59fcde", + ), + ( + "nef-nikon-d3400.nef", + "https://raw.pixls.us/getfile.php/1561/nice/Nikon%20-%20D3400%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "eced190786231f0081261b6c6b425fe23e42a53ade21dec3def5ae8551be71a5", + ), + ( + "raf-fujifilm-x-m1.raf", + "https://raw.pixls.us/getfile.php/842/nice/Fujifilm%20-%20X-M1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "eef4e9ae629d2d81cef5a234fb853c14c88b3c0c12c39d635a1f9fa2a871bec3", + ), + ( + "raf-fujifilm-x-t30.raf", + "https://raw.pixls.us/getfile.php/3068/nice/Fujifilm%20-%20X-T30%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "f0353544bc62a6816385de0850e1d37e2f3383892a7eb3928c73e4517fe47ff5", + ), + ( + "nrw-nikon-coolpix-p950.nrw", + "https://raw.pixls.us/getfile.php/3806/nice/Nikon%20-%20COOLPIX%20P950%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "fc6136b9e5a7e133d7dc30fe65baa04c09ff8c18ca3c7ad82b4ffdbb71ce5bef", + ), + ( + "raf-fujifilm-finepix-f770exr.raf", + "https://raw.pixls.us/getfile.php/3039/nice/Fujifilm%20-%20FinePix%20F770EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "ffc905e325a00c57a5335681654fdf226b724269cd5d26cd976983939f88043d", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From 6178021107f2b743baf38fa54c7aa75b14dcd1e9 Mon Sep 17 00:00:00 2001 From: robmdunn <1900604+robmdunn@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:25:59 -0400 Subject: [PATCH 020/315] LR-IMP-CAMERA-COVERAGE: measured image area for Nikon bodies without CropArea Nikon bodies from before ~2016 record no CropArea, so the decoder kept their whole sensor data, less trailing dark columns. Where that data holds masked lines above black or optical black, they were developed as a coloured or dark bar on that edge: the D7000 and D5100's 36 columns at ~600 (#976), the D800's 14 (#914, the top in portrait), and others. MEASURED_BODIES (crates/raw/src/vendor/nef.rs) now gives 36 model / data size / depth groups the frame of their camera JPEG inside the data: D7000, D5100, D800, D800E, Df, the D4's 12-bit mode, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80 and D2Xs. An entry matches model, data size and depth exactly; the measured white level becomes optional (only the D4's 14-bit mode has one). Bodies whose data holds only picture beyond the camera frame get no entry and render as before. `cargo xtask raw-frame FILE...` (crates/engine/examples/raw_frame.rs) measures a body: it aligns each raw's embedded camera JPEG with the sensor data (half-size luminance, gradient correlation, sub-pixel peak; weak, ambiguous or between-pixel peaks are flagged), counts the edge lines that hold no picture and reports what the decoder's crop keeps beyond the camera frame. It prints entries only with evidence: at least 3 clean files agreeing exactly, of the model (12- and 14-bit pooled) or, with at least one of its own, with sibling models of the same readout (data and JPEG size) that agree on the same frame. On the D7500, which records its CropArea, it finds that area exactly; it declines the Z f, whose JPEG is distortion-corrected. It works for any make whose raw embeds a full-size camera JPEG: on Sony, Canon CR2 / CR3, Pentax and Samsung files it finds the decoder's own crop (relative to the active area). docs/nikon-bodies.md surveys every Nikon raw on raw.pixls.us (98 models, 291 files; 138 CC0 samples measured): 22 models record CropArea; of the 88 groups without it, 36 have entries, 13 are fine, 25 could not be aligned and 7 do not decode. The D3200, D3100, D200, D50, D750 and Nikon 1 V1 / V2 are pending, each listed with the files it needs; the doc also describes how to add a body. raw-parity.md no longer claims every NEF coding but HE decodes. measured_bodies_are_consistent checks every entry lies inside its data and is listed once. The evidence is committed in docs/nikon-bodies/: headers.json (every file's raw size, depth and maker-note crop tags), samples.json (the 138 measured CC0 files with URL and sha256) and frames.jsonl (the tool's per-file results). `cargo xtask corpus --download nikon` fetches the samples (sha256 checked) so `cargo xtask raw-frame` reproduces every count. Fixes #976 Fixes #914 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/examples/raw_frame.rs | 497 ++++++++++++++++++++++++++++ crates/raw/src/vendor/nef.rs | 137 +++++++- docs/gaps.md | 6 +- docs/nikon-bodies.md | 247 ++++++++++++++ docs/nikon-bodies/frames.jsonl | 138 ++++++++ docs/nikon-bodies/headers.json | 296 +++++++++++++++++ docs/nikon-bodies/samples.json | 144 ++++++++ docs/parity-checklist.md | 2 +- docs/raw-parity.md | 7 +- docs/whats-new.md | 6 + xtask/src/main.rs | 64 +++- 11 files changed, 1522 insertions(+), 22 deletions(-) create mode 100644 crates/engine/examples/raw_frame.rs create mode 100644 docs/nikon-bodies.md create mode 100644 docs/nikon-bodies/frames.jsonl create mode 100644 docs/nikon-bodies/headers.json create mode 100644 docs/nikon-bodies/samples.json diff --git a/crates/engine/examples/raw_frame.rs b/crates/engine/examples/raw_frame.rs new file mode 100644 index 000000000..a1d5a86f1 --- /dev/null +++ b/crates/engine/examples/raw_frame.rs @@ -0,0 +1,497 @@ +//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no +//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame -- +//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries +//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`; survey in `docs/nikon-bodies.md`) for bodies that record +//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 / +//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most +//! Fujifilm, Panasonic and Olympus files) get the edge bands only. +//! +//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size +//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is +//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak +//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths +//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the +//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several +//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG +//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off); +//! a smaller preview is not aligned. +//! +//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark* +//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples +//! alike within 4 standard errors, spread no more than the line's own neighbour noise and under 0.4% of white). A +//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a +//! survey aid: a dark band can also be deep shadow, so read it with the frame. +use std::collections::BTreeMap; + +use lightcraft_codecs::DecodeOptions; +use lightcraft_raw::RawData; + +/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3 +/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower). +const MIN_SCORE: f64 = 0.5; +const MIN_MARGIN: f64 = 0.02; +/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment. +const MAX_FRACTION: f64 = 0.35; +/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep +/// up to ~280 px of masked border). +const MAX_SEARCH: usize = 192; +/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine. +const FULL_SEARCH: usize = 2048; +/// Edge bands are counted up to this many lines from each edge. +const MAX_BAND: usize = 128; +/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area), +/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own +/// files agree on the same frame. +const MIN_FILES: usize = 3; + +struct Frame { + jpeg: (usize, usize), + /// Sub-pixel offset of the JPEG's top left corner in sensor pixels. + at: (f64, f64), + score: f64, + margin: f64, +} + +impl Frame { + fn whole(&self) -> (usize, usize) { + (self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize) + } + fn clean(&self) -> bool { + let frac = |v: f64| (v - v.round()).abs(); + self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION + } +} + +/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge. +#[derive(Clone, Copy, Default)] +struct Band { + dark: usize, + masked: usize, +} + +impl Band { + fn total(self) -> usize { + self.dark + self.masked + } +} + +struct Measured { + model: String, + size: (usize, usize), + bits: u32, + black: f64, + white: f64, + /// The crop the decoder applies, in sensor-data coordinates: x, y, width, height. + crop: (usize, usize, usize, usize), + /// Left, right, top, bottom. + bands: [Band; 4], + frame: Result<Frame, String>, + /// With a clean frame: what the crop holds beyond it. + beyond: Option<[Beyond; 4]>, +} + +impl Measured { + /// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture. + fn in_crop(&self) -> [usize; 4] { + let (w, h) = self.size; + let (x, y, cw, ch) = self.crop; + let [l, r, t, b] = self.bands.map(Band::total); + [l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))] + } +} + +/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are +/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the +/// camera, harmless). +#[derive(Clone, Copy, Default)] +struct Beyond { + masked: usize, + dark: usize, + picture: usize, +} + +fn main() { + let args: Vec<String> = std::env::args().skip(1).collect(); + let json = args.iter().any(|a| a == "--json"); + let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect(); + if files.is_empty() { + eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)"); + std::process::exit(2); + } + // model, data size, JPEG size -> (bit depth, frame) of each file + let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new(); + for f in files { + let m = match measure(f) { + Ok(m) => m, + Err(e) => { + if json { + println!("{}", serde_json::json!({ "file": f, "error": e })); + } else { + println!("{f}: skipped: {e}"); + } + continue; + } + }; + let in_crop = m.in_crop(); + if json { + let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked }); + let frame = match &m.frame { + Ok(fr) => serde_json::json!({ + "jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean() + }), + Err(e) => serde_json::json!({ "error": e }), + }; + println!( + "{}", + serde_json::json!({ + "file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white, + "crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3], + "bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) }, + "in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] }, + "beyond_frame": m.beyond.map(|b| { + let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture }); + serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) }) + }), + "frame": frame, + }) + ); + } else { + let frame = match &m.frame { + Ok(fr) => format!( + "JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}", + fr.jpeg.0, + fr.jpeg.1, + fr.at.0, + fr.at.1, + fr.score, + fr.margin, + if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" } + ), + Err(e) => format!("no frame: {e}"), + }; + println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits); + let side = |name: &str, b: Band| match (b.dark, b.masked) { + (0, 0) => None, + (d, 0) => Some(format!("{name} {d} dark")), + (0, k) => Some(format!("{name} {k} masked")), + (d, k) => Some(format!("{name} {d} dark + {k} masked")), + }; + let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect(); + let shown: Vec<String> = + ["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect(); + println!( + " edge bands: {}; decoder crop ({}, {}, {}, {}){}", + if bands.is_empty() { "none".to_string() } else { bands.join(", ") }, + m.crop.0, + m.crop.1, + m.crop.2, + m.crop.3, + match m.beyond { + Some(b) => { + let parts: Vec<String> = ["left", "right", "top", "bottom"] + .iter() + .zip(b) + .filter(|(_, b)| b.masked + b.dark + b.picture > 0) + .map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture)) + .collect(); + if parts.is_empty() { + "; nothing beyond the camera frame".to_string() + } else { + format!("; beyond the camera frame: {}", parts.join(", ")) + } + } + None if shown.is_empty() => String::new(), + None => format!(" still includes edge bands: {} lines", shown.join(", ")), + } + ); + } + if let Ok(fr) = m.frame { + groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr)); + } + } + if json { + return; + } + println!(); + // each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree + let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups + .iter() + .filter_map(|(key, frames)| { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + let first = *offsets.first()?; + offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len()))) + }) + .collect(); + for (key, frames) in &groups { + let (model, size, jpeg) = key; + let clean = frames.iter().filter(|(_, f)| f.clean()).count(); + let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect(); + depths.sort_unstable(); + depths.dedup(); + let label = format!("{model} {}×{}", size.0, size.1); + let Some(&((x, y), _)) = agreed.get(key) else { + if clean == 0 { + println!("{label}: no clean alignment in {} files; no entry", frames.len()); + } else { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + println!("{label}: files disagree: {offsets:?}; no entry"); + } + continue; + }; + let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() }; + println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len()); + // siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on + // the same frame + let siblings: Vec<(&str, usize)> = agreed + .iter() + .filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y)) + .map(|((m, _, _), (_, n))| (m.as_str(), *n)) + .collect(); + let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>(); + if clean < MIN_FILES { + if pooled < MIN_FILES { + println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled); + continue; + } + let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect(); + println!(" with siblings of the same readout and frame: {}", names.join(", ")); + } + for bits in depths { + println!( + " MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},", + size.0, size.1, jpeg.0, jpeg.1 + ); + } + } +} + +fn measure(path: &str) -> Result<Measured, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?; + let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) }; + if raw.cpp != 1 || raw.cfa.is_none() { + return Err("not colour-filter sensor data".into()); + } + let (w, h) = (raw.width, raw.height); + if w < 4 || h < 4 || d.len() < w.saturating_mul(h) { + return Err("sensor data shorter than its size".into()); + } + let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0); + let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0); + let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0)); + let bands = [ + band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white), + band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white), + band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white), + band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white), + ]; + // the decoder's crop is relative to the active area: in sensor-data coordinates here + let a = raw.active_area; + let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height); + let frame = frame(&bytes, d, w, h); + let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| { + let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg); + let kinds = |lines: Vec<Line>| { + lines.iter().fold(Beyond::default(), |mut b, l| { + match kind(l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => b.picture += 1, + } + b + }) + }; + let col = |x: usize| line(h, |i| at(x, i)); + let row = |y: usize| line(w, |i| at(i, y)); + [ + kinds((c.x..fx.min(c.x + c.width)).map(col).collect()), + kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()), + kinds((c.y..fy.min(c.y + c.height)).map(row).collect()), + kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()), + ] + }); + Ok(Measured { + model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(), + size: (w, h), + bits: raw.bits, + black, + white, + crop: (c.x, c.y, c.width, c.height), + bands, + frame, + beyond, + }) +} + +/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart) +/// and the number of sample pairs. +struct Line { + mean: f64, + pattern: f64, + spread: f64, + noise: f64, + pairs: f64, +} + +fn line(n: usize, at: impl Fn(usize) -> f64) -> Line { + let stride = ((n / 512).max(1)).div_ceil(2) * 2; + let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64); + let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect(); + for &i in &idx { + even += at(i); + odd += at(i + 1); + k += 1.0; + } + let (even, odd) = (even / k.max(1.0), odd / k.max(1.0)); + for &i in &idx { + var += (at(i) - even).powi(2); + noise += (at(i + 2) - at(i)).powi(2) / 2.0; + } + Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k } +} + +enum Kind { + Dark, + Masked, + Picture, +} + +fn kind(l: &Line, black: f64, white: f64) -> Kind { + let range = white - black; + let slack = white / 4096.0; + if l.mean < black + range * 0.01 { + Kind::Dark + } else if l.mean > black + range * 0.02 + && l.mean < white * 0.25 + && l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack + && l.spread < 1.5 * l.noise + slack + && l.spread < white * 0.004 + { + Kind::Masked + } else { + Kind::Picture + } +} + +fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band { + let mut b = Band::default(); + for l in lines { + match kind(&l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => break, + } + } + b +} + +fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> { + let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?; + let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?; + let (jw, jh) = (jpeg.image.width, jpeg.image.height); + if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 { + return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data")); + } + // half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike + let (rw, rh) = (w / 2, h / 2); + let raw_y: Vec<f64> = (0..rh * rw) + .map(|i| { + let (x, y) = (2 * (i % rw), 2 * (i / rw)); + let s: f64 = + [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum(); + s.sqrt() + }) + .collect(); + let (pw, ph) = (jw / 2, jh / 2); + let px = &jpeg.image.data; + let jpg_y: Vec<f64> = (0..ph * pw) + .map(|i| { + let (x, y) = (2 * (i % pw), 2 * (i / pw)); + let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)] + .iter() + .map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0))) + .sum(); + s.sqrt() + }) + .collect(); + let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH)); + let grad = |img: &[f64], stride: usize, x: usize, y: usize| { + let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0); + (at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y)) + }; + let normalise = |v: Vec<f64>| { + let n = v.len().max(1) as f64; + let mean = v.iter().sum::<f64>() / n; + let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12); + v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>() + }; + // gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset + let sample = |step: usize| { + let points: Vec<(usize, usize)> = + (0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect(); + let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect()); + (points, gj) + }; + let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| { + let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect()); + gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64 + }; + let fine = sample(2); + // every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset + // around the best of those + let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1]; + let mut coarse_best = f64::NEG_INFINITY; + let (mut cx, mut cy) = (0, 0); + let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH { + (0..=sx, 0..=sy) + } else { + let coarse = sample(8); + for dy in (0..=sy).step_by(4) { + for dx in (0..=sx).step_by(4) { + let v = score(&coarse, dx, dy); + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = v; + } + if v > coarse_best { + (coarse_best, cx, cy) = (v, dx, dy); + } + } + } + (cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy)) + }; + for dy in window.1.clone() { + for dx in window.0.clone() { + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = score(&fine, dx, dy); + } + } + } + let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY); + let (mut bx, mut by) = (0, 0); + for dy in 0..=sy { + for dx in 0..=sx { + if at(dx, dy) > at(bx, by) { + (bx, by) = (dx, dy); + } + } + } + let best = at(bx, by); + let runner_up = (0..=sy) + .flat_map(|dy| (0..=sx).map(move |dx| (dx, dy))) + .filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1) + .map(|(dx, dy)| at(dx, dy)) + .fold(f64::NEG_INFINITY, f64::max); + // parabola through the peak and its neighbours (none at the window's edge) + let refine = |a: f64, b: f64, c: f64| { + let k = a - 2.0 * b + c; + if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 } + }; + let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 }; + let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 }; + Ok(Frame { + jpeg: (jw, jh), + at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)), + score: best, + margin: if runner_up.is_finite() { best - runner_up } else { best }, + }) +} diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index cacb516af..97e526df9 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -58,7 +58,8 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { } /// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the -/// camera's full-size JPEG sits when the two are aligned) and the level where its channels clip. +/// camera's full-size JPEG sits when the two are aligned) and, when the data's own top is wrong, the level where its +/// channels clip. struct MeasuredBody { model: &'static str, /// Sensor data size and sample depth of the measured files. @@ -66,7 +67,7 @@ struct MeasuredBody { bits: u32, /// Image area in sensor pixels: x, y, width, height. crop: (usize, usize, usize, usize), - white: f32, + white: Option<f32>, } /// * **NIKON D4** (148 uncompressed 14-bit NEFs from one body, 2023–2026, ISO 100–10000): the data is 4992 × 3292, @@ -74,8 +75,66 @@ struct MeasuredBody { /// 4928 × 3280 JPEG sits at (8, 6). Without this area the optical black shows as purple stripes on both sides. /// Green saturates at 15546–15672 while red and blue run on to 15687–16383, so the data's own top (~16300) leaves /// clipped highlights magenta; one level of 15520 for every channel turns them white and keeps the white balance. -const MEASURED_BODIES: &[MeasuredBody] = - &[MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: 15520.0 }]; +/// * **NIKON D7000** and **NIKON D5100** (and the Coolpix A, the same readout): the data is 4992 × 3280, the picture its +/// first 4948 columns, then 36 masked ones at one level (~600, with a black level of 0) and 8 of 0. The masked +/// columns sit above the dark-column trim's threshold, so without this area they show as a coloured bar on the right +/// edge (the top in portrait; issue #976). The camera's 4928 × 3264 JPEG sits at (8, 8): in the D5100's 3 CC0 +/// samples, and in the D7000's one clean one, with the D5100 and Coolpix A as siblings (see below). +/// * **The other bodies** below keep masked or dark lines inside the whole data (the survey in `docs/nikon-bodies.md`): +/// each crop is its camera JPEG's frame as `cargo xtask raw-frame` measures it on raw.pixls.us CC0 samples, with at +/// least 3 clean files agreeing: of the model (bit depths pooled: both read out the same area), or, with at least +/// one of its own, together with models of the same readout (data and JPEG size) that agree on the same frame +/// (*siblings*). The D4's 12-bit mode reads out the 14-bit area measured above; the Df, the same readout, agrees on +/// (8, 6) in 6 files. Bodies whose crop holds only picture beyond the camera frame have no entry (no change). +const MEASURED_BODIES: &[MeasuredBody] = &[ + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) }, + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 12, crop: (8, 8, 4928, 3264), white: None }, + MeasuredBody { model: "NIKON D5100", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + // siblings: D7000 (4), D5100 (3); masked: right 36 + MeasuredBody { model: "COOLPIX A", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + // 6 files each; masked: left 2, right 2 + MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + // 6 and 4 files; masked: right up to 32 + MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None }, + // D2X (1) and D2Xs (2) as siblings; dark: right 1 + MeasuredBody { model: "NIKON D2Xs", size: (4320, 2868), bits: 12, crop: (16, 10, 4288, 2848), white: None }, + // D3000, D40X, D60, D80: one file each, siblings; masked: right 4, bottom 3 + MeasuredBody { model: "NIKON D3000", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D40X", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D60", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D80", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + // 3 files; dark: bottom 4 + MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 12, crop: (4, 4, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 14, crop: (4, 4, 4928, 3280), white: None }, + // 4 files; dark: bottom up to 4 + MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 12, crop: (8, 8, 6000, 4000), white: None }, + MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 14, crop: (8, 8, 6000, 4000), white: None }, + // 4 files each; masked: right 8 + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + // D800: 6 files; D800E: 2, with the D800 as sibling; masked: right 14 (issue #914, the top in portrait) + MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 12, crop: (8, 6, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D800E", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None }, + // 3 files; dark: bottom 1 + MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 12, crop: (12, 8, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 14, crop: (12, 8, 7360, 4912), white: None }, + // 6 files; masked: left 2, right 42 (the D4's readout) + MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: None }, +]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) @@ -165,9 +224,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { // the white level is measured from the whole image (or known for a measured body); the probe (no samples in its // result) takes the nominal one let white = match (body, mode) { - (Some(b), _) => b.white, - (None, Mode::Full) => white_from_data(samples, bits), - (None, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, + (Some(&MeasuredBody { white: Some(white), .. }), _) => white, + (_, Mode::Full) => white_from_data(samples, bits), + (_, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, }; let crop = match body { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), @@ -275,6 +334,22 @@ mod tests { /// [`nef_with_note`] in either byte order (file and maker note). #[allow(clippy::too_many_arguments)] fn nef_in(order: ByteOrder, compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> { + nef_model_in(order, "NIKON TEST", compression, bits, strips, w, h, rps, note) + } + + /// [`nef_in`] from camera `model`. + #[allow(clippy::too_many_arguments)] + fn nef_model_in( + order: ByteOrder, + model: &str, + compression: u16, + bits: u16, + strips: Vec<Vec<u8>>, + w: u32, + h: u32, + rps: u32, + note: Option<IfdBuilder>, + ) -> Vec<u8> { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -287,7 +362,7 @@ mod tests { raw.set_image(ImageData::Strips { rows_per_strip: rps, strips }); let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into())); - ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); if let Some(mn) = note { let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec(); bytes.extend(TiffWriter::new(order, false).write(&[mn]).unwrap()); @@ -392,9 +467,49 @@ mod tests { assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280))); let developed = d4.develop(crate::Method::Bilinear).unwrap(); assert_eq!((developed.width, developed.height), (4928, 3280)); - for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] { - assert_eq!(other.crop, Rect::new(0, 0, w, h)); - assert_ne!(other.white, vec![15520.0]); + // the 12-bit mode reads out the same area but keeps the white level from its data + let d4_12 = decode_as("NIKON D4", 12); + assert_eq!(d4_12.crop, Rect::new(8, 6, 4928, 3280)); + assert_ne!(d4_12.white, vec![15520.0]); + let other = decode_as("NIKON D5", 14); + assert_eq!(other.crop, Rect::new(0, 0, w, h)); + assert_ne!(other.white, vec![15520.0]); + } + + /// Issue #976: the D7000 and D5100 record no CropArea, and their 36 masked columns at ~600 were developed as a + /// coloured bar. Their measured area applies to the measured models, data sizes and depths only. + #[test] + fn measured_d7000_and_d5100_image_area() { + let (w, h) = (4992usize, 3280usize); + let decode_as = |model: &str, bits: u16| { + let nef = nef_model_in(ByteOrder::Big, model, 1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None); + crate::decode(&nef).unwrap() + }; + for (model, bits) in [("NIKON D7000", 14), ("NIKON D7000", 12), ("NIKON D5100", 14)] { + let raw = decode_as(model, bits); + assert_eq!(raw.crop, Rect::new(8, 8, 4928, 3264), "{model} {bits}-bit"); + assert_eq!((raw.metadata.width, raw.metadata.height), (Some(4928), Some(3264))); + let developed = raw.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (4928, 3264)); + } + // another model, or the D5100's unmeasured 12-bit mode, keeps the whole data + for (model, bits) in [("NIKON D7100", 14), ("NIKON D5100", 12), ("NIKON D7000 ", 16)] { + assert_eq!(decode_as(model, bits).crop, Rect::new(0, 0, w, h), "{model} {bits}-bit"); + } + } + + /// Every measured entry is a crop inside its data, once per model, data size and depth, with a white level (if + /// any) inside its bit range. + #[test] + fn measured_bodies_are_consistent() { + let mut seen = std::collections::HashSet::new(); + for b in MEASURED_BODIES { + let (x, y, w, h) = b.crop; + assert!(w > 0 && h > 0 && x + w <= b.size.0 && y + h <= b.size.1, "{}: crop outside its data", b.model); + assert!(seen.insert((b.model, b.size, b.bits)), "{} {:?} {}-bit listed twice", b.model, b.size, b.bits); + assert!(matches!(b.bits, 12 | 14), "{}: {}-bit", b.model, b.bits); + assert!(b.white.is_none_or(|v| v > 0.0 && v < (1u32 << b.bits) as f32), "{}: white level", b.model); + assert_eq!(b.model.trim(), b.model, "{}: model names are matched trimmed", b.model); } } diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..edfc3f895 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (Nikon edge bars on bodies without CropArea fixed, #976 / #914; gap 10 notes the survey) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users; Nikon crops of bodies without CropArea now surveyed and measured ([nikon-bodies.md](nikon-bodies.md), D3200 / D3100 / D200 / D50 / Nikon 1 V1, V2 pending samples) | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -59,6 +59,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-11: Nikon edge bars on bodies without CropArea (#976, #914): 36 model / size / depth groups measured. - 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345), JPEG XL export (#247). - 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), @@ -72,6 +73,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Nikon edge bars on bodies without CropArea fixed for 36 model / size / depth groups (#976, #914; [nikon-bodies.md](nikon-bodies.md)); gap 10 notes the survey and the pending bodies; gap not closed | | 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/nikon-bodies.md b/docs/nikon-bodies.md new file mode 100644 index 000000000..c9309bb2d --- /dev/null +++ b/docs/nikon-bodies.md @@ -0,0 +1,247 @@ +# Nikon bodies: sensor data, image area and edge bands + +> **Last updated:** 2026-10-11 · **Change:** created (every Nikon raw on raw.pixls.us; 138 CC0 samples measured with `cargo xtask raw-frame`) · **Scope:** Nikon NEF / NRW + +Nikon raws hold more sensor data than the picture: a few lines of extra picture around the camera's own frame and, +on many bodies, optical black or masked lines that are not picture at all. Newer bodies record the picture's area in +the maker note (`CropArea`, 0x0045) and LightCraft crops to it. Older ones record nothing, so the decoder keeps the +whole data (less trailing dark columns), and masked lines inside it are developed as a coloured or dark bar on that +edge ([#976](https://github.com/storytold/lightcraft/issues/976), [#914](https://github.com/storytold/lightcraft/issues/914)). +Such a body gets an entry in `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`): the frame of its camera JPEG inside +the data, applied only to that model, data size and sample depth. Bodies whose data holds only picture beyond the +camera frame get no entry and render as before. + +## Summary + +- **Record `CropArea`:** 22 models, 53 data sizes: handled. +- **Without `CropArea`:** 66 models in 88 groups (model, data size, depth), plus 7 groups without a CC0 sample: + - measured entry (cropped to the camera frame): 36 + - fine (only picture beyond the camera frame): 13 + - bar, pending more files: 3 + - dark lines, pending more files: 4 + - no clean alignment: 25 + - not decoded: 7 + +Status: ✅ **measured entry** · ✅ **fine** (a few extra pixels of picture) · ⚠️ **bar** (masked lines inside the crop: +a coloured or dark edge) · ❓ **check** (dark lines inside the crop: optical black, or shadow in that sample) · +❓ **unmeasured** (no clean alignment: a compact's JPEG is distortion-corrected, or the samples are soft or dark) · +**not decoded** (a raw variant LightCraft can't read yet). + +## Pending + +Bodies whose crop keeps lines without picture but that lack the evidence for an entry (see *Adding a body*). Each +needs more files with detail across the frame, from raw.pixls.us or contributed (any licence: only the measurement +is committed, never the file). + +| Model | Data | Bits | In the crop today | Needs | +|---|---|---:|---|---| +| NIKON 1 V1 | 3904 × 2606 | 12 | dark lines in crop (optical black or a dark sample): bottom 2 | 2 more clean file(s) | +| NIKON 1 V2 | 4620 × 3084 | 12 | dark lines in crop (optical black or a dark sample): bottom 3 | 1 more clean file(s) | +| NIKON D200 | 3904 × 2616 | 12 | masked lines in crop: left 1, right 1 | 1 more clean file(s) | +| NIKON D3100 | 4672 × 3084 | 12 | masked lines in crop: left 5, right 13 | 2 more clean file(s) | +| NIKON D3200 | 6080 × 4012 | 12 | masked lines in crop: right 45 | 2 more clean file(s) | +| NIKON D750 | 6032 × 4032 | 12 | dark lines in crop (optical black or a dark sample): left 8, right 8, top 8 | 1 more clean file(s) | +| NIKON D750 | 6032 × 4032 | 14 | dark lines in crop (optical black or a dark sample): left 4, right 8, top 8 | 1 more clean file(s) | +| NIKON D50 | 3040 × 2014 | 12 | masked lines at the edges: right 1 | files that align cleanly | + +## Bodies without `CropArea` + +One row per model, sensor data size and sample depth (each covers the compression modes listed). *Frame*: where the +camera's full-size JPEG sits in the data and how many measured files agree (bit depths pooled). *Files*: samples of +the group on raw.pixls.us. *Sample*: the first file measured, in `corpus/raw/nikon-inventory/`. + +| Model | Data | Bits | Files | Modes | Frame | Status | Notes | Sample | +|---|---|---:|---:|---|---|---|---|---| +| COOLPIX A | 4992 × 3280 | 14 | 1 | lossy 2 | (8, 8), 1 of 1 files | ✅ measured entry | crop (8, 8, 4928, 3264); siblings: NIKON D5100 (3), NIKON D7000 (1) | `coolpix-a-4992x3280-14bit.nef` | +| COOLPIX A1000 | 4608 × 3456 | 12 | 1 | uncompressed | unclear (4608×3456, score 0.21) | ❓ unmeasured | no masked edge lines found | `coolpix-a1000-4608x3456-12bit.nrw` | +| COOLPIX B700 | 5200 × 3902 | 12 | 1 | uncompressed | unclear (5184×3888, score 0.44) | ❓ unmeasured | no masked edge lines found | `coolpix-b700-5200x3902-12bit.nrw` | +| COOLPIX P1000 | 4624 × 3470 | 12 | 1 | uncompressed | unclear (4608×3456, score 0.30) | ❓ unmeasured | no masked edge lines found | `coolpix-p1000-4624x3470-12bit.nrw` | +| COOLPIX P1100 | 4624 × 3470 | 12 | 1 | uncompressed | unclear (4608×3456, score 0.20) | ❓ unmeasured | no masked edge lines found | `coolpix-p1100-4624x3470-12bit.nrw` | +| COOLPIX P330 | 4032 × 3024 | 12 | 1 | HE / other | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `coolpix-p330-4032x3024-12bit.nrw` | +| COOLPIX P7700 | 4032 × 3024 | 12 | 1 | HE / other | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `coolpix-p7700-4032x3024-12bit.nrw` | +| COOLPIX P7800 | 4032 × 3024 | 12 | 1 | HE / other | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `coolpix-p7800-4032x3024-12bit.nrw` | +| COOLPIX P950 | 4624 × 3470 | 12 | 1 | uncompressed | unclear (4608×3456, score 0.21) | ❓ unmeasured | no masked edge lines found | `coolpix-p950-4624x3470-12bit.nrw` | +| E5700 | 2576 × 1924 | 12 | 1 | uncompressed | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `e5700-2576x1924-12bit.nef` | +| E8400 | 3280 × 2454 | 12 | 1 | uncompressed | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `e8400-3280x2454-12bit.nef` | +| E8800 | 3280 × 2454 | 12 | 1 | uncompressed | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `e8800-3280x2454-12bit.nef` | +| NIKON 1 AW1 | 4620 × 3084 | 12 | 1 | lossy 2 | (12, 10), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-1-aw1-4620x3084-12bit.nef` | +| NIKON 1 J1 | 3904 × 2606 | 12 | 3 | lossy 2 | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `nikon-1-j1-3904x2606-12bit.nef` | +| NIKON 1 J2 | 3904 × 2606 | 12 | 1 | lossy 2 | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `nikon-1-j2-3904x2606-12bit.nef` | +| NIKON 1 J3 | 4620 × 3084 | 12 | 1 | lossy 2 | (6, 6), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-1-j3-4620x3084-12bit.nef` | +| NIKON 1 J4 | 5248 × 3504 | 12 | 1 | lossy 2 | unclear (5232×3488, score 0.42) | ❓ unmeasured | no masked edge lines found | `nikon-1-j4-5248x3504-12bit.nef` | +| NIKON 1 J5 | 5584 × 3726 | 12 | 2 | lossy 2 | unclear (5568×3712, score 0.30) | ❓ unmeasured | no masked edge lines found | `nikon-1-j5-5584x3726-12bit.nef` | +| NIKON 1 S1 | 3948 × 2604 | 12 | 1 | lossy 2 | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `nikon-1-s1-3948x2604-12bit.nef` | +| NIKON 1 V1 | 3904 × 2606 | 12 | 1 | lossy 2 | (12, 6), 1 of 1 files | ❓ check | dark lines in crop (optical black or a dark sample): bottom 2; pending: 2 more clean file(s) | `nikon-1-v1-3904x2606-12bit.nef` | +| NIKON 1 V2 | 4620 × 3084 | 12 | 1 | lossy 2 | (6, 6), 1 of 1 files | ❓ check | dark lines in crop (optical black or a dark sample): bottom 3; pending: 1 more clean file(s) | `nikon-1-v2-4620x3084-12bit.nef` | +| NIKON 1 V3 | 5248 × 3504 | 12 | 1 | lossy 2 | (8, 8), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-1-v3-5248x3504-12bit.nef` | +| NIKON D100 | 3034 × 2024 | 12 | 1 | uncompressed | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `nikon-d100-3034x2024-12bit.nef` | +| NIKON D1H | 2012 × 1324 | 12 | 1 | uncompressed | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `nikon-d1h-2012x1324-12bit.nef` | +| NIKON D1X | 4028 × 1324 | 12 | 2 | compressed, uncompressed | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `nikon-d1x-4028x1324-12bit.nef` | +| NIKON D200 | 3904 × 2616 | 12 | 2 | lossy 1, uncompressed | (16, 12), 2 of 2 files | ⚠️ bar | masked lines in crop: left 1, right 1; pending: 1 more clean file(s) | `nikon-d200-3904x2616-12bit.nef` | +| NIKON D2H | 2496 × 1648 | 12 | 2 | lossy 1, uncompressed | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `nikon-d2h-2496x1648-12bit.nef` | +| NIKON D2Hs | 2496 × 1648 | 12 | 1 | uncompressed | (16, 9), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-d2hs-2496x1648-12bit.nef` | +| NIKON D2X | 4320 × 2868 | 12 | 2 | lossy 1, uncompressed | (16, 10), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-d2x-4320x2868-12bit.nef` | +| NIKON D2Xs | 4320 × 2868 | 12 | 2 | lossy 1, uncompressed | (16, 10), 2 of 2 files | ✅ measured entry | crop (16, 10, 4288, 2848); siblings: NIKON D2X (1) | `nikon-d2xs-4320x2868-12bit.nef` | +| NIKON D3 | 4288 × 2844 | 12 | 3 | lossless, lossy 2, uncompressed | (15, 6), 6 of 6 files | ✅ measured entry | crop (15, 6, 4256, 2832) | `nikon-d3-4288x2844-12bit.nef` | +| NIKON D3 | 4288 × 2844 | 14 | 3 | lossless, lossy 2, uncompressed | (15, 6), 6 of 6 files | ✅ measured entry | crop (15, 6, 4256, 2832) | `nikon-d3-4288x2844-14bit.nef` | +| NIKON D300 | 4352 × 2868 | 12 | 3 | lossless, lossy 2, uncompressed | (15, 10), 6 of 6 files | ✅ measured entry | crop (15, 10, 4288, 2848) | `nikon-d300-4352x2868-12bit.nef` | +| NIKON D300 | 4352 × 2868 | 14 | 3 | lossless, lossy 2, uncompressed | (15, 10), 6 of 6 files | ✅ measured entry | crop (15, 10, 4288, 2848) | `nikon-d300-4352x2868-14bit.nef` | +| NIKON D3000 | 3904 × 2616 | 12 | 1 | lossy 1 | (14, 9), 1 of 1 files | ✅ measured entry | crop (14, 9, 3872, 2592); siblings: NIKON D40X (1), NIKON D60 (1), NIKON D80 (1) | `nikon-d3000-3904x2616-12bit.nef` | +| NIKON D300S | 4352 × 2868 | 12 | 3 | lossless, lossy 2, uncompressed | (15, 10), 4 of 4 files | ✅ measured entry | crop (15, 10, 4288, 2848) | `nikon-d300s-4352x2868-12bit.nef` | +| NIKON D300S | 4352 × 2868 | 14 | 3 | lossless, lossy 2, uncompressed | (15, 10), 4 of 4 files | ✅ measured entry | crop (15, 10, 4288, 2848) | `nikon-d300s-4352x2868-14bit.nef` | +| NIKON D3100 | 4672 × 3084 | 12 | 1 | lossy 2 | (24, 6), 1 of 1 files | ⚠️ bar | masked lines in crop: left 5, right 13; pending: 2 more clean file(s) | `nikon-d3100-4672x3084-12bit.nef` | +| NIKON D3200 | 6080 × 4012 | 12 | 1 | lossy 2 | (8, 6), 1 of 1 files | ⚠️ bar | masked lines in crop: right 45; pending: 2 more clean file(s) | `nikon-d3200-6080x4012-12bit.nef` | +| NIKON D3300 | 6016 × 4016 | 12 | 1 | lossy 2 | (8, 8), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-d3300-6016x4016-12bit.nef` | +| NIKON D3S | 4288 × 2844 | 12 | 3 | lossless, lossy 2, uncompressed | (15, 6), 6 of 6 files | ✅ measured entry | crop (15, 6, 4256, 2832) | `nikon-d3s-4288x2844-12bit.nef` | +| NIKON D3S | 4288 × 2844 | 14 | 3 | lossless, lossy 2, uncompressed | (15, 6), 6 of 6 files | ✅ measured entry | crop (15, 6, 4256, 2832) | `nikon-d3s-4288x2844-14bit.nef` | +| NIKON D3X | 6080 × 4044 | 14 | 2 | lossless, uncompressed | unclear (6048×4032, score 0.50) | ❓ unmeasured | no masked edge lines found | `nikon-d3x-6080x4044-14bit.nef` | +| NIKON D4 | 4992 × 3292 | 12 | 3 | lossless, lossy 2, uncompressed | unclear (4928×3280, score 0.61) | ✅ measured entry | crop (8, 6, 4928, 3280) | `nikon-d4-4992x3292-12bit.nef` | +| NIKON D4 | 4992 × 3292 | 14 | 3 | lossless, lossy 2, uncompressed | unclear (4928×3280, score 0.61) | ✅ measured entry | crop (8, 6, 4928, 3280) | `nikon-d4-4992x3292-14bit.nef` | +| NIKON D40 | 3040 × 2014 | 12 | 1 | lossy 1 | (16, 9), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-d40-3040x2014-12bit.nef` | +| NIKON D40X | 3904 × 2616 | 12 | 1 | lossy 1 | (14, 9), 1 of 1 files | ✅ measured entry | crop (14, 9, 3872, 2592); siblings: NIKON D3000 (1), NIKON D60 (1), NIKON D80 (1) | `nikon-d40x-3904x2616-12bit.nef` | +| NIKON D4S | 2464 × 1640 | 12 | 1 | HE / other | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `nikon-d4s-2464x1640-12bit.nef` | +| NIKON D4S | 4936 × 3288 | 12 | 3 | lossless, lossy 2, uncompressed | (4, 4), 3 of 3 files | ✅ measured entry | crop (4, 4, 4928, 3280) | `nikon-d4s-4936x3288-12bit.nef` | +| NIKON D4S | 4936 × 3288 | 14 | 3 | lossless, lossy 2, uncompressed | (4, 4), 3 of 3 files | ✅ measured entry | crop (4, 4, 4928, 3280) | `nikon-d4s-4936x3288-14bit.nef` | +| NIKON D50 | 3040 × 2014 | 12 | 1 | lossy 1 | unclear (3008×2000, score 0.74) | ❓ unmeasured | masked edge lines: right 1 | `nikon-d50-3040x2014-12bit.nef` | +| NIKON D5000 | 4352 × 2868 | 12 | 2 | lossy 2 | unclear (4288×2848, score 0.58) | ❓ unmeasured | no masked edge lines found | `nikon-d5000-4352x2868-12bit.nef` | +| NIKON D5100 | 4992 × 3280 | 14 | 3 | lossless, lossy 2, uncompressed | (8, 8), 3 of 3 files | ✅ measured entry | crop (8, 8, 4928, 3264) | `nikon-d5100-4992x3280-14bit.nef` | +| NIKON D5200 | 6036 × 4020 | 14 | 2 | lossy 2 | unclear (6000×4000, score 0.55) | ❓ unmeasured | no masked edge lines found | `nikon-d5200-6036x4020-14bit.nef` | +| NIKON D5300 | 6016 × 4016 | 12 | 1 | lossy 2 | (8, 8), 2 of 2 files | ✅ fine | only picture beyond the camera frame | `nikon-d5300-6016x4016-12bit.nef` | +| NIKON D5300 | 6016 × 4016 | 14 | 1 | lossy 2 | (8, 8), 2 of 2 files | ✅ fine | only picture beyond the camera frame | `nikon-d5300-6016x4016-14bit.nef` | +| NIKON D5500 | 6016 × 4016 | 12 | 1 | lossy 2 | (8, 8), 2 of 2 files | ✅ fine | only picture beyond the camera frame | `nikon-d5500-6016x4016-12bit.nef` | +| NIKON D5500 | 6016 × 4016 | 14 | 2 | lossy 2 | (8, 8), 2 of 2 files | ✅ fine | only picture beyond the camera frame | `nikon-d5500-6016x4016-14bit.nef` | +| NIKON D5600 | 6016 × 4016 | 12 | 2 | lossy 2 | (8, 8), 4 of 4 files | ✅ measured entry | crop (8, 8, 6000, 4000) | `nikon-d5600-6016x4016-12bit.nef` | +| NIKON D5600 | 6016 × 4016 | 14 | 2 | lossy 2 | (8, 8), 4 of 4 files | ✅ measured entry | crop (8, 8, 6000, 4000) | `nikon-d5600-6016x4016-14bit.nef` | +| NIKON D60 | 3904 × 2616 | 12 | 1 | lossy 1 | (14, 9), 1 of 1 files | ✅ measured entry | crop (14, 9, 3872, 2592); siblings: NIKON D3000 (1), NIKON D40X (1), NIKON D80 (1) | `nikon-d60-3904x2616-12bit.nef` | +| NIKON D600 | 6080 × 4028 | 12 | 2 | lossless, lossy 2 | (8, 6), 4 of 4 files | ✅ measured entry | crop (8, 6, 6016, 4016) | `nikon-d600-6080x4028-12bit.nef` | +| NIKON D600 | 6080 × 4028 | 14 | 2 | lossless, lossy 2 | (8, 6), 4 of 4 files | ✅ measured entry | crop (8, 6, 6016, 4016) | `nikon-d600-6080x4028-14bit.nef` | +| NIKON D610 | 6080 × 4028 | 12 | 2 | lossless, lossy 2 | (8, 6), 4 of 4 files | ✅ measured entry | crop (8, 6, 6016, 4016) | `nikon-d610-6080x4028-12bit.nef` | +| NIKON D610 | 6080 × 4028 | 14 | 2 | lossless, lossy 2 | (8, 6), 4 of 4 files | ✅ measured entry | crop (8, 6, 6016, 4016) | `nikon-d610-6080x4028-14bit.nef` | +| NIKON D70 | 3040 × 2014 | 12 | 1 | lossy 1 | (15, 7), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-d70-3040x2014-12bit.nef` | +| NIKON D700 | 4288 × 2844 | 12 | 3 | lossless, lossy 2, uncompressed | (15, 6), 6 of 6 files | ✅ measured entry | crop (15, 6, 4256, 2832) | `nikon-d700-4288x2844-12bit.nef` | +| NIKON D700 | 4288 × 2844 | 14 | 3 | lossless, lossy 2, uncompressed | (15, 6), 6 of 6 files | ✅ measured entry | crop (15, 6, 4256, 2832) | `nikon-d700-4288x2844-14bit.nef` | +| NIKON D7000 | 4992 × 3280 | 12 | 2 | lossless, lossy 2 | (8, 8), 1 of 5 files | ✅ measured entry | crop (8, 8, 4928, 3264); siblings: COOLPIX A (1), NIKON D5100 (3) | `nikon-d7000-4992x3280-12bit.nef` | +| NIKON D7000 | 4992 × 3280 | 14 | 3 | lossless, lossy 2 | (8, 8), 1 of 5 files | ✅ measured entry | crop (8, 8, 4928, 3264); siblings: COOLPIX A (1), NIKON D5100 (3) | `nikon-d7000-4992x3280-14bit.nef` | +| NIKON D70s | 3040 × 2014 | 12 | 1 | lossy 1 | unclear (3008×2000, score 0.76) | ❓ unmeasured | no masked edge lines found | `nikon-d70s-3040x2014-12bit.nef` | +| NIKON D7100 | 6036 × 4020 | 12 | 2 | lossless, lossy 2 | unclear (6000×4000, score 0.23) | ❓ unmeasured | no masked edge lines found | `nikon-d7100-6036x4020-12bit.nef` | +| NIKON D7100 | 6036 × 4020 | 14 | 2 | lossless, lossy 2 | unclear (6000×4000, score 0.24) | ❓ unmeasured | no masked edge lines found | `nikon-d7100-6036x4020-14bit.nef` | +| NIKON D7200 | 6016 × 4016 | 12 | 2 | lossless, lossy 2 | unclear (6000×4000, score 0.33) | ❓ unmeasured | no masked edge lines found | `nikon-d7200-6016x4016-12bit.nef` | +| NIKON D7200 | 6016 × 4016 | 14 | 2 | lossless, lossy 2 | unclear (6000×4000, score 0.31) | ❓ unmeasured | no masked edge lines found | `nikon-d7200-6016x4016-14bit.nef` | +| NIKON D750 | 6032 × 4032 | 12 | 2 | lossless, lossy 2 | (8, 8), 2 of 4 files | ❓ check | dark lines in crop (optical black or a dark sample): left 8, right 8, top 8; pending: 1 more clean file(s) | `nikon-d750-6032x4032-12bit.nef` | +| NIKON D750 | 6032 × 4032 | 14 | 2 | lossless, lossy 2 | (8, 8), 2 of 4 files | ❓ check | dark lines in crop (optical black or a dark sample): left 4, right 8, top 8; pending: 1 more clean file(s) | `nikon-d750-6032x4032-14bit.nef` | +| NIKON D80 | 3904 × 2616 | 12 | 1 | lossy 1 | (14, 9), 1 of 1 files | ✅ measured entry | crop (14, 9, 3872, 2592); siblings: NIKON D3000 (1), NIKON D40X (1), NIKON D60 (1) | `nikon-d80-3904x2616-12bit.nef` | +| NIKON D80 | 3900 × 2611 | 16 | 1 | compressed | no full-size JPEG | ❓ unmeasured | no masked edge lines found | `nikon-d80-3900x2611-16bit.dng` | +| NIKON D800 | 7424 × 4924 | 12 | 3 | lossless, lossy 2, uncompressed | (8, 6), 6 of 6 files | ✅ measured entry | crop (8, 6, 7360, 4912) | `nikon-d800-7424x4924-12bit.nef` | +| NIKON D800 | 7424 × 4924 | 14 | 3 | lossless, lossy 2, uncompressed | (8, 6), 6 of 6 files | ✅ measured entry | crop (8, 6, 7360, 4912) | `nikon-d800-7424x4924-14bit.nef` | +| NIKON D800E | 7424 × 4924 | 14 | 2 | lossless, uncompressed | (8, 6), 2 of 2 files | ✅ measured entry | crop (8, 6, 7360, 4912); siblings: NIKON D800 (6) | `nikon-d800e-7424x4924-14bit.nef` | +| NIKON D810 | 3680 × 2456 | 12 | 1 | HE / other | — | not decoded | Nikon compressed NEF without a linearization table (maker note 0x96) | `nikon-d810-3680x2456-12bit.nef` | +| NIKON D810 | 7380 × 4928 | 12 | 3 | HE / other, lossless, lossy 2 | (12, 8), 3 of 3 files | ✅ measured entry | crop (12, 8, 7360, 4912) | `nikon-d810-7380x4928-12bit.nef` | +| NIKON D810 | 7380 × 4928 | 14 | 3 | lossless, lossy 2, uncompressed | (12, 8), 3 of 3 files | ✅ measured entry | crop (12, 8, 7360, 4912) | `nikon-d810-7380x4928-14bit.nef` | +| NIKON D90 | 4352 × 2868 | 12 | 1 | lossy 2 | (11, 10), 1 of 1 files | ✅ fine | only picture beyond the camera frame | `nikon-d90-4352x2868-12bit.nef` | +| NIKON Df | 4992 × 3292 | 12 | 3 | lossless, lossy 2, uncompressed | (8, 6), 6 of 6 files | ✅ measured entry | crop (8, 6, 4928, 3280) | `nikon-df-4992x3292-12bit.nef` | +| NIKON Df | 4992 × 3292 | 14 | 3 | lossless, lossy 2, uncompressed | (8, 6), 6 of 6 files | ✅ measured entry | crop (8, 6, 4928, 3280) | `nikon-df-4992x3292-14bit.nef` | +| COOLPIX P340 | 4032 × 3024 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | +| COOLPIX P6000 | 4240 × 3174 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | +| COOLPIX P7000 | 3664 × 2742 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | +| COOLPIX P7100 | 3664 × 2744 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | +| E5400 | 2608 × 1950 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | +| NIKON 1 S2 | 4608 × 3080 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | +| NIKON D1 | 2012 × 1324 | 12 | — | — | — | no CC0 sample | only non-CC0 samples on raw.pixls.us | — | + +## Bodies that record `CropArea` + +Read from the maker note of every sample (the first 1 MB of each file); the decoder crops to it. + +| Model | Data | Bits | CropArea (x, y, width, height) | +|---|---|---:|---| +| NIKON D3400 | 6016 × 4016 | 12 | (8, 8, 6000, 4000) | +| NIKON D3500 | 6016 × 4016 | 12 | (8, 8, 6000, 4000) | +| NIKON D5 | 5584 × 3728 | 14 | (8, 8, 5568, 3712) | +| NIKON D500 | 5600 × 3728 | 12 | (16, 8, 5568, 3712) | +| NIKON D500 | 5600 × 3728 | 14 | (16, 8, 5568, 3712) | +| NIKON D500 | 4800 × 3192 | 12 | (15, 6, 4770, 3180) | +| NIKON D500 | 4200 × 2792 | 12 | (12, 4, 4176, 2784) | +| NIKON D6 | 5584 × 3728 | 14 | (8, 8, 5568, 3712) | +| NIKON D6 | 4786 × 3194 | 12 | (8, 7, 4770, 3180) | +| NIKON D6 | 4188 × 2796 | 12 | (6, 6, 4176, 2784) | +| NIKON D6 | 5584 × 3728 | 12 | (8, 8, 5568, 3712) | +| NIKON D7500 | 5600 × 3728 | 12 | (16, 8, 5568, 3712) | +| NIKON D7500 | 5600 × 3728 | 14 | (16, 8, 5568, 3712) | +| NIKON D780 | 6064 × 4040 | 12 | (8, 8, 6048, 4024) | +| NIKON D780 | 6064 × 4040 | 14 | (8, 8, 6048, 4024) | +| NIKON D850 | 6216 × 4136 | 12 | (12, 4, 6192, 4128) | +| NIKON D850 | 7104 × 4728 | 12 | (15, 5, 7074, 4716) | +| NIKON D850 | 8288 × 5520 | 12 | (16, 8, 8256, 5504) | +| NIKON D850 | 8288 × 5520 | 14 | (16, 8, 8256, 5504) | +| NIKON Z 30 | 5600 × 3728 | 12 | (16, 8, 5568, 3712) | +| NIKON Z 30 | 5600 × 3728 | 14 | (16, 8, 5568, 3712) | +| NIKON Z 5 | 6040 × 4032 | 14 | (12, 8, 6016, 4016) | +| NIKON Z 5 | 6040 × 4032 | 12 | (12, 8, 6016, 4016) | +| NIKON Z 50 | 5600 × 3728 | 14 | (16, 8, 5568, 3712) | +| NIKON Z 50 | 5600 × 3728 | 12 | (16, 8, 5568, 3712) | +| NIKON Z 6 | 6064 × 4040 | 14 | (8, 8, 6048, 4024) | +| NIKON Z 6 | 6064 × 4040 | 12 | (8, 8, 6048, 4024) | +| NIKON Z 6 | 5196 × 3462 | 12 | (10, 7, 5176, 3448) | +| NIKON Z 6 | 4548 × 3030 | 12 | (10, 6, 4528, 3018) | +| NIKON Z 6_2 | 6064 × 4040 | 14 | (8, 8, 6048, 4024) | +| NIKON Z 6_2 | 6064 × 4040 | 12 | (8, 8, 6048, 4024) | +| NIKON Z 7 | 8288 × 5520 | 14 | (16, 8, 8256, 5504) | +| NIKON Z 7 | 8288 × 5520 | 12 | (16, 8, 8256, 5504) | +| NIKON Z 7 | 7104 × 4730 | 12 | (15, 7, 7074, 4716) | +| NIKON Z 7 | 6216 × 4140 | 12 | (12, 6, 6192, 4128) | +| NIKON Z 7_2 | 8288 × 5520 | 14 | (16, 8, 8256, 5504) | +| NIKON Z 7_2 | 8288 × 5520 | 12 | (16, 8, 8256, 5504) | +| NIKON Z 8 | 8280 × 5520 | 14 | (12, 8, 8256, 5504) | +| NIKON Z 9 | 8280 × 5520 | 14 | (12, 8, 8256, 5504) | +| NIKON Z f | 6064 × 4040 | 14 | (8, 4, 6048, 4032) | +| NIKON Z fc | 5600 × 3728 | 14 | (16, 8, 5568, 3712) | +| NIKON Z fc | 5600 × 3728 | 12 | (16, 8, 5568, 3712) | +| NIKON Z50_2 | 5600 × 3728 | 14 | (16, 8, 5568, 3712) | +| NIKON Z50_2 | 3728 × 3728 | 14 | (8, 8, 3712, 3712) | +| NIKON Z50_2 | 5600 × 3144 | 14 | (16, 8, 5568, 3128) | +| NIKON Z5_2 | 4048 × 4040 | 14 | (8, 4, 4032, 4032) | +| NIKON Z5_2 | 6064 × 3416 | 14 | (8, 8, 6048, 3400) | +| NIKON Z5_2 | 4000 × 2672 | 14 | (8, 8, 3984, 2656) | +| NIKON Z5_2 | 6064 × 4040 | 14 | (8, 4, 6048, 4032) | +| NIKON Z6_3 | 4048 × 4040 | 14 | (8, 4, 4032, 4032) | +| NIKON Z6_3 | 6064 × 3416 | 14 | (8, 8, 6048, 3400) | +| NIKON Z6_3 | 4000 × 2672 | 14 | (8, 8, 3984, 2656) | +| NIKON Z6_3 | 6064 × 4040 | 14 | (8, 4, 6048, 4032) | + +## Adding a body + +1. Gather files of the body: several per data size, with detail across the frame (a blank sky, fog or a lens cap + has nothing to align on), shot with in-camera distortion control off. +2. Run `cargo xtask raw-frame FILE…`. It prints, per model and data size, how many files align cleanly on the same + frame and, once there is enough evidence, the `MeasuredBody` lines. +3. Evidence: at least 3 clean files that agree exactly, either of the model itself (12- and 14-bit pooled: both + read out the same area), or at least one of its own plus *siblings*, other models with the same readout (data + size and JPEG size) whose own files agree on the same frame. The same data size alone is not enough: the D200 + and D80 share 3904 × 2616 data but frame it at (16, 12) and (14, 9). +4. Add an entry only when the decoder's crop keeps lines without picture (*edge bands* and *beyond the camera frame* + in the tool's output), paste it into `MEASURED_BODIES` with a comment naming the evidence, and update this file. + `measured_bodies_are_consistent` checks every entry lies inside its data and is listed once. + +## How this survey was made, and how to reproduce it + +The evidence is committed next to this file, in [`nikon-bodies/`](nikon-bodies/): + +- [`headers.json`](nikon-bodies/headers.json): every Nikon raw on raw.pixls.us (index of 2026-10-11, 291 files) with its + URL, sha256, raw image size, depth and compression and the maker note's `CropArea`, `CropHiSpeed` and + `NEFCompression`, read from the first 1 MB of each file. +- [`samples.json`](nikon-bodies/samples.json): the 138 CC0 files measured: one per model, data size and depth without + `CropArea`, and every other CC0 sample of the groups that showed lines without picture (URL and sha256 each). +- [`frames.jsonl`](nikon-bodies/frames.jsonl): `cargo xtask raw-frame --json` on those files: frame, score, margin, + edge bands and what the decoder's crop keeps beyond the frame. + +To reproduce, or to re-measure after a decoder change: + +```sh +cargo xtask corpus --download nikon # 138 files, ~2.7 GB, into corpus/raw/nikon-inventory/ (sha256 checked) +cargo xtask raw-frame corpus/raw/nikon-inventory/*.* corpus/raw/nikon-inventory/extra/* +cargo xtask raw-frame --json corpus/raw/nikon-inventory/*.* corpus/raw/nikon-inventory/extra/* > docs/nikon-bodies/frames.jsonl +``` + +The first `raw-frame` prints each group's verdict and `MeasuredBody` entries; the counts in the tables above are its +output. Only CC0 files are listed; the D4's 14-bit entry comes from its contributor's own measurement. The statuses +above reflect the decoder with this file's entries. diff --git a/docs/nikon-bodies/frames.jsonl b/docs/nikon-bodies/frames.jsonl new file mode 100644 index 000000000..b8e2bc512 --- /dev/null +++ b/docs/nikon-bodies/frames.jsonl @@ -0,0 +1,138 @@ +{"bands":{"bottom":{"dark":128,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/coolpix-a-4992x3280-14bit.nef","frame":{"at":[7.991218271186213,7.984734834742818],"clean":true,"jpeg":[4928,3264],"margin":0.320695820604336,"score":0.9461083377252202},"in_crop":{"bottom":120,"left":0,"right":0,"top":0},"model":"COOLPIX A","size":[4992,3280],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,4544,3456],"file":"corpus/raw/nikon-inventory/coolpix-a1000-4608x3456-12bit.nrw","frame":{"at":[0.0,0.0],"clean":false,"jpeg":[4608,3456],"margin":0.2146546126305362,"score":0.2146546126305362},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"COOLPIX A1000","size":[4608,3456],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":200.0,"crop":[0,0,5200,3902],"file":"corpus/raw/nikon-inventory/coolpix-b700-5200x3902-12bit.nrw","frame":{"at":[8.117527970169887,2.514114098818923],"clean":false,"jpeg":[5184,3888],"margin":0.0018384623713932347,"score":0.4427812879486046},"in_crop":{"bottom":0,"left":0,"right":128,"top":0},"model":"COOLPIX B700","size":[5200,3902],"white":4079.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":200.0,"crop":[0,0,4624,3470],"file":"corpus/raw/nikon-inventory/coolpix-p1000-4624x3470-12bit.nrw","frame":{"at":[0.0,0.0],"clean":false,"jpeg":[4608,3456],"margin":0.002211624468715756,"score":0.29924756248594736},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"COOLPIX P1000","size":[4624,3470],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":200.0,"crop":[0,0,4624,3470],"file":"corpus/raw/nikon-inventory/coolpix-p1100-4624x3470-12bit.nrw","frame":{"at":[0.0,14.0],"clean":false,"jpeg":[4608,3456],"margin":0.0015148405389381336,"score":0.19682573070506165},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"COOLPIX P1100","size":[4624,3470],"white":4095.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/coolpix-p330-4032x3024-12bit.nrw"} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/coolpix-p7700-4032x3024-12bit.nrw"} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/coolpix-p7800-4032x3024-12bit.nrw"} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":200.0,"crop":[0,0,4624,3470],"file":"corpus/raw/nikon-inventory/coolpix-p950-4624x3470-12bit.nrw","frame":{"at":[1.9304110667713008,0.0],"clean":false,"jpeg":[4608,3456],"margin":0.000308158253209595,"score":0.20857626808889443},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"COOLPIX P950","size":[4624,3470],"white":4078.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,2576,1924],"file":"corpus/raw/nikon-inventory/e5700-2576x1924-12bit.nef","frame":{"error":"the largest preview (640×480) is not a full-size JPEG of the 2576×1924 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"E5700","size":[2576,1924],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3280,2454],"file":"corpus/raw/nikon-inventory/e8400-3280x2454-12bit.nef","frame":{"error":"the largest preview (1600×1200) is not a full-size JPEG of the 3280×2454 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"E8400","size":[3280,2454],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3280,2454],"file":"corpus/raw/nikon-inventory/e8800-3280x2454-12bit.nef","frame":{"error":"the largest preview (1600×1200) is not a full-size JPEG of the 3280×2454 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"E8800","size":[3280,2454],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":2},"left":{"dark":0,"masked":0,"picture":12},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":10}},"bits":12,"black":0.0,"crop":[0,0,4620,3084],"file":"corpus/raw/nikon-inventory/nikon-1-aw1-4620x3084-12bit.nef","frame":{"at":[12.0,9.881772886744043],"clean":true,"jpeg":[4608,3072],"margin":0.025115459242572524,"score":0.5461172395955519},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 AW1","size":[4620,3084],"white":4095.0} +{"bands":{"bottom":{"dark":2,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3904,2606],"file":"corpus/raw/nikon-inventory/nikon-1-j1-3904x2606-12bit.nef","frame":{"error":"the largest preview (1616×1080) is not a full-size JPEG of the 3904×2606 data"},"in_crop":{"bottom":2,"left":0,"right":0,"top":0},"model":"NIKON 1 J1","size":[3904,2606],"white":4095.0} +{"bands":{"bottom":{"dark":2,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3904,2606],"file":"corpus/raw/nikon-inventory/nikon-1-j2-3904x2606-12bit.nef","frame":{"error":"the largest preview (1616×1080) is not a full-size JPEG of the 3904×2606 data"},"in_crop":{"bottom":2,"left":0,"right":0,"top":0},"model":"NIKON 1 J2","size":[3904,2606],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":6},"left":{"dark":0,"masked":0,"picture":6},"right":{"dark":0,"masked":0,"picture":6},"top":{"dark":0,"masked":0,"picture":6}},"bits":12,"black":0.0,"crop":[0,0,4620,3084],"file":"corpus/raw/nikon-inventory/nikon-1-j3-4620x3084-12bit.nef","frame":{"at":[6.0121799754892,6.010387732302243],"clean":true,"jpeg":[4608,3072],"margin":0.35916693076964834,"score":0.9528098910292042},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 J3","size":[4620,3084],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":200.0,"crop":[0,0,5248,3504],"file":"corpus/raw/nikon-inventory/nikon-1-j4-5248x3504-12bit.nef","frame":{"at":[4.041269306655014,8.250550612474758],"clean":false,"jpeg":[5232,3488],"margin":0.017590203804156768,"score":0.4207034561445027},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 J4","size":[5248,3504],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":200.0,"crop":[0,0,5584,3726],"file":"corpus/raw/nikon-inventory/nikon-1-j5-5584x3726-12bit.nef","frame":{"at":[7.979719382569926,5.984733578303413],"clean":false,"jpeg":[5568,3712],"margin":0.27426181223538765,"score":0.2993335310428164},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 J5","size":[5584,3726],"white":4072.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3948,2604],"file":"corpus/raw/nikon-inventory/nikon-1-s1-3948x2604-12bit.nef","frame":{"error":"the largest preview (1620×1080) is not a full-size JPEG of the 3948×2604 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 S1","size":[3948,2604],"white":4095.0} +{"bands":{"bottom":{"dark":2,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":2,"masked":0,"picture":6},"left":{"dark":0,"masked":0,"picture":12},"right":{"dark":0,"masked":0,"picture":20},"top":{"dark":0,"masked":0,"picture":6}},"bits":12,"black":0.0,"crop":[0,0,3904,2606],"file":"corpus/raw/nikon-inventory/nikon-1-v1-3904x2606-12bit.nef","frame":{"at":[11.83644130952505,5.965873451016604],"clean":true,"jpeg":[3872,2592],"margin":0.08883419033058815,"score":0.8374681990482491},"in_crop":{"bottom":2,"left":0,"right":0,"top":0},"model":"NIKON 1 V1","size":[3904,2606],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":3,"masked":0,"picture":3},"left":{"dark":0,"masked":0,"picture":6},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":6}},"bits":12,"black":0.0,"crop":[0,0,4556,3084],"file":"corpus/raw/nikon-inventory/nikon-1-v2-4620x3084-12bit.nef","frame":{"at":[5.99532411238925,5.919067799645294],"clean":true,"jpeg":[4608,3072],"margin":0.16307025550867194,"score":0.8118777207360209},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 V2","size":[4620,3084],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":0,"picture":8},"top":{"dark":0,"masked":0,"picture":8}},"bits":12,"black":200.0,"crop":[0,0,5248,3504],"file":"corpus/raw/nikon-inventory/nikon-1-v3-5248x3504-12bit.nef","frame":{"at":[7.986155114735317,7.999411235310537],"clean":true,"jpeg":[5232,3488],"margin":0.2566831935328603,"score":0.9118503739132838},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON 1 V3","size":[5248,3504],"white":4095.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/nikon-d100-3034x2024-12bit.nef"} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,2012,1324],"file":"corpus/raw/nikon-inventory/nikon-d1h-2012x1324-12bit.nef","frame":{"error":"the largest preview (570×375) is not a full-size JPEG of the 2012×1324 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D1H","size":[2012,1324],"white":3953.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/nikon-d1x-4028x1324-12bit.nef"} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":1},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":12},"left":{"dark":0,"masked":1,"picture":15},"right":{"dark":0,"masked":1,"picture":15},"top":{"dark":0,"masked":0,"picture":12}},"bits":12,"black":0.0,"crop":[0,0,3904,2616],"file":"corpus/raw/nikon-inventory/nikon-d200-3904x2616-12bit.nef","frame":{"at":[16.01025407598696,12.006844183759362],"clean":true,"jpeg":[3872,2592],"margin":0.3416236421836649,"score":0.9595136124542439},"in_crop":{"bottom":0,"left":1,"right":1,"top":0},"model":"NIKON D200","size":[3904,2616],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,2496,1648],"file":"corpus/raw/nikon-inventory/nikon-d2h-2496x1648-12bit.nef","frame":{"error":"the largest preview (570×375) is not a full-size JPEG of the 2496×1648 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D2H","size":[2496,1648],"white":3893.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":7},"left":{"dark":0,"masked":0,"picture":16},"right":{"dark":0,"masked":0,"picture":16},"top":{"dark":0,"masked":0,"picture":9}},"bits":12,"black":0.0,"crop":[0,0,2496,1648],"file":"corpus/raw/nikon-inventory/nikon-d2hs-2496x1648-12bit.nef","frame":{"at":[16.038741141901035,9.069969938695325],"clean":true,"jpeg":[2464,1632],"margin":0.17716115547156064,"score":0.8101537541551725},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D2Hs","size":[2496,1648],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":10},"left":{"dark":0,"masked":0,"picture":16},"right":{"dark":0,"masked":0,"picture":16},"top":{"dark":0,"masked":0,"picture":10}},"bits":12,"black":0.0,"crop":[0,0,4320,2868],"file":"corpus/raw/nikon-inventory/nikon-d2x-4320x2868-12bit.nef","frame":{"at":[16.00858884711577,9.991109378310284],"clean":true,"jpeg":[4288,2848],"margin":0.40880031850072157,"score":0.8895149367778301},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D2X","size":[4320,2868],"white":3864.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[16,10,4288,2848],"file":"corpus/raw/nikon-inventory/nikon-d2xs-4320x2868-12bit.nef","frame":{"at":[16.030612351582747,10.026667883092484],"clean":true,"jpeg":[4288,2848],"margin":0.17971609935486177,"score":0.9670293366141101},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D2Xs","size":[4320,2868],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/nikon-d3-4288x2844-12bit.nef","frame":{"at":[15.06197935887176,6.209582795998887],"clean":true,"jpeg":[4256,2832],"margin":0.13107011150001835,"score":0.8372899975466536},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3","size":[4288,2844],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/nikon-d3-4288x2844-14bit.nef","frame":{"at":[15.0065254709036,6.175549268126694],"clean":true,"jpeg":[4256,2832],"margin":0.12550985591838626,"score":0.8478430501032164},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3","size":[4288,2844],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/nikon-d300-4352x2868-12bit.nef","frame":{"at":[14.963163304663745,10.013949375217896],"clean":true,"jpeg":[4288,2848],"margin":0.27057625314707945,"score":0.828855021089607},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300","size":[4352,2868],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":30},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/nikon-d300-4352x2868-14bit.nef","frame":{"at":[14.965340282236578,10.1093672491249],"clean":true,"jpeg":[4288,2848],"margin":0.27180978661403976,"score":0.8353492052707778},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300","size":[4352,2868],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":3},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":4},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[14,9,3872,2592],"file":"corpus/raw/nikon-inventory/nikon-d3000-3904x2616-12bit.nef","frame":{"at":[14.020985337488275,9.007736795968189],"clean":true,"jpeg":[3872,2592],"margin":0.18090525238171873,"score":0.8169129191871185},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3000","size":[3904,2616],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/nikon-d300s-4352x2868-12bit.nef","frame":{"at":[15.001424661603835,9.891872114725707],"clean":true,"jpeg":[4288,2848],"margin":0.231563820874913,"score":0.8448010960484519},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300S","size":[4352,2868],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":32},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/nikon-d300s-4352x2868-14bit.nef","frame":{"at":[15.005471214865059,9.888637180383792],"clean":true,"jpeg":[4288,2848],"margin":0.23336691666125664,"score":0.8471488598604319},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300S","size":[4352,2868],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":3},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":6},"left":{"dark":0,"masked":5,"picture":19},"right":{"dark":0,"masked":13,"picture":27},"top":{"dark":0,"masked":0,"picture":6}},"bits":12,"black":0.0,"crop":[0,0,4672,3084],"file":"corpus/raw/nikon-inventory/nikon-d3100-4672x3084-12bit.nef","frame":{"at":[23.990446328056706,6.083100669816429],"clean":true,"jpeg":[4608,3072],"margin":0.21459547420761427,"score":0.8945683409676348},"in_crop":{"bottom":0,"left":3,"right":0,"top":0},"model":"NIKON D3100","size":[4672,3084],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":44},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":6},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":45,"picture":11},"top":{"dark":0,"masked":0,"picture":6}},"bits":12,"black":0.0,"crop":[0,0,6080,4012],"file":"corpus/raw/nikon-inventory/nikon-d3200-6080x4012-12bit.nef","frame":{"at":[7.98074239159573,6.01139723926263],"clean":true,"jpeg":[6016,4000],"margin":0.3595418333415642,"score":0.8937326573333666},"in_crop":{"bottom":0,"left":0,"right":44,"top":0},"model":"NIKON D3200","size":[6080,4012],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":0,"picture":8},"top":{"dark":0,"masked":0,"picture":8}},"bits":12,"black":150.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d3300-6016x4016-12bit.nef","frame":{"at":[8.017930658000486,8.027302880304203],"clean":true,"jpeg":[6000,4000],"margin":0.19487309016988497,"score":0.6365949284639026},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3300","size":[6016,4016],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/nikon-d3s-4288x2844-12bit.nef","frame":{"at":[14.977910344660529,5.993320052275129],"clean":true,"jpeg":[4256,2832],"margin":0.25660763058250735,"score":0.8169187560395977},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3S","size":[4288,2844],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/nikon-d3s-4288x2844-14bit.nef","frame":{"at":[14.987442593524294,5.98400119337978],"clean":true,"jpeg":[4256,2832],"margin":0.24553931434573506,"score":0.8193142775561776},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3S","size":[4288,2844],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[0,0,6080,4044],"file":"corpus/raw/nikon-inventory/nikon-d3x-6080x4044-14bit.nef","frame":{"at":[14.86964137111168,5.925985683207438],"clean":false,"jpeg":[6048,4032],"margin":0.09007599064761829,"score":0.4958718622677699},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3X","size":[6080,4044],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/nikon-d4-4992x3292-12bit.nef","frame":{"at":[13.399213486513673,3.0802936823578593],"clean":false,"jpeg":[4928,3280],"margin":0.0304824142107043,"score":0.6099597535123862},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4","size":[4992,3292],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/nikon-d4-4992x3292-14bit.nef","frame":{"at":[13.478674204732135,2.999745693545507],"clean":false,"jpeg":[4928,3280],"margin":0.021159169875086148,"score":0.6071558327589177},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4","size":[4992,3292],"white":15520.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":5},"left":{"dark":0,"masked":0,"picture":16},"right":{"dark":0,"masked":0,"picture":16},"top":{"dark":0,"masked":0,"picture":9}},"bits":12,"black":0.0,"crop":[0,0,3040,2014],"file":"corpus/raw/nikon-inventory/nikon-d40-3040x2014-12bit.nef","frame":{"at":[15.959615240113543,9.17315605761679],"clean":true,"jpeg":[3008,2000],"margin":0.18466231384261222,"score":0.8632578187475985},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D40","size":[3040,2014],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":3},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":4},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[14,9,3872,2592],"file":"corpus/raw/nikon-inventory/nikon-d40x-3904x2616-12bit.nef","frame":{"at":[14.01656548784139,8.787332104654881],"clean":true,"jpeg":[3872,2592],"margin":0.1542437902013285,"score":0.816191473050358},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D40X","size":[3904,2616],"white":4080.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/nikon-d4s-2464x1640-12bit.nef"} +{"bands":{"bottom":{"dark":128,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":192.0,"crop":[4,4,4928,3280],"file":"corpus/raw/nikon-inventory/nikon-d4s-4936x3288-12bit.nef","frame":{"at":[3.957424031022619,3.995342441481671],"clean":true,"jpeg":[4928,3280],"margin":0.306070801333025,"score":0.9263599049311801},"in_crop":{"bottom":124,"left":0,"right":0,"top":0},"model":"NIKON D4S","size":[4936,3288],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":768.0,"crop":[4,4,4928,3280],"file":"corpus/raw/nikon-inventory/nikon-d4s-4936x3288-14bit.nef","frame":{"at":[3.986434158718541,3.9773600975641097],"clean":true,"jpeg":[4928,3280],"margin":0.309896666741202,"score":0.9333110382543731},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4S","size":[4936,3288],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":1},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3040,2014],"file":"corpus/raw/nikon-inventory/nikon-d50-3040x2014-12bit.nef","frame":{"at":[14.413195438114768,7.3980069536379505],"clean":false,"jpeg":[3008,2000],"margin":0.18308086995218098,"score":0.7423127283804339},"in_crop":{"bottom":0,"left":0,"right":1,"top":0},"model":"NIKON D50","size":[3040,2014],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,4352,2868],"file":"corpus/raw/nikon-inventory/nikon-d5000-4352x2868-12bit.nef","frame":{"at":[0.0,6.141511491497044],"clean":false,"jpeg":[4288,2848],"margin":0.0009979280512447675,"score":0.5750553855650493},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5000","size":[4352,2868],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/nikon-d5100-4992x3280-14bit.nef","frame":{"at":[8.008021848230495,8.016054975969679],"clean":true,"jpeg":[4928,3264],"margin":0.3527114054043975,"score":0.8976575608894457},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5100","size":[4992,3280],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[0,0,6036,4020],"file":"corpus/raw/nikon-inventory/nikon-d5200-6036x4020-14bit.nef","frame":{"at":[17.645007680152993,13.582070090541709],"clean":false,"jpeg":[6000,4000],"margin":0.03524685187151477,"score":0.5473877132887051},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5200","size":[6036,4020],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":0,"picture":8},"top":{"dark":0,"masked":0,"picture":8}},"bits":12,"black":150.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d5300-6016x4016-12bit.nef","frame":{"at":[7.985103420488071,7.9982612013849534],"clean":true,"jpeg":[6000,4000],"margin":0.3577109057296845,"score":0.7942102854464412},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5300","size":[6016,4016],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":0,"picture":8},"top":{"dark":0,"masked":0,"picture":8}},"bits":14,"black":600.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d5300-6016x4016-14bit.nef","frame":{"at":[7.976807992051084,7.998189111104181],"clean":true,"jpeg":[6000,4000],"margin":0.3121296742299233,"score":0.7920660149557761},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5300","size":[6016,4016],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":0,"picture":8},"top":{"dark":0,"masked":0,"picture":8}},"bits":12,"black":150.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d5500-6016x4016-12bit.nef","frame":{"at":[8.073050735635013,8.054269312911488],"clean":true,"jpeg":[6000,4000],"margin":0.20981793009003685,"score":0.6995731820281116},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5500","size":[6016,4016],"white":4072.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":0,"masked":0,"picture":8},"right":{"dark":0,"masked":0,"picture":8},"top":{"dark":0,"masked":0,"picture":8}},"bits":14,"black":600.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d5500-6016x4016-14bit.nef","frame":{"at":[8.052197952469333,8.036494438115742],"clean":true,"jpeg":[6000,4000],"margin":0.22671674296643968,"score":0.6954276957370694},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5500","size":[6016,4016],"white":16287.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":150.0,"crop":[8,8,6000,4000],"file":"corpus/raw/nikon-inventory/nikon-d5600-6016x4016-12bit.nef","frame":{"at":[7.969084135749677,7.974496754705931],"clean":true,"jpeg":[6000,4000],"margin":0.28403697333640165,"score":0.8206301903421694},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5600","size":[6016,4016],"white":4072.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":600.0,"crop":[8,8,6000,4000],"file":"corpus/raw/nikon-inventory/nikon-d5600-6016x4016-14bit.nef","frame":{"at":[7.959800456495091,7.994556897278949],"clean":true,"jpeg":[6000,4000],"margin":0.2768906120524435,"score":0.7952585709106961},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5600","size":[6016,4016],"white":16287.0} +{"bands":{"bottom":{"dark":0,"masked":3},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":4},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[14,9,3872,2592],"file":"corpus/raw/nikon-inventory/nikon-d60-3904x2616-12bit.nef","frame":{"at":[14.043134767884817,9.122245522713433],"clean":true,"jpeg":[3872,2592],"margin":0.21826226122323622,"score":0.7313381116229737},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D60","size":[3904,2616],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d600-6080x4028-12bit.nef","frame":{"at":[8.018829583976117,6.012496654801777],"clean":true,"jpeg":[6016,4016],"margin":0.3670041004770914,"score":0.9142582945577193},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D600","size":[6080,4028],"white":3174.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d600-6080x4028-14bit.nef","frame":{"at":[8.02056019198638,6.017223068403106],"clean":true,"jpeg":[6016,4016],"margin":0.37079877685376206,"score":0.9135494095428678},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D600","size":[6080,4028],"white":12707.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d610-6080x4028-12bit.nef","frame":{"at":[7.976958545258397,6.020087854077047],"clean":true,"jpeg":[6016,4016],"margin":0.2957101224147237,"score":0.8230370934847547},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D610","size":[6080,4028],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d610-6080x4028-14bit.nef","frame":{"at":[7.989126860167796,6.02270028324246],"clean":true,"jpeg":[6016,4016],"margin":0.2890755616443125,"score":0.8248939083917021},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D610","size":[6080,4028],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":7},"left":{"dark":0,"masked":0,"picture":15},"right":{"dark":0,"masked":0,"picture":17},"top":{"dark":0,"masked":0,"picture":7}},"bits":12,"black":0.0,"crop":[0,0,3040,2014],"file":"corpus/raw/nikon-inventory/nikon-d70-3040x2014-12bit.nef","frame":{"at":[15.238798183649205,6.731085392714868],"clean":true,"jpeg":[3008,2000],"margin":0.1391085067257367,"score":0.7887356463015482},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D70","size":[3040,2014],"white":4080.0} +{"bands":{"bottom":{"dark":17,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/nikon-d700-4288x2844-12bit.nef","frame":{"at":[14.942573488355356,6.047312558884514],"clean":true,"jpeg":[4256,2832],"margin":0.2156080260452553,"score":0.8610453656395287},"in_crop":{"bottom":11,"left":0,"right":0,"top":0},"model":"NIKON D700","size":[4288,2844],"white":3918.0} +{"bands":{"bottom":{"dark":17,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/nikon-d700-4288x2844-14bit.nef","frame":{"at":[14.937801366197323,6.048356598289724],"clean":true,"jpeg":[4256,2832],"margin":0.21832357175985173,"score":0.861047104011575},"in_crop":{"bottom":11,"left":0,"right":0,"top":0},"model":"NIKON D700","size":[4288,2844],"white":15689.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/nikon-d7000-4992x3280-12bit.nef","frame":{"at":[7.797292136225339,8.505365503155778],"clean":false,"jpeg":[4928,3264],"margin":0.0931008039754595,"score":0.761386671692001},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7000","size":[4992,3280],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/nikon-d7000-4992x3280-14bit.nef","frame":{"at":[7.9217379308398,8.519679730671863],"clean":false,"jpeg":[4928,3264],"margin":0.10394198029376478,"score":0.7618415778636567},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7000","size":[4992,3280],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,3040,2014],"file":"corpus/raw/nikon-inventory/nikon-d70s-3040x2014-12bit.nef","frame":{"at":[15.361879085749457,6.929337772746099],"clean":false,"jpeg":[3008,2000],"margin":0.12404186918983884,"score":0.7632094017630537},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D70s","size":[3040,2014],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[0,0,6036,4020],"file":"corpus/raw/nikon-inventory/nikon-d7100-6036x4020-12bit.nef","frame":{"at":[0.0,0.0],"clean":false,"jpeg":[6000,4000],"margin":0.0007707770448449058,"score":0.2320813780433149},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7100","size":[6036,4020],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[0,0,6036,4020],"file":"corpus/raw/nikon-inventory/nikon-d7100-6036x4020-14bit.nef","frame":{"at":[0.0,0.0],"clean":false,"jpeg":[6000,4000],"margin":0.0024608175352617,"score":0.23634771596034546},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7100","size":[6036,4020],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":150.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d7200-6016x4016-12bit.nef","frame":{"at":[8.008243161394006,8.023839897203336],"clean":false,"jpeg":[6000,4000],"margin":0.17680034308176606,"score":0.325544227330648},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7200","size":[6016,4016],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":600.0,"crop":[0,0,6016,4016],"file":"corpus/raw/nikon-inventory/nikon-d7200-6016x4016-14bit.nef","frame":{"at":[8.021254818586835,8.028887851774844],"clean":false,"jpeg":[6000,4000],"margin":0.16872668859408105,"score":0.3081936263986367},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7200","size":[6016,4016],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":128,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":128,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":8,"masked":0,"picture":0},"right":{"dark":8,"masked":0,"picture":0},"top":{"dark":8,"masked":0,"picture":0}},"bits":12,"black":150.0,"crop":[0,0,6032,4032],"file":"corpus/raw/nikon-inventory/nikon-d750-6032x4032-12bit.nef","frame":{"at":[7.670942297864694,8.113369200109082],"clean":true,"jpeg":[6016,4016],"margin":0.14395130237194154,"score":0.7865396925505159},"in_crop":{"bottom":0,"left":128,"right":128,"top":128},"model":"NIKON D750","size":[6032,4032],"white":4072.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":128,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":8},"left":{"dark":4,"masked":0,"picture":4},"right":{"dark":8,"masked":0,"picture":0},"top":{"dark":8,"masked":0,"picture":0}},"bits":14,"black":600.0,"crop":[0,0,6032,4032],"file":"corpus/raw/nikon-inventory/nikon-d750-6032x4032-14bit.nef","frame":{"at":[7.651793531638724,8.034084667085798],"clean":true,"jpeg":[6016,4016],"margin":0.14453837200820108,"score":0.7769333572453446},"in_crop":{"bottom":0,"left":0,"right":128,"top":128},"model":"NIKON D750","size":[6032,4032],"white":16287.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":16,"black":0.0,"crop":[14,9,3872,2592],"file":"corpus/raw/nikon-inventory/nikon-d80-3900x2611-16bit.dng","frame":{"error":"the largest preview (1024×683) is not a full-size JPEG of the 3900×2611 data"},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D80","size":[3900,2611],"white":3880.0} +{"bands":{"bottom":{"dark":0,"masked":3},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":4},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[14,9,3872,2592],"file":"corpus/raw/nikon-inventory/nikon-d80-3904x2616-12bit.nef","frame":{"at":[13.845289593690447,9.259505121441883],"clean":true,"jpeg":[3872,2592],"margin":0.23602643297310755,"score":0.8225433845356894},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D80","size":[3904,2616],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/nikon-d800-7424x4924-12bit.nef","frame":{"at":[7.930847189878942,5.915653347350122],"clean":true,"jpeg":[7360,4912],"margin":0.26927818028915373,"score":0.9317770552865274},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800","size":[7424,4924],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/nikon-d800-7424x4924-14bit.nef","frame":{"at":[7.91561059480293,5.902197171122292],"clean":true,"jpeg":[7360,4912],"margin":0.2762050732260336,"score":0.93742688048927},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800","size":[7424,4924],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/nikon-d800e-7424x4924-14bit.nef","frame":{"at":[7.972028781487126,5.964825938664561],"clean":true,"jpeg":[7360,4912],"margin":0.2715322090750353,"score":0.922034706153291},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800E","size":[7424,4924],"white":16319.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/nikon-d810-3680x2456-12bit.nef"} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":150.0,"crop":[12,8,7360,4912],"file":"corpus/raw/nikon-inventory/nikon-d810-7380x4928-12bit.nef","frame":{"at":[11.955407132657381,7.960002424656449],"clean":true,"jpeg":[7360,4912],"margin":0.34767946827779084,"score":0.9673216418460541},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D810","size":[7380,4928],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":600.0,"crop":[12,8,7360,4912],"file":"corpus/raw/nikon-inventory/nikon-d810-7380x4928-14bit.nef","frame":{"at":[11.963259725742814,7.960056571451713],"clean":true,"jpeg":[7360,4912],"margin":0.348139083781265,"score":0.9649553904857636},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D810","size":[7380,4928],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":10},"left":{"dark":0,"masked":0,"picture":11},"right":{"dark":0,"masked":0,"picture":53},"top":{"dark":0,"masked":0,"picture":10}},"bits":12,"black":0.0,"crop":[0,0,4352,2868],"file":"corpus/raw/nikon-inventory/nikon-d90-4352x2868-12bit.nef","frame":{"at":[10.9118846536034,10.028871172552668],"clean":true,"jpeg":[4288,2848],"margin":0.2009357647241916,"score":0.8284840582738},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D90","size":[4352,2868],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/nikon-df-4992x3292-12bit.nef","frame":{"at":[8.027905326823548,5.971693105397619],"clean":true,"jpeg":[4928,3280],"margin":0.2729527832823915,"score":0.8677363527751335},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON Df","size":[4992,3292],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/nikon-df-4992x3292-14bit.nef","frame":{"at":[8.115246622321285,6.1019002101089255],"clean":true,"jpeg":[4928,3280],"margin":0.26321414362147133,"score":0.8361672382416335},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON Df","size":[4992,3292],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":12},"left":{"dark":0,"masked":0,"picture":16},"right":{"dark":0,"masked":0,"picture":16},"top":{"dark":0,"masked":0,"picture":12}},"bits":12,"black":0.0,"crop":[0,0,3904,2616],"file":"corpus/raw/nikon-inventory/extra/nikon-d200-3904x2616-12bit-2.nef","frame":{"at":[16.007666069483708,12.008555105547535],"clean":true,"jpeg":[3872,2592],"margin":0.31960242299406516,"score":0.9394735869712327},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D200","size":[3904,2616],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[16,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d2xs-4320x2868-12bit-2.nef","frame":{"at":[16.05759019668479,10.055836179688047],"clean":true,"jpeg":[4288,2848],"margin":0.2007519455310358,"score":0.9697896352104445},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D2Xs","size":[4320,2868],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3-4288x2844-12bit-2.nef","frame":{"at":[14.980187078169585,6.066834960757376],"clean":true,"jpeg":[4256,2832],"margin":0.12244947411171359,"score":0.8378929185698076},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3","size":[4288,2844],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3-4288x2844-12bit-3.nef","frame":{"at":[14.992717334671173,6.056548228082232],"clean":true,"jpeg":[4256,2832],"margin":0.11523163034961137,"score":0.8384078036257756},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3","size":[4288,2844],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3-4288x2844-14bit-2.nef","frame":{"at":[15.041834233593521,6.170113980005793],"clean":true,"jpeg":[4256,2832],"margin":0.12725753360423142,"score":0.8511244105063482},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3","size":[4288,2844],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3-4288x2844-14bit-3.nef","frame":{"at":[14.990731846945978,5.993771217949102],"clean":true,"jpeg":[4256,2832],"margin":0.12678275755382395,"score":0.849762408929188},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3","size":[4288,2844],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d300-4352x2868-12bit-2.nef","frame":{"at":[14.967543691281245,10.024498614925346],"clean":true,"jpeg":[4288,2848],"margin":0.2659664226064259,"score":0.8327825218544854},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300","size":[4352,2868],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":1},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d300-4352x2868-12bit-3.nef","frame":{"at":[14.958111889293724,10.036567435039556],"clean":true,"jpeg":[4288,2848],"margin":0.2760517434215627,"score":0.8325580243786451},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300","size":[4352,2868],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":32},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d300-4352x2868-14bit-2.nef","frame":{"at":[15.003616148890309,10.000113616939965],"clean":true,"jpeg":[4288,2848],"margin":0.2679003500829471,"score":0.8363094330235713},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300","size":[4352,2868],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":32},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d300-4352x2868-14bit-3.nef","frame":{"at":[14.989447485699047,10.089343423234716],"clean":true,"jpeg":[4288,2848],"margin":0.2664176295360958,"score":0.829959127305412},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300","size":[4352,2868],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":32},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d300s-4352x2868-14bit-2.nef","frame":{"at":[15.00875433965344,9.894231096351069],"clean":true,"jpeg":[4288,2848],"margin":0.2280032923184374,"score":0.8494302173319537},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300S","size":[4352,2868],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":32},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,10,4288,2848],"file":"corpus/raw/nikon-inventory/extra/nikon-d300s-4352x2868-14bit-3.nef","frame":{"at":[14.975661275010463,10.040912462246233],"clean":true,"jpeg":[4288,2848],"margin":0.23086645396527306,"score":0.8503846854344668},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D300S","size":[4352,2868],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3s-4288x2844-12bit-2.nef","frame":{"at":[14.989055882434,5.990766546333286],"clean":true,"jpeg":[4256,2832],"margin":0.25381154387234073,"score":0.8170628248920012},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3S","size":[4288,2844],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3s-4288x2844-12bit-3.nef","frame":{"at":[14.993801717571497,5.981874525964817],"clean":true,"jpeg":[4256,2832],"margin":0.2571772156122383,"score":0.8235585336711027},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3S","size":[4288,2844],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3s-4288x2844-14bit-2.nef","frame":{"at":[14.99539737435001,5.974482931186765],"clean":true,"jpeg":[4256,2832],"margin":0.20584056839798281,"score":0.8877639131040432},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3S","size":[4288,2844],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":1},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d3s-4288x2844-14bit-3.nef","frame":{"at":[14.978236542262762,6.000763882248561],"clean":true,"jpeg":[4256,2832],"margin":0.26062848446839226,"score":0.8108940037689448},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D3S","size":[4288,2844],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-d4-4992x3292-12bit-2.nef","frame":{"at":[13.382055215728215,3.2234402185545528],"clean":false,"jpeg":[4928,3280],"margin":0.03201920577444273,"score":0.612110263416557},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4","size":[4992,3292],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-d4-4992x3292-12bit-3.nef","frame":{"at":[13.42360053212776,2.992393002373246],"clean":false,"jpeg":[4928,3280],"margin":0.02490272227928947,"score":0.6113121806173664},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4","size":[4992,3292],"white":4095.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-d4-4992x3292-14bit-2.nef","frame":{"at":[13.355858658318112,3.141329215346695],"clean":false,"jpeg":[4928,3280],"margin":0.02893704306865441,"score":0.6078042046696639},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4","size":[4992,3292],"white":15520.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-d4-4992x3292-14bit-3.nef","frame":{"at":[13.393817279588676,3.178444167665886],"clean":false,"jpeg":[4928,3280],"margin":0.03200927617995042,"score":0.6100234901763839},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D4","size":[4992,3292],"white":15520.0} +{"bands":{"bottom":{"dark":128,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":192.0,"crop":[4,4,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-d4s-4936x3288-12bit-2.nef","frame":{"at":[3.9598223026235435,3.987314697978216],"clean":true,"jpeg":[4928,3280],"margin":0.31074016364939616,"score":0.9284921865752147},"in_crop":{"bottom":124,"left":0,"right":0,"top":0},"model":"NIKON D4S","size":[4936,3288],"white":4095.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/extra/nikon-d4s-4936x3288-12bit-3.nef"} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":150.0,"crop":[8,8,6000,4000],"file":"corpus/raw/nikon-inventory/extra/nikon-d5600-6016x4016-12bit-2.nef","frame":{"at":[7.994586545151522,7.996817356389964],"clean":true,"jpeg":[6000,4000],"margin":0.25614293611645966,"score":0.5952496027458306},"in_crop":{"bottom":0,"left":0,"right":120,"top":0},"model":"NIKON D5600","size":[6016,4016],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":600.0,"crop":[8,8,6000,4000],"file":"corpus/raw/nikon-inventory/extra/nikon-d5600-6016x4016-14bit-2.nef","frame":{"at":[8.002860864614728,7.97829450425446],"clean":true,"jpeg":[6000,4000],"margin":0.2985718226202447,"score":0.618746247343758},"in_crop":{"bottom":0,"left":0,"right":120,"top":0},"model":"NIKON D5600","size":[6016,4016],"white":16287.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/extra/nikon-d600-6080x4028-12bit-2.nef","frame":{"at":[8.02382662032276,6.010713465882345],"clean":true,"jpeg":[6016,4016],"margin":0.35823042537036487,"score":0.9108606369733137},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D600","size":[6080,4028],"white":3182.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/extra/nikon-d600-6080x4028-14bit-2.nef","frame":{"at":[8.018800214291126,6.017619304536601],"clean":true,"jpeg":[6016,4016],"margin":0.3659367247945301,"score":0.9124135150660873},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D600","size":[6080,4028],"white":12729.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/extra/nikon-d610-6080x4028-12bit-2.nef","frame":{"at":[7.985970204366252,6.050344317946167],"clean":true,"jpeg":[6016,4016],"margin":0.28943262954575266,"score":0.8228170990105343},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D610","size":[6080,4028],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":32,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,6016,4016],"file":"corpus/raw/nikon-inventory/extra/nikon-d610-6080x4028-14bit-2.nef","frame":{"at":[7.977473095879465,6.0174068589515395],"clean":true,"jpeg":[6016,4016],"margin":0.2884178207385366,"score":0.8101465134744326},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D610","size":[6080,4028],"white":16320.0} +{"bands":{"bottom":{"dark":17,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d700-4288x2844-12bit-2.nef","frame":{"at":[14.941067070961772,6.050307207331495],"clean":true,"jpeg":[4256,2832],"margin":0.2182616143310312,"score":0.8615567654707883},"in_crop":{"bottom":11,"left":0,"right":0,"top":0},"model":"NIKON D700","size":[4288,2844],"white":3922.0} +{"bands":{"bottom":{"dark":19,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d700-4288x2844-12bit-3.nef","frame":{"at":[14.93854928966781,6.047747632093432],"clean":true,"jpeg":[4256,2832],"margin":0.21618878847249123,"score":0.8614344661861965},"in_crop":{"bottom":13,"left":0,"right":0,"top":0},"model":"NIKON D700","size":[4288,2844],"white":3922.0} +{"bands":{"bottom":{"dark":17,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d700-4288x2844-14bit-2.nef","frame":{"at":[14.935687802904276,6.043713039408552],"clean":true,"jpeg":[4256,2832],"margin":0.21742762990540077,"score":0.8618235451160839},"in_crop":{"bottom":11,"left":0,"right":0,"top":0},"model":"NIKON D700","size":[4288,2844],"white":15691.0} +{"bands":{"bottom":{"dark":17,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":0,"masked":2},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[15,6,4256,2832],"file":"corpus/raw/nikon-inventory/extra/nikon-d700-4288x2844-14bit-3.nef","frame":{"at":[14.943631773750436,6.050152789779033],"clean":true,"jpeg":[4256,2832],"margin":0.21807600657258264,"score":0.8608587904767784},"in_crop":{"bottom":11,"left":0,"right":0,"top":0},"model":"NIKON D700","size":[4288,2844],"white":15691.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":12,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/extra/nikon-d7000-4992x3280-12bit-2.nef","frame":{"at":[7.853405009476967,8.387166270529473],"clean":false,"jpeg":[4928,3264],"margin":0.0938003222014373,"score":0.7609341965535669},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7000","size":[4992,3280],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":null,"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/extra/nikon-d7000-4992x3280-14bit-2.nef","frame":{"at":[7.804233031747276,8.405918709914786],"clean":false,"jpeg":[4928,3264],"margin":0.09497530501545726,"score":0.757381044215693},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D7000","size":[4992,3280],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":128,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/extra/nikon-d7000-4992x3280-14bit-3.nef","frame":{"at":[7.9732705133564625,8.005829563440242],"clean":true,"jpeg":[4928,3264],"margin":0.2801468012241305,"score":0.5732713458469983},"in_crop":{"bottom":0,"left":0,"right":0,"top":120},"model":"NIKON D7000","size":[4992,3280],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":120,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":128,"masked":0}},"beyond_frame":null,"bits":12,"black":150.0,"crop":[0,0,6032,4032],"file":"corpus/raw/nikon-inventory/extra/nikon-d750-6032x4032-12bit-2.nef","frame":{"at":[7.606973106237589,8.102161918892651],"clean":false,"jpeg":[6016,4016],"margin":0.14086170856695934,"score":0.7729158488862322},"in_crop":{"bottom":0,"left":120,"right":128,"top":128},"model":"NIKON D750","size":[6032,4032],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":128,"masked":0},"top":{"dark":128,"masked":0}},"beyond_frame":null,"bits":14,"black":600.0,"crop":[0,0,6032,4032],"file":"corpus/raw/nikon-inventory/extra/nikon-d750-6032x4032-14bit-2.nef","frame":{"at":[7.552763215807217,8.07471871816735],"clean":false,"jpeg":[6016,4016],"margin":0.12658592287779824,"score":0.7764678431243132},"in_crop":{"bottom":0,"left":0,"right":128,"top":128},"model":"NIKON D750","size":[6032,4032],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/extra/nikon-d800-7424x4924-12bit-2.nef","frame":{"at":[7.914310287624199,5.873672120221374],"clean":true,"jpeg":[7360,4912],"margin":0.2995025285506596,"score":0.9340865082830804},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800","size":[7424,4924],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/extra/nikon-d800-7424x4924-12bit-3.nef","frame":{"at":[7.935265837129149,5.89757150311888],"clean":true,"jpeg":[7360,4912],"margin":0.32046329982900557,"score":0.9244157371324039},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800","size":[7424,4924],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/extra/nikon-d800-7424x4924-14bit-2.nef","frame":{"at":[7.930267675249204,5.87128926841653],"clean":true,"jpeg":[7360,4912],"margin":0.31008524591760145,"score":0.9375157258000918},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800","size":[7424,4924],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/extra/nikon-d800-7424x4924-14bit-3.nef","frame":{"at":[7.932256187810048,5.821194431533657],"clean":true,"jpeg":[7360,4912],"margin":0.2746563967226444,"score":0.9373162474490357},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D800","size":[7424,4924],"white":16320.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":30,"masked":0},"top":{"dark":46,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,7360,4912],"file":"corpus/raw/nikon-inventory/extra/nikon-d800e-7424x4924-14bit-2.nef","frame":{"at":[7.9144784168619555,5.997066662443523],"clean":true,"jpeg":[7360,4912],"margin":0.26423394438946823,"score":0.8584854723901633},"in_crop":{"bottom":0,"left":0,"right":0,"top":40},"model":"NIKON D800E","size":[7424,4924],"white":16319.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":0,"masked":0},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":150.0,"crop":[12,8,7360,4912],"file":"corpus/raw/nikon-inventory/extra/nikon-d810-7380x4928-12bit-2.nef","frame":{"at":[11.952675961670657,7.957759264677984],"clean":true,"jpeg":[7360,4912],"margin":0.3531360589775445,"score":0.9670673725399618},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D810","size":[7380,4928],"white":4095.0} +{"error":"unsupported raw variant: Nikon compressed NEF without a linearization table (maker note 0x96)","file":"corpus/raw/nikon-inventory/extra/nikon-d810-7380x4928-12bit-3.nef"} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-df-4992x3292-12bit-2.nef","frame":{"at":[8.077556969611615,6.0429380755628435],"clean":true,"jpeg":[4928,3280],"margin":0.27022865819945197,"score":0.8459071469181635},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON Df","size":[4992,3292],"white":4080.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":12,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-df-4992x3292-12bit-3.nef","frame":{"at":[8.131672535017442,6.125325801476691],"clean":true,"jpeg":[4928,3280],"margin":0.2521038670490052,"score":0.8204434177055658},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON Df","size":[4992,3292],"white":3872.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-df-4992x3292-14bit-2.nef","frame":{"at":[8.124196201896094,6.122177346942158],"clean":true,"jpeg":[4928,3280],"margin":0.2585267070435534,"score":0.8295563014469615},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON Df","size":[4992,3292],"white":15488.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":2},"right":{"dark":8,"masked":42},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,6,4928,3280],"file":"corpus/raw/nikon-inventory/extra/nikon-df-4992x3292-14bit-3.nef","frame":{"at":[8.121003123220877,6.104022926801082],"clean":true,"jpeg":[4928,3280],"margin":0.2570802113288917,"score":0.83190195591443},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON Df","size":[4992,3292],"white":15488.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/extra/nikon-d5100-4992x3280-14bit-lossless.nef","frame":{"at":[8.01068034199096,8.029686269115706],"clean":true,"jpeg":[4928,3264],"margin":0.37762191306608917,"score":0.8926857172435795},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5100","size":[4992,3280],"white":16383.0} +{"bands":{"bottom":{"dark":0,"masked":0},"left":{"dark":0,"masked":0},"right":{"dark":8,"masked":36},"top":{"dark":0,"masked":0}},"beyond_frame":{"bottom":{"dark":0,"masked":0,"picture":0},"left":{"dark":0,"masked":0,"picture":0},"right":{"dark":0,"masked":0,"picture":0},"top":{"dark":0,"masked":0,"picture":0}},"bits":14,"black":0.0,"crop":[8,8,4928,3264],"file":"corpus/raw/nikon-inventory/extra/nikon-d5100-4992x3280-14bit-uncompressed.nef","frame":{"at":[8.014583073547408,8.037900577847378],"clean":true,"jpeg":[4928,3264],"margin":0.3748761876102942,"score":0.8910891644721247},"in_crop":{"bottom":0,"left":0,"right":0,"top":0},"model":"NIKON D5100","size":[4992,3280],"white":16383.0} diff --git a/docs/nikon-bodies/headers.json b/docs/nikon-bodies/headers.json new file mode 100644 index 000000000..85f80f787 --- /dev/null +++ b/docs/nikon-bodies/headers.json @@ -0,0 +1,296 @@ +{ + "about": "every Nikon raw on raw.pixls.us (index of 2026-10-11): raw image and maker-note crop tags read from the first 1 MB of each file", + "files": [ + {"model": "NIKON D1", "mode": "12bit 12bit uncompressed (3:2)", "cc0": false, "url": "https://raw.pixls.us/getfile.php/201/nice/Nikon%20-%20D1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "7886d8b0e1257897faa7404b98fe1086ee2d95606531b6285aed83a0939b768f", "raw": {"w": 2012, "h": 1324, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON 1 S2", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": false, "url": "https://raw.pixls.us/getfile.php/205/nice/Nikon%20-%201%20S2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "c9a3eac53afb6ff393458f6a389466af05dcce8a32e97725f7aaef543e2894cb", "raw": {"w": 4608, "h": 3080, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4608, 3080, 4608, 3080, 0, 0], "nef_compression": [4]}, + {"model": "E5400", "mode": "12bit 12bit uncompressed (4:3)", "cc0": false, "url": "https://raw.pixls.us/getfile.php/212/nice/Nikon%20-%20E5400%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "0bc1b12855d1f15aac9d615a3613ec04b7c5354627dfdd50c4251bc27cf44d3a", "raw": {"w": 2608, "h": 1950, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D3X", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": false, "url": "https://raw.pixls.us/getfile.php/231/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "f52cf0aa29e65218bf80937764ab1946d850d505621e387a74f742d817aa6610", "raw": {"w": 6080, "h": 4044, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4044, 6080, 4044, 0, 0], "nef_compression": [3]}, + {"model": "COOLPIX P7100", "mode": "12bit 12bit-uncompressed", "cc0": false, "url": "https://raw.pixls.us/getfile.php/585/nice/Nikon%20-%20Coolpix%20P7100%20-%2012bit%2012bit-uncompressed.NRW", "sha256": "3cbb4ac4be80226cc3fa7c6ad4f01ea5bb3c640288e3847621e947129489fbd1", "raw": {"w": 3664, "h": 2744, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "COOLPIX P7000", "mode": "12bit 12bit-uncompressed", "cc0": false, "url": "https://raw.pixls.us/getfile.php/598/nice/Nikon%20-%20Coolpix%20P7000%20-%2012bit%2012bit-uncompressed.NRW", "sha256": "09165ec1031b36e6ae43f2263b4400ec4d9c3444ae252d258d002f7e96dbea25", "raw": {"w": 3664, "h": 2742, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "COOLPIX P340", "mode": "12bit 12bit-compressed ((9))", "cc0": false, "url": "https://raw.pixls.us/getfile.php/619/nice/Nikon%20-%20Coolpix%20P340%20-%2012bit%2012bit-compressed%20%28%289%29%29.NRW", "sha256": "ee99efe160b5a20a0df725ed2a0eeb75480fe3a634dd4d0310c9154dc3f5b79c", "raw": {"w": 4032, "h": 3024, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [9]}, + {"model": "COOLPIX P6000", "mode": "12bit 12bit-uncompressed", "cc0": false, "url": "https://raw.pixls.us/getfile.php/651/nice/Nikon%20-%20Coolpix%20P6000%20-%2012bit%2012bit-uncompressed.NRW", "sha256": "652d96bf6fc6979a7791da1781192e7efe5863d83a32196d6ec7e258d4063ff9", "raw": {"w": 4240, "h": 3174, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D3200", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/743/nice/Nikon%20-%20D3200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "91657dd8e9c7a086826683f6d20799685657dd7ca2056702b145851465f57cd3", "raw": {"w": 6080, "h": 4012, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4012, 6080, 4012, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D800", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/750/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "26d58c21ed3019af5a22ef6bc02f017b863aad9eafcf84252ed375cd495c0efb", "raw": {"w": 7424, "h": 4924, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D800", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/751/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "f5b7906869da3e48cb85ec08c5fa97c4c3ff8ab491825a89ac5ed0fff11fef5d", "raw": {"w": 7424, "h": 4924, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D800", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/755/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "fd34b869e799fa6b1a4940ddcd60cd74b1624b6244e923caf1dbdbe7866d9eac", "raw": {"w": 7424, "h": 4924, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": null}, + {"model": "NIKON D800", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/756/nice/Nikon%20-%20D800%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "2cc08a8722e1a53d7a19631b8ac1ce1084861358d9eec751a4d639744e3c657f", "raw": {"w": 7424, "h": 4924, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D800", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/757/nice/Nikon%20-%20D800%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "317766527a1a9019985d6b41c4b400f89e8fb8621f468a0826b77d17263ab648", "raw": {"w": 7424, "h": 4924, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D800", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/759/nice/Nikon%20-%20D800%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "e096d28a860f8b7afe3abde4ccc5cec5bcda229d137b4919cd821a2fc9bf87b2", "raw": {"w": 7424, "h": 4924, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": null}, + {"model": "NIKON D40X", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/804/nice/Nikon%20-%20D40X%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "c3e4956f7e2d1f1b3f846b6ec9552678073db0bcaeb51132ec4c60bd2229d56e", "raw": {"w": 3904, "h": 2616, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2616, 3904, 2616, 0, 0], "nef_compression": [1]}, + {"model": "NIKON D200", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/824/nice/Nikon%20-%20D200%20-%2012bit%2012bit%20uncompressed%20%283:2%29.nef", "sha256": "d468895b0b30a60d05a7f6468d69f2f15830dd49b2d82698b34245072a22e747", "raw": {"w": 3904, "h": 2616, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 3904, 2616, 3904, 2616, 0, 0], "nef_compression": null}, + {"model": "NIKON D7200", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/832/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "5dea8ff6f95f372e4eb0852f75e728f182d7aa4057b2b76631f0fd3066c6a4b5", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7200", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/833/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "60f6a525322124949fd3b99e884b78b1ad95d80d8a9e03b897b881a6f50ae2ff", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7200", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/834/nice/Nikon%20-%20D7200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8dc6c5c796238056309dbb9c158917e56bbd7033e260e5b0d3736ad71c88cc60", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7200", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/836/nice/Nikon%20-%20D7200%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "9360c9980a20d56a43727493e5d87113b28c3ef205deb5516d5c2508f8aa5db8", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D610", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/888/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "11f0a71bb4141a8ed44c7e1c64f9a12571f5f67a11571f2bde8516aa95a7bea3", "raw": {"w": 6080, "h": 4028, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D610", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/889/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "a6de67362a7ae053f36115e639a806f3f5a1c10b89f0a4ed21e95cdc27b7b8cc", "raw": {"w": 6080, "h": 4028, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D610", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/890/nice/Nikon%20-%20D610%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ec3676c6a17d23d1d2e29519b9bd0bd32dc54f560784d61ef8bc9aaf0a5436c0", "raw": {"w": 6080, "h": 4028, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D610", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/891/nice/Nikon%20-%20D610%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "947ab8a6612a9fd3eaeed193030cbe5fc650d2daa2dff7b5e11051dc6b953366", "raw": {"w": 6080, "h": 4028, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D750", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/895/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "e4a3f85e014275fc0a47600a373af95ec850872a5f6cf87649bcaa42b77ef2e4", "raw": {"w": 6032, "h": 4032, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 6032, 4032, 6032, 4032, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D750", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/896/nice/Nikon%20-%20D750%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3389da2bd0997be2f8e1408d8d0fba09f5062de05c4017a0bbf2849d8f0cd2a1", "raw": {"w": 6032, "h": 4032, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 6032, 4032, 6032, 4032, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D750", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/897/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "baae8dfc05a81a4a4034831a49ed99d129e797858ad876d31173a46d33b7d3ea", "raw": {"w": 6032, "h": 4032, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 6032, 4032, 6032, 4032, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D750", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/898/nice/Nikon%20-%20D750%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "f90deb2819863f5a6ca9913c255fdd83a94963f4284eb88df9d389da7a6c7665", "raw": {"w": 6032, "h": 4032, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 6032, 4032, 6032, 4032, 0, 0], "nef_compression": [3]}, + {"model": "COOLPIX P330", "mode": "12bit 12bit compressed ((7)) (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/912/nice/Nikon%20-%20Coolpix%20P330%20-%2012bit%2012bit%20compressed%20%28%287%29%29%20%284:3%29.NRW", "sha256": "bff89a1a985c834f894b6bb2c7989ed265ab96c5f5a2e981a713d6fe836df3f0", "raw": {"w": 4032, "h": 3024, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [7]}, + {"model": "NIKON D600", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/916/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "a70c3e54501efa7e4391ec4cf5705912a10c356a029e984ee13be5f68bc7482d", "raw": {"w": 6080, "h": 4028, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D600", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/917/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6dd9470e0ab44ecf6d1a5341a5314edfbeaf091ecda414f6881c37ea5b0179c0", "raw": {"w": 6080, "h": 4028, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D600", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/918/nice/Nikon%20-%20D600%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c434e4d3f225a09d8d9d05f8709a7ac537fedda295062f9eb392092038305038", "raw": {"w": 6080, "h": 4028, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D600", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/919/nice/Nikon%20-%20D600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "d96ff1bfa0bccaf5e900c7e1cdd0ebb87c4b15dda959e1f9a40075c1ba7fbcfa", "raw": {"w": 6080, "h": 4028, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6080, 4028, 6080, 4028, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 J2", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/923/nice/Nikon%20-%201%20J2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "81051ee36135883acea55bd1e609d6d31212c0bf03640b7ae9fe177a1ca51219", "raw": {"w": 3904, "h": 2606, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2606, 3904, 2606, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D300", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/924/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "8b989f53c96995f7014ddd16124530cf6d8f3341ea9570d6312d9da1eb0a117a", "raw": {"w": 4352, "h": 2868, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D300", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/925/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0d47cd75b79e172124d0a3c51fb36fdcc21a33157b30bac38028e55465bf9b6d", "raw": {"w": 4352, "h": 2868, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D300", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/926/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "0261523d0ab4694df0219404eb08af5db04697a05e85718e7f7bd42d283205f4", "raw": {"w": 4352, "h": 2868, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": null}, + {"model": "NIKON D300", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/927/nice/Nikon%20-%20D300%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "7e6d38ddebe82784dc8c84cdf02dce7bcfe74b71b0dec91b28551936102c39b9", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D300", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/928/nice/Nikon%20-%20D300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "f1162624678ae9b0e0e835fcbe77d97584fc88ceefd996e38ee126942b54bc8c", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D300", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/929/nice/Nikon%20-%20D300%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "1e0ff56bba6561182c80cff3cca5b912746990523ea777123b4860c42a7f080a", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": null}, + {"model": "NIKON D7000", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/958/nice/Nikon%20-%20D7000%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "639945753240005d80488c0462c01e73a7ab82c96d3d1cdb6661f02e1b3c1319", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7000", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/959/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "65d8d063de99befa2f8463c3b1114cb5bf71e35aed6466d36fca39e83d0353bc", "raw": {"w": 4992, "h": 3280, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7000", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/960/nice/Nikon%20-%20D7000%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "b8953c3e0ff4e4ca7668f4ea8eef0532b04503162d53e31bfcd7946c05a2cbfe", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7000", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "986db2bf9ed08cf095bb94cfbaf02f0e89d123edad28b63c05133e7815749486", "raw": {"w": 4992, "h": 3280, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 J1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1017/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "5965935729931b72ca8398041f8fc15cb0a226004db941d1f0626ccc2c5bb5f3", "raw": {"w": 3904, "h": 2606, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2606, 3904, 2606, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D70s", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692", "raw": {"w": 3040, "h": 2014, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [1]}, + {"model": "NIKON 1 AW1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1171/nice/Nikon%20-%201%20AW1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "519d354ec40907f1cd30329fc1a641ddec5b46e08e88883f3cf2fddc4ddd9974", "raw": {"w": 4620, "h": 3084, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4620, 3084, 4620, 3084, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D50", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1282/nice/Nikon%20-%20D50%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "9f94c27672a63f946c92d43910ef0c266dffaa14dae0007ae00ac72d2c684935", "raw": {"w": 3040, "h": 2014, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [1]}, + {"model": "NIKON D800E", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1303/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "4559b9614ab6d558192d9d30e1db4a4ec44752553e45b2bd26c1569c1820441a", "raw": {"w": 7424, "h": 4924, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": null}, + {"model": "NIKON D90", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1325/nice/Nikon%20-%20D90%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "9eb3daa4e3d1c1614bccc4c6d136c4166960f936f17c437cc4d11a5786b2df06", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5000", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1341/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "5f8e8005be87366bbf7746d35c3c88f82a12a1dd18206c939d8f1254c5922360", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 J5", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1342/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "56efc4f426fa489383a967102c6d30fa9d6b198ca0484c4725c91f0b0d47c063", "raw": {"w": 5584, "h": 3726, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 5584, 3726, 5584, 3726, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Df", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1385/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "38ea7aba6b7e0cdbd338fe6703d3c98291f4a9a28b0d1a81bfb1f77b21712c63", "raw": {"w": 4992, "h": 3292, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": null}, + {"model": "NIKON Df", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1386/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "902483437706df59bed669ebb4eb7d9ecaf95b23a70d6c79e319316ce50ee884", "raw": {"w": 4992, "h": 3292, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Df", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1387/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c235099efa19a02f50174dfe473c5d4da1e190d44d9153604486a79cbcf120df", "raw": {"w": 4992, "h": 3292, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Df", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1388/nice/Nikon%20-%20Df%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "452ed50be5e212cf7832cd5fc992d920206cebde8864c073988aeaf5a9d07708", "raw": {"w": 4992, "h": 3292, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Df", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1389/nice/Nikon%20-%20Df%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "b3240362af7342d23afe69e5947b34c6ba7cfc46d49aae767c286f493ecc24d0", "raw": {"w": 4992, "h": 3292, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": null}, + {"model": "NIKON Df", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1390/nice/Nikon%20-%20Df%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "65b940bc1436e5df406bdfd83dcfeffcbed9da5b96db67fa03036ec840cecbf9", "raw": {"w": 4992, "h": 3292, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D5600", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1415/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "82e15f02c4037820e80f14bacfacdc52ea29721b3c70d2cf08b237b50ce8db05", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5600", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1416/nice/Nikon%20-%20D5600%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0917e0ffdd7aa648ed35d99d1858b5a4ab81f62d5a0418d4bcfe3799fc59a7a0", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "COOLPIX P7800", "mode": "12bit 12bit compressed ((7)) (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1425/nice/Nikon%20-%20Coolpix%20P7800%20-%2012bit%2012bit%20compressed%20%28%287%29%29%20%284:3%29.NRW", "sha256": "27ac09537dd0f75523864dcf221278e2db354d490534bc13b6eabdc031060e51", "raw": {"w": 4032, "h": 3024, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [7]}, + {"model": "NIKON D5500", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1469/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "96d5b85dae24e190691c504781d8e7ee58cb9b63ea715760de9af3d345dc4c13", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5500", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1470/nice/Nikon%20-%20D5500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6e0681b54ef808913a31530e0e320751dbb910f53fbca1b4bd65329f8f129b01", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7500", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1531/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "ad53ee232836ba707d3d6820b5f9c015f129599aef7737adcec4a07b803b8891", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7500", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "1f4e58a961d3d317fa39a7e1c8948b825651e968c8cd06e49e4239f612faccad", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7500", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1533/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "02f9bdceb66f776233225231a3be8b11ea66f0a8b4761a41c4a96cca7802c795", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7500", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "430b4f1be4e53a011861b63294ad19fdc0353ec3ea62104f77d4c193e3dd3fc8", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D3400", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1561/nice/Nikon%20-%20D3400%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "eced190786231f0081261b6c6b425fe23e42a53ade21dec3def5ae8551be71a5", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6000, 4000], "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5100", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1596/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "3686f6063ca1c05273c7c1021d47ed57ef50194656e3cfe30d9cae70781b98ef", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5100", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1597/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", "sha256": "3ce328830942381648be26b372a54b317fdb27b63c1aa9c2ee1fce076a2e1b4f", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D5100", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1598/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.nef", "sha256": "2fb2f1b87a3b89de5ba9e56527f21a912ca624f2fa2a594cef108ee8d7180412", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": null}, + {"model": "COOLPIX B700", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "a175d5892304e79282add07eacf4bdc498ca3f050af48cf989c080355b4735ec", "raw": {"w": 5200, "h": 3902, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D5", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1636/nice/Nikon%20-%20D5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "13191560a6a9d9ed4156ec5d20863277c9618916aaba934758c5ae2be6654f6a", "raw": {"w": 5584, "h": 3728, "bits": 14, "compression": 1}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON D5200", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1691/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6c49ca47d189f9d6766f94be7c9d7d779ce45c79c0c6e48a0eba7d92f320fe0e", "raw": {"w": 6036, "h": 4020, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6036, 4020, 6036, 4020, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 V2", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1693/nice/Nikon%20-%201%20V2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3d29dfa6397b0aa4e72969bb5b85d040e8bebf2e02f61f6364f580ce39f30bf2", "raw": {"w": 4620, "h": 3084, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4620, 3084, 4620, 3084, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3300", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1734/nice/Nikon%20-%20D3300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "97dfc95aecc00383d6da6197cb759bacb1e43c86071b4ca2d94748e498620f2f", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D1X", "mode": "12bit 12bit compressed (3.0422960725076)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1785/nice/Nikon%20-%20D1X%20-%2012bit%2012bit%20compressed%20%283.0422960725076%29.NEF", "sha256": "ff80b83537a909e6adabeb091a12e4d5c83993e011672705366e7de39601695b", "raw": {"w": 4028, "h": 1324, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D1X", "mode": "12bit 12bit uncompressed (3.0422960725076)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1786/nice/Nikon%20-%20D1X%20-%2012bit%2012bit%20uncompressed%20%283.0422960725076%29.NEF", "sha256": "9fabb93479b17c6de96ec5e5247de3b0a0a6e90101a8c8fc03356937af5d7014", "raw": {"w": 4028, "h": 1324, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON 1 J3", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1814/nice/Nikon%20-%201%20J3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "996d65705bf1ac9f83f055817ea92618ea44c822109196d7d97a803a0268b576", "raw": {"w": 4620, "h": 3084, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4620, 3084, 4620, 3084, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D850", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1835/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "31b7fe298a5c91694c6ca8b3284a0a016e572a02c3964b502d4732e8e9283da8", "raw": {"w": 6216, "h": 4136, "bits": 12, "compression": 34713}, "crop_area": [12, 4, 6192, 4128], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D850", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1836/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "1aba8fe0a0d5f3a990da08bea906f3bd8aaff438982fa37bf8232d1a47a99300", "raw": {"w": 7104, "h": 4728, "bits": 12, "compression": 34713}, "crop_area": [15, 5, 7074, 4716], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D850", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1837/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "61dff3d1b520b46d81bc948b2d1614e56f75142a4aea0aa1d8c28d35ce118a8f", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D850", "mode": "12bit 12bit compressed ((6)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1838/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "fb438957983d72567a740bcb93365feb1bbcd75db629eb9626f31fa548a24484", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [6]}, + {"model": "NIKON D850", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1839/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "ba11bc767b4f67144a8b7b5d27fa0fe2be8e69e5fcc5e0161f80623794f294f4", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D850", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1840/nice/Nikon%20-%20D850%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "0c9438258ddae5260b9f5a1aace6658a723f1cb1acace3a6cb507116dc6da4f2", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D850", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1841/nice/Nikon%20-%20D850%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "e54b5d4f0d5c721a90309741c60754e687750f1b2d366ac2587d870eef121126", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D850", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1842/nice/Nikon%20-%20D850%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "ce1fb927aca4f0ac3a1fa5caded0d7859560988e500f71177287310fd1a3d845", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 1}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON D7100", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1855/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "b241cb95b1008f25b8ed7d1443354e8cc7dee9c4d4e1edd0a5856d92063a48d0", "raw": {"w": 6036, "h": 4020, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6036, 4020, 6036, 4020, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7100", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1856/nice/Nikon%20-%20D7100%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "9c6ec61079c3511fed302e9ccf1da31439fb7d4402b555ec6f14404cb67db742", "raw": {"w": 6036, "h": 4020, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6036, 4020, 6036, 4020, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7100", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1857/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "b9e1ac6c38eb06a9d296bbafe81ca403a7f1520d61735603cb238b9ae6a35fce", "raw": {"w": 6036, "h": 4020, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6036, 4020, 6036, 4020, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D7100", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1858/nice/Nikon%20-%20D7100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "48b86be680b631291e193660daeece2ca1515583c9780ab9fcff2ea80628d3d1", "raw": {"w": 6036, "h": 4020, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6036, 4020, 6036, 4020, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3400", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1876/nice/Nikon%20-%20D3400%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "69d1f76c3a206f846a3e3957663f0f3285ac4d1c74494443314019a7e889ca62", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6000, 4000], "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 J5", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1883/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "92b7e0a7637e5e498011372786a988aea3b431b0427ee10f9021469ecd6408e3", "raw": {"w": 5584, "h": 3726, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 5584, 3726, 5584, 3726, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D4", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1974/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "23aa2dd3c409b3cbbfeea3e3f7b6752180efa15430d6e059e87404e969875cd7", "raw": {"w": 4992, "h": 3292, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": null}, + {"model": "NIKON D4", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1975/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "aefb8b307361b5d5ed27fadb8a3a810d8c174f258313c77c57187eb829aa30cb", "raw": {"w": 4992, "h": 3292, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D4", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1976/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "35ed038a1a55988b62fa29eb27f9c1f6088b79de10ff2f447861b1accdb0a317", "raw": {"w": 4992, "h": 3292, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D4", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1977/nice/Nikon%20-%20D4%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "90a8fe82c2ca66cb925590370187a16773b455fb655671967443fcc423bf0351", "raw": {"w": 4992, "h": 3292, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": null}, + {"model": "NIKON D4", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1978/nice/Nikon%20-%20D4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "1148bcbf80106b21f7788d0b926751bf5a14ebb5b8cf45ff67bf5bfe6b4ac84f", "raw": {"w": 4992, "h": 3292, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D4", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1979/nice/Nikon%20-%20D4%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "e549dfba6667148c726100b5e1871e449657284aa5023b2a5f51a35394750480", "raw": {"w": 4992, "h": 3292, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3292, 4992, 3292, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D80", "mode": "16bit (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/1988/nice/Nikon%20-%20D80%20-%2016bit%20%283:2%29.dng", "sha256": "5095ca264e763624cd3aa62940286dd1bcff4504eefa1ce61cbb72f50f8426bb", "raw": {"w": 3900, "h": 2611, "bits": 16, "compression": 7}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D810", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2029/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "40a9df99865ad25a68b1131014cf954fb72da8bc32991b6a5e19d14937e21bc9", "raw": {"w": 7380, "h": 4928, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D810", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2031/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "ea612ad3a01aaa76f6e9064eadb5c1746771875bd2ec81984667b28359b5c78d", "raw": {"w": 7380, "h": 4928, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D810", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2032/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "5d8a476f33d350cc90fcb3e3df7c012ddea8e3ab6a81a89c5368fa632a3d04c9", "raw": {"w": 7380, "h": 4928, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": null}, + {"model": "NIKON D810", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2033/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ea8baa753dc1d77fdabb3f35048f71aa3c99d5b675f50f6a8d301ab287f5fd68", "raw": {"w": 7380, "h": 4928, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D810", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2034/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6aa49c8cdfcde936b10a342d4901391fe4dcdb54cfb6b9b5d02ce46c65ac5ebf", "raw": {"w": 7380, "h": 4928, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D810", "mode": "12bit 12bit compressed ((6)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2035/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "19519ee6c6b713a1afe45daf5ce9be4054aa6fae3b5db9df915b4b30be63126b", "raw": {"w": 7380, "h": 4928, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": [6]}, + {"model": "NIKON D810", "mode": "12bit 12bit compressed ((8)), small raw (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2036/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28%288%29%29%2C%20small%20raw%20%283:2%29.NEF", "sha256": "0cb5b8193ddc1de57fcc5f2091d0f7e8476f3b3af4beeb1ccc73d45f1c7a6bed", "raw": {"w": 3680, "h": 2456, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 7384, 4928, 7380, 4928, 0, 0], "nef_compression": [8]}, + {"model": "NIKON D70", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2060/nice/Nikon%20-%20D70%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "dd6405aeb33b0cd5bf66c98ba98ccbb478a765450cfd130810e470dab8d1f4b4", "raw": {"w": 3040, "h": 2014, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [1]}, + {"model": "E8400", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2082/nice/Nikon%20-%20E8400%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "84ca3fd7f6e684f1469bd02f1d715b4ccb0716ce0bd42200aeda32ef5b9c8562", "raw": {"w": 3280, "h": 2454, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D80", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2104/nice/Nikon%20-%20D80%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "745cb067eea6dbb92d3088ee66e8a28bf4912848b43adb6ee30bec3ca0b1d066", "raw": {"w": 3904, "h": 2616, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2616, 3904, 2616, 0, 0], "nef_compression": [1]}, + {"model": "NIKON D5500", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2127/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "5538d77857efda5fb450d43f4a96a4786790a3721c536f1c4d81c0acaf5cf48a", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3000", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2161/nice/Nikon%20-%20D3000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "a07ee8a0b1ff7a1ad06085306ef8f9a197d95fec639994467317c56108eeb435", "raw": {"w": 3904, "h": 2616, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2616, 3904, 2616, 0, 0], "nef_compression": [1]}, + {"model": "NIKON 1 J1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2177/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "fd60658d99da9d4048c462bba266c657b931b317820f630bdc143c6d6ee1faab", "raw": {"w": 3904, "h": 2606, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2606, 3904, 2606, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 V1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2210/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0dc76bc274f9cb776eb930683a4e53a3ebede1719abafd1cba4b0c800f13f7e4", "raw": null, "crop_area": null, "crop_hi_speed": [0, 3904, 2606, 3904, 2606, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 V1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2217/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8315c5c5c786703d057ed44339cdff7e82844bb8b21c254ac80942f65d8e6b77", "raw": {"w": 3904, "h": 2606, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2606, 3904, 2606, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2218/nice/Nikon%20-%20D5%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "febde1abbb52dcd207f10d44412742af491d63ef96a9cbd8a491a33b98944eeb", "raw": {"w": 5584, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D4S", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2220/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "49067c81374549465f3c621acd249bf05dbdef4e44b9201fef1fb11c93572983", "raw": {"w": 4936, "h": 3288, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D4S", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2221/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3120d2b3680ec2b9cd017553dd32be2081e62ca220b6e2e7e8988016662042d4", "raw": {"w": 4936, "h": 3288, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D4S", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2222/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "1613a1622ac7fcc67ece0ef65b386d03a066ace2ab15933dea79efa3fa371ae9", "raw": {"w": 4936, "h": 3288, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": [6]}, + {"model": "NIKON D4S", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2224/nice/Nikon%20-%20D4S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "d31b3213a1aa4dd1bbbd59015bcc5459b7b6e968a96a5eff55099ae914c2ee8e", "raw": {"w": 4936, "h": 3288, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": null}, + {"model": "NIKON D4S", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2225/nice/Nikon%20-%20D4S%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8726fceea6aaae58b5341e4a78316ee6db463c5a07fa2af1c41b9ed3305fe653", "raw": {"w": 4936, "h": 3288, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D4S", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2226/nice/Nikon%20-%20D4S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "906a6282969659e6dee7a8d37dfff820ab1556be541c7b53aa799b6eec007286", "raw": {"w": 4936, "h": 3288, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D4S", "mode": "12bit 12bit compressed ((8)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2227/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28%288%29%29%20%283:2%29.NEF", "sha256": "c8256566c01eed83b8f9c4eeec6276a94031d19f607afc511a90cb0c2fa76f9c", "raw": {"w": 2464, "h": 1640, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [11, 4936, 3288, 4936, 3288, 0, 0], "nef_compression": [8]}, + {"model": "COOLPIX A", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2238/nice/Nikon%20-%20Coolpix%20A%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "c5ca0333a7cda8a2fc33fb2157d18dfa89f1844548dc49c2fd2e67f502f72e25", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D700", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "00c3314f2ccf92aff17644943e6149e8793b517c24bb2c38a15ec41f3af109cb", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D700", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2250/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0f279ad4a3e92859109ce78440e562a8d25b91b8704a79e1245c8e98d9b27cf1", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D700", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2251/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "44a45eb60cde6904894b0191590256f90692474ee90de451822c29d46a927667", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": null}, + {"model": "NIKON D700", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2254/nice/Nikon%20-%20D700%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "6735ee78c8e6718f6333127f6bbd73bbd8ef745b360e3e2033defee4a8d7c2a0", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D700", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2255/nice/Nikon%20-%20D700%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "bfd5e164c3fa10ffdc38237000ccb63f52a53bcd080ceb48de1443534dd17036", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D700", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2256/nice/Nikon%20-%20D700%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "0831d8103a2f2275e479095a75a1217b49e1d415ced439f43c331eb3927f2675", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": null}, + {"model": "NIKON D5600", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2260/nice/Nikon%20-%20D5600%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "4d9b49e6eb8a9e055b10be67f580a9c42538132e3a6e808d2fd08826c644827e", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5600", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2261/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "7ea7d00ec7c4de55398ad258c6dd9952237ab8110ea0bfc7b29b4e17c1090719", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D500", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2504/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "33c96a2ba7167f780928767c0545719737d601883e5f98e5edba4f71bdc99d83", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D500", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2506/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "9e05c61fd5b02dc11233e45b4f1e3f6d60ed2ccd0b72f9e7cc487b76f3a9ba72", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D500", "mode": "12bit 12bit compressed ((6)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2508/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "0755e3703df5a5f9c7f132f49a40b94e09c71a62a8b8faea50e83175cbd84228", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [6]}, + {"model": "NIKON D500", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2510/nice/Nikon%20-%20D500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "658060f858cc070076a4a40eec82c4c63bc76515a463ebbba714e709a9de8983", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D500", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2512/nice/Nikon%20-%20D500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "90995baaad6eb2f8c0257b8a38ff6c25557fa8d411f29b84630759d9563e35fd", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D500", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2514/nice/Nikon%20-%20D500%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "9498d65c876d3d548c2361191ec0b64bc999abd78cea67f28e2a7c7d01890676", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 1}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON D500", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2516/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "4c9ca014c09bad1c945a2ca5d0532207a99b274419117c52ce6b700246d6bab6", "raw": {"w": 4800, "h": 3192, "bits": 12, "compression": 34713}, "crop_area": [15, 6, 4770, 3180], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D500", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2518/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "290ea97c0aa0f490cc1eca0bbfb68048f20219bd921682907d04a8bf8a6bcc70", "raw": {"w": 4200, "h": 2792, "bits": 12, "compression": 34713}, "crop_area": [12, 4, 4176, 2784], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D5200", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2540/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "daf6c47d846ac9d96df26c75ae01eadad03c9056b27da08b035204b5de1756d6", "raw": {"w": 6036, "h": 4020, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6036, 4020, 6036, 4020, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D800E", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2552/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ec31bc8517cbb1247867aebd7b604c29de015f09bb1d2610b68a8fdb0d219dd7", "raw": {"w": 7424, "h": 4924, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 7424, 4924, 7424, 4924, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D7000", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2578/nice/Nikon%20-%20D7000%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "6d5aab3dbab2d77deab82ea5dfc72608e60a97315d869d6a55dac596e8691616", "raw": {"w": 4992, "h": 3280, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4992, 3280, 4992, 3280, 0, 0], "nef_compression": [4]}, + {"model": "E8800", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2769/nice/Nikon%20-%20Coolpix%20E8800%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "4a9bc7214e5eee808f46bc64cfa7d50d830e094f92253b52bb30a6abee684a78", "raw": {"w": 3280, "h": 2454, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2788/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "ef046d37ffb11807648dd0b55589c02825f98c2c99416f4a7a74ae965a8eeb72", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2791/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "f15d7a8abd777678e1a148e4ad4a177e9cd51cdd82184219eb0640f466b265d4", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2793/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "1172f414821cec2fd1e51e0ba9fde48476f5d40ee85b9268d54aedcae6d9ee70", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2794/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "2f931c441f8e31102c5d5b8a417a0e925e63257cbf1d0ca199164c7a96d6cd54", "raw": {"w": 7104, "h": 4730, "bits": 12, "compression": 34713}, "crop_area": [15, 7, 7074, 4716], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 7", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2795/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "85e08750866f339f9e810b9004e542b0a623edf83df911459c7ae0057fe1a1dd", "raw": {"w": 6216, "h": 4140, "bits": 12, "compression": 34713}, "crop_area": [12, 6, 6192, 4128], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON 1 V3", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2802/nice/Nikon%20-%201%20V3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3f5376481f9ae59e9712b537dcc4eaffa72e311a8768c5396414506ae2eb1d69", "raw": {"w": 5248, "h": 3504, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 5248, 3504, 5248, 3504, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5000", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2825/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "b6fb03eaded625af8ed024da232fdd7d433c06a457bd9a7a27a6f9ca2c42a228", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D2Hs", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2852/nice/Nikon%20-%20D2Hs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "9ea1c3d5d6c8a11f33239079559f1f0c86bbdb9440a2c7d2578368647abf7ea4", "raw": {"w": 2496, "h": 1648, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D2Xs", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2871/nice/Nikon%20-%20D2Xs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "2204bc2e2178bf594e17886ca92a50a94350403a110846ca6e83bdc5f3352706", "raw": {"w": 4320, "h": 2868, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4320, 2868, 4320, 2868, 0, 0], "nef_compression": null}, + {"model": "NIKON D3500", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2902/nice/Nikon%20-%20D3500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "b53395f74674588a63f6b667325c5a2b400f26cba72ff402a65ddd02c5f841d5", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6000, 4000], "crop_hi_speed": [12, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON 1 J1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2956/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "1d3b7c26b012cf585d46a28c9c83876d687a6e215b7d0bdf8d062c82ab626401", "raw": {"w": 3904, "h": 2606, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2606, 3904, 2606, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D100", "mode": "12bit 12bit compressed (Uncompressed) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/2996/nice/Nikon%20-%20D100%20-%2012bit%2012bit%20compressed%20%28Uncompressed%29%20%283:2%29.NEF", "sha256": "1889abfd1ed64048c90b0c3384e1e0be9bbb021692249427e90848d5d1e9c8f0", "raw": {"w": 3034, "h": 2024, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [2]}, + {"model": "NIKON D300S", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3049/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "de950a2263c87f085ee611a21e69fc4e0ecb6e417fff61ff6a23ea3121e21a81", "raw": {"w": 4352, "h": 2868, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D300S", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3052/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "34749388e846408eb1633991ca774b341e5d7d9752df8c5538eab281cecf4c83", "raw": {"w": 4352, "h": 2868, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D300S", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3053/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "a7dea59999273727cb73d3a7854aa01162c30f06d7fbb5c826dd61a54ba07934", "raw": {"w": 4352, "h": 2868, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": null}, + {"model": "NIKON D300S", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3054/nice/Nikon%20-%20D300S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c6723c94d8f3054013b2e927de7b82ae60df16518e737a9f10f73596285b37af", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D300S", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3055/nice/Nikon%20-%20D300S%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "bb9834d03bc158731aefebc5bef11ab1607c9c8cc12f8ae964e6c1b031696d05", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D300S", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3056/nice/Nikon%20-%20D300S%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "039afbf0a020ace5e654c42d3a0a9e2faf7244cb45f5fd168403028318294662", "raw": {"w": 4352, "h": 2868, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4352, 2868, 4352, 2868, 0, 0], "nef_compression": null}, + {"model": "COOLPIX P1000", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3381/nice/Nikon%20-%20COOLPIX%20P1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "1d9470ee083f914e51aa0be782161b432f27f87dbef551a59ec435f5dbc289dd", "raw": {"w": 4624, "h": 3470, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D3", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3516/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3517/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "bd1efee38aab8daf79c199e076785125ece01eb8f5ef6741121b0f313ee4ac04", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": null}, + {"model": "NIKON D3", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3518/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "6c4ca5ac7525c01c972f517503c47389f2f244c1fab2ad4ea9d10c63d36c42e6", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D3", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3519/nice/Nikon%20-%20D3%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0626c09a58ca12ee4c12f919772abd4fd053c7d88c2f2b25e8d9be7c26cc7fd5", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3520/nice/Nikon%20-%20D3%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c280a2afc31fb15f8c0bc8d8cf6fb13b1972f7d913fa673c5db9d1c1f50beddd", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D3", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3521/nice/Nikon%20-%20D3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "12c75d4ee829261d15031e02d17a8461f05e72575be76b3e19d1fbdba549de54", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3580/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "b4cdb8ecf8a971ec023dd242251c6cbfb19966914e37c2311dc9813a7ab5e15f", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "14bit 14bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", "sha256": "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "14bit 14bit lossless compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", "sha256": "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3583/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "13c1ec5c2d020b3038d9cb660bc0e0b5a52cb9fe12561dec9ba0e9919e9cbee2", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "12bit 12bit lossless compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3585/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20lossless%20compressed%20%283:2%29.NEF", "sha256": "59615b65f8a7edc92a845b2a9c8ef313d6e7d7a5d842f94e6adf786da045a6db", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3586/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "9d81cd5f5456b0fe2bd841b7c5e668629e51803064913571566a07c16e2bb437", "raw": {"w": 5196, "h": 3462, "bits": 12, "compression": 34713}, "crop_area": [10, 7, 5176, 3448], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 6", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3587/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "f0ab08da469ee205f2bc08efd9005778c64cdcb064893cee03768e32e5da1a36", "raw": {"w": 4548, "h": 3030, "bits": 12, "compression": 34713}, "crop_area": [10, 6, 4528, 3018], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON D40", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3618/nice/Nikon%20-%20D40%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "44e88bc77b7a531b22647bcd07b9393c4568062e8f0906d3bbdecb42fbe29e03", "raw": {"w": 3040, "h": 2014, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3040, 2014, 3040, 2014, 0, 0], "nef_compression": [1]}, + {"model": "NIKON Z 50", "mode": "14bit 14bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3647/nice/Nikon%20-%20Z%2050%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", "sha256": "5e93735923229117abee2779d81f114f180f361e8ae553c9dd9bdaaedb0d9e02", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 50", "mode": "12bit 12bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3648/nice/Nikon%20-%20Z%2050%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", "sha256": "b25081cf9adcd51fec020b6d227ff094bc0327cbd26d76bad7e1dee3fb4c034a", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON D60", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3698/nice/Nikon%20-%20D60%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "780ce4867e997143acbd1734a889575a5eaeeb40c6afb33b6006c266831caaa6", "raw": {"w": 3904, "h": 2616, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2616, 3904, 2616, 0, 0], "nef_compression": [1]}, + {"model": "NIKON D3X", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3708/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "48dc0339ef86541aabcc6bb68a6452f603e23a7d7a5597dad0d267f69e3e79d4", "raw": {"w": 6080, "h": 4044, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 6080, 4044, 6080, 4044, 0, 0], "nef_compression": null}, + {"model": "LS-5000", "mode": "uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3721/nice/Nikon%20-%20LS-5000%20-%20uncompressed%20%283:2%29.dng", "sha256": "be615ecfe290e9f843979369edb4402a5613406845972386675d47812b050ca9", "raw": null, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "COOLPIX P950", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3806/nice/Nikon%20-%20COOLPIX%20P950%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "fc6136b9e5a7e133d7dc30fe65baa04c09ff8c18ca3c7ad82b4ffdbb71ce5bef", "raw": {"w": 4624, "h": 3470, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D780", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3825/nice/Nikon%20-%20D780%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "3178d0656e3b5fbe90176e38a1cd608d0b69292299445932dddc9a0b2d196b36", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D780", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3827/nice/Nikon%20-%20D780%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "986488947e552af25c6824667aed5a1622140a581dc57efd4b00f0b55b5e4ae0", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D780", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3828/nice/Nikon%20-%20D780%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "8b786e0ecfb4cca8d94af9b3a114e9e4cab429be251efb6bb06a11a98116d49b", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D780", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/3829/nice/Nikon%20-%20D780%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6ee9e13f6f2532a5ba66f43b75885cf8a3f27f252d39969545f0471eff7d3f6f", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 5", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4136/nice/Nikon%20-%20Z%205%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "9870248c45532080c2459f3b52cdcc4a07bc8846ad50cbb81c7c1c6dc5e36d91", "raw": {"w": 6040, "h": 4032, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 6016, 4016], "crop_hi_speed": [11, 6040, 4032, 6040, 4032, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 5", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4137/nice/Nikon%20-%20Z%205%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ac11b88c978958222af79946c01071f270e0d660ae8fb5e1f15e4c965a25aa54", "raw": {"w": 6040, "h": 4032, "bits": 12, "compression": 34713}, "crop_area": [12, 8, 6016, 4016], "crop_hi_speed": [11, 6040, 4032, 6040, 4032, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 5", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4138/nice/Nikon%20-%20Z%205%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "d893b6f7e7d2c0aa7d4b016a559bf0d9af440c9ec7486119085c2b2368c7a2a4", "raw": {"w": 6040, "h": 4032, "bits": 12, "compression": 34713}, "crop_area": [12, 8, 6016, 4016], "crop_hi_speed": [11, 6040, 4032, 6040, 4032, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 5", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4139/nice/Nikon%20-%20Z%205%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "e675138e7185b026000c97b654b80ac230c5796f48a9180633672ddacee01bed", "raw": {"w": 6040, "h": 4032, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 6016, 4016], "crop_hi_speed": [11, 6040, 4032, 6040, 4032, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 6_2", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 6_2", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4161/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "c79fa1f8986ba059771f125af283dc3b0ed8bd27c46db9e4f83d2a0e3fb34059", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 6_2", "mode": "14bit 14bit compressed ((10)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4162/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28%2810%29%29%20%283:2%29.NEF", "sha256": "4c75e683bd4e751615c93842f519579cde8e0e7fbee3cc58ba7f6a1a23f060dc", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [10]}, + {"model": "NIKON Z 6_2", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4163/nice/Nikon%20-%20Z%206_2%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "04ad59c3b97609e72a54ad277ab433444b12392b032207ca7244571f60ab2514", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 6_2", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4164/nice/Nikon%20-%20Z%206_2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "85e9ed4bce4feb439e9e05c3845d557b76d10080c239358821a00759afd9314b", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 6_2", "mode": "12bit 12bit compressed ((6)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4165/nice/Nikon%20-%20Z%206_2%20-%2012bit%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "b0c7a24a4efd41a45ce03bac7eae3056bc5f8fc9dfbd9bdb08b569ab42734205", "raw": {"w": 6064, "h": 4040, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 6048, 4024], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": [6]}, + {"model": "NIKON Z 7_2", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4213/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "dc83d3e49db54ca9c3a1e41dc847afd213c822d482c40158e42d01ee8968f33b", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 7_2", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4214/nice/Nikon%20-%20Z%207_2%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "f407fbfee6baef21c843b0ecdbfa30219ef6a031c853d9d0ce07391b2e4bf619", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 7_2", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4215/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "60de3df0af395adac77c20db7f2714760450eaff99f8d0a76834c447bedf7381", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 7_2", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4216/nice/Nikon%20-%20Z%207_2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "5b8322b8bb2bd72a5a824e558d5ee9cdc5790085baaf3e6c14825c3e3b919286", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 7_2", "mode": "14bit 14bit Uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4217/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20Uncompressed%20%283:2%29.NEF", "sha256": "33a6a4838bc9d80fa1cb2e7c2011e52ca9f4965a26e67b604412c69928d37c45", "raw": {"w": 8288, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [10]}, + {"model": "NIKON Z 7_2", "mode": "12bit 12bit Uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4218/nice/Nikon%20-%20Z%207_2%20-%2012bit%2012bit%20Uncompressed%20%283:2%29.NEF", "sha256": "b2166f9ac7a9f348da74f51055b8e1a03a3bcac7bff8d0863a74618c1b402765", "raw": {"w": 8288, "h": 5520, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 8256, 5504], "crop_hi_speed": [11, 8288, 5520, 8288, 5520, 0, 0], "nef_compression": [6]}, + {"model": "NIKON D3100", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4229/nice/Nikon%20-%20D3100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "e8113b6e7285155f9f2b88f81566dab63a59cf5559d6915113b74e8c3a8680f6", "raw": {"w": 4672, "h": 3084, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4672, 3084, 4672, 3084, 0, 0], "nef_compression": [4]}, + {"model": "Nikon COOLSCAN V ED", "mode": "uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4269/nice/Nikon%20-%20Nikon%20COOLSCAN%20V%20ED%20-%20uncompressed%20%283:2%29.nef", "sha256": "ed8978b7a0a3270fbaad4c23863d081538b9dbdfbeb25750d5849872b396e7d2", "raw": null, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "Nikon COOLSCAN IV ED", "mode": "uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4282/nice/Nikon%20-%20Nikon%20COOLSCAN%20IV%20ED%20-%20uncompressed%20%284:3%29.nef", "sha256": "268d9a98920a9f3ea3ebf6a2b9ff68b956df74ac0e46b980bee69e7ef3ebc172", "raw": null, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "COOLPIX A1000", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4337/nice/Nikon%20-%20Coolpix%20A1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "9b4edd03bbb17d66eacc2a72b30f044969e67666446f3f1ed25e88bb947d4f8a", "raw": {"w": 4608, "h": 3456, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "E5700", "mode": "12bit 12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4456/nice/Nikon%20-%20E5700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "6efeb5049cb4957f0586c47478caed2c5cfdce23eed6b65f5e65ab50ab7c1294", "raw": {"w": 2576, "h": 1924, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON D6", "mode": "14bit 14bit compressed ((10)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4717/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28%2810%29%29%20%283:2%29.NEF", "sha256": "8c8cf431f5807e2d216318f8a42e30a4d4f8561e325eb6495d9aa0e466ef53fe", "raw": {"w": 5584, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [10]}, + {"model": "NIKON D6", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", "raw": {"w": 5584, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D6", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", "raw": {"w": 5584, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D6", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4722/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "b409210ee4d9d0f2a6bfcdae8ff413102452cf9cd899652e713f9e8f4e3b9c11", "raw": {"w": 4786, "h": 3194, "bits": 12, "compression": 34713}, "crop_area": [8, 7, 4770, 3180], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D6", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4723/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "979a712d59bb768913572db874bd3c605e83c3be23a91b89d7cf88111af951d4", "raw": {"w": 4188, "h": 2796, "bits": 12, "compression": 34713}, "crop_area": [6, 6, 4176, 2784], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D6", "mode": "12bit 12bit compressed ((6)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4724/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "7cd1caa3fbf7f19348ea3cc3dd31927a4bc1b105a4a90dab36a46e7693eacff1", "raw": {"w": 5584, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [6]}, + {"model": "NIKON D6", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4725/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "feb3c34f364c71abeb2fe9f0d24b18835ef8b82f17832fa25229ad6c80a3d86c", "raw": {"w": 5584, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D6", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", "raw": {"w": 5584, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [8, 8, 5568, 3712], "crop_hi_speed": [11, 5584, 3728, 5584, 3728, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z fc", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4812/nice/Nikon%20-%20Z%20fc%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", "sha256": "4099ecc46ebaca26e2a66a96a8313fed5e8c707b3836ccf0e77a134844b041c1", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z fc", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/4813/nice/Nikon%20-%20Z%20fc%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.nef", "sha256": "e4cabbeaf818eaa77719158ca2f480fbbf413e694f3714b44f01d588bc7a9863", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 9", "mode": "8bit 8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5146/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", "sha256": "99d0fe24a38f0f7623aeab21440d266a5ac09f9665cf5283f590337e84323c38", "raw": {"w": 8280, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 8256, 5504], "crop_hi_speed": [11, 8280, 5520, 8280, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 9", "mode": "8bit 8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5147/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", "sha256": "e113deb305d84153b26251ce259d1f839be1f36b048cbe83a4548cbda806c0f5", "raw": {"w": 8280, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 8256, 5504], "crop_hi_speed": [11, 8280, 5520, 8280, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 9", "mode": "8bit 8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5148/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", "sha256": "be91bac25573adf7ec6320a542105d87b4fb9c956d7b620ec90d57ac2ae6c3f0", "raw": {"w": 8280, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 8256, 5504], "crop_hi_speed": [11, 8280, 5520, 8280, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON D3S", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5203/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "402898e57007a4176b8686019ef2878c28481f7520c6136127f00148de9960b5", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D3S", "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5204/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "9b7c39fb4c37a117aecdefdbdb52b35482a092681dfe66068d652b19c47c4eca", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3S", "mode": "14bit 14bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5205/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "6330d1ab9adaa65de609c2c6cb29948b85579be215de71385db63520825d0db8", "raw": {"w": 4288, "h": 2844, "bits": 14, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": null}, + {"model": "NIKON D3S", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5206/nice/Nikon%20-%20D3S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c9784f9cfca635be29beb08aad4ed40c60be1ad536eb1186bcd565381fd1056c", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [3]}, + {"model": "NIKON D3S", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5207/nice/Nikon%20-%20D3S%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "322ec3cea1f0be42ea38b3e4974dcfdadd1ff6574743d82d2a59f07242852297", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D3S", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5208/nice/Nikon%20-%20D3S%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "cb7f0877f20d32e8590c0c0889fb365f73037227eb565cf2629cd9725ead6e0a", "raw": {"w": 4288, "h": 2844, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4288, 2844, 4288, 2844, 0, 0], "nef_compression": null}, + {"model": "NIKON D2H", "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5227/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "155edb938f884ea7372ce98d4ff5f965c3e413b43b95bc9923da6e92082cf914", "raw": {"w": 2496, "h": 1648, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [1]}, + {"model": "NIKON D1H", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5364/nice/Nikon%20-%20D1H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "d110b80a8b10a3a67b5cd1c15ef33f1a6397557b73d5d3f4e565739f0c072571", "raw": {"w": 2012, "h": 1324, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "COOLPIX P7700", "mode": "12bit 12bit compressed ((7)) (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5495/nice/Nikon%20-%20Coolpix%20P7700%20-%2012bit%2012bit%20compressed%20%28%287%29%29%20%284:3%29.NRW", "sha256": "559aaf6616e092b363e72bb36422c4d1c024da3c0d4ba582126fa16d42e96cb8", "raw": {"w": 4032, "h": 3024, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": null, "nef_compression": [7]}, + {"model": "NIKON D2H", "mode": "12bit 12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5632/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "27927b15a7145ceae6236b30472b533875d42e7bb2d8bf6f38e3a1babd80cd5b", "raw": {"w": 2496, "h": 1648, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON 1 S1", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5661/nice/Nikon%20-%201%20S1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8e540d40b842f61de69161cb0b8be72b024629c752fd82b6f8d10e460bce88cb", "raw": {"w": 3948, "h": 2604, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3948, 2604, 3948, 2604, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 30", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5812/nice/Nikon%20-%20Z%2030%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "75249c11a3a845367c7a322a1c4e7076f33c785ec2518fc6594c34c185b41161", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 30", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5813/nice/Nikon%20-%20Z%2030%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "03d94c1f184c4236ea8f890ab0fe30e91b65965e8b09cebecc397fba081a607f", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON 1 J4", "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/5922/nice/Nikon%20-%201%20J4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0e2887892b8cfc80891b2c510721088fcac6d77eff9ba68089f382a0115b274c", "raw": {"w": 5248, "h": 3504, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 5248, 3504, 5248, 3504, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z 30", "mode": "12bit 12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6172/nice/Nikon%20-%20Z%2030%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "6157b2d6c307fda7b12cf1f5e398874bd198ef16f5e8929e20c57c9a57fef58b", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 30", "mode": "14bit 14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6173/nice/Nikon%20-%20Z%2030%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "a952c7baaed05c0f191fa3698262a1a894e54e3799dd34dc1ead0f63cf09e2ca", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z 8", "mode": "8bit 8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6616/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", "sha256": "82df041542b8d328738443bb5ff3373b396e406ecc8cbe7dc3b35252790c53e7", "raw": {"w": 8280, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 8256, 5504], "crop_hi_speed": [11, 8280, 5520, 8280, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 8", "mode": "8bit 8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", "sha256": "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", "raw": {"w": 8280, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 8256, 5504], "crop_hi_speed": [11, 8280, 5520, 8280, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z 8", "mode": "8bit 8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6618/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", "sha256": "ea8b093cd11c946c5eeb501b775859e8e617ce1d79577abc41b233c339e4004c", "raw": {"w": 8280, "h": 5520, "bits": 14, "compression": 34713}, "crop_area": [12, 8, 8256, 5504], "crop_hi_speed": [11, 8280, 5520, 8280, 5520, 0, 0], "nef_compression": null}, + {"model": "NIKON Z f", "mode": "14bit 14bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6885/nice/Nikon%20-%20Z%20f%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", "sha256": "83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z f", "mode": "8bit 8bit lossy compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6886/nice/Nikon%20-%20Z%20f%20-%208bit%208bit%20lossy%20compressed%20%283:2%29.NEF", "sha256": "c888f109dc420e359853a2ce768d8a6274b8ba0109b2f5cb6e0c982981d5a624", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z f", "mode": "8bit 8bit lossy compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/6887/nice/Nikon%20-%20Z%20f%20-%208bit%208bit%20lossy%20compressed%20%283:2%29.NEF", "sha256": "98d6ca8e6c98048ca7ffed68ccaeda7b2b9f03807f0d320d97d5678db21748c2", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "COOLPIX P1100", "mode": "12bit uncompressed (4:3)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7689/nice/Nikon%20-%20Coolpix%20P1100%20-%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "706840f7a36c3852092d4aad0465e75920e04f1aa653adf31626c0c05ab41258", "raw": {"w": 4624, "h": 3470, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": null, "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7735/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "465aba7a444c013063b3a85d7dca1595e4ce1c77fdd115b781b952d490af7258", "raw": {"w": 4048, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 4032, 4032], "crop_hi_speed": [17, 6064, 4040, 4048, 4040, 1008, 0], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7736/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "6d087e8860491a9a871edbd145fa9a423ad41e06d787be4e0fec30b925c38218", "raw": {"w": 6064, "h": 3416, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 3400], "crop_hi_speed": [6, 6064, 4040, 6064, 3416, 0, 312], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7737/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "196971ea960cfa6f5c0a19cebe515ac118e079c57ceeef850ab3a4995dc7d20f", "raw": {"w": 4000, "h": 2672, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3984, 2656], "crop_hi_speed": [2, 6064, 4040, 4000, 2672, 1032, 684], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7738/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "d8938c5df60f23ea848498752549bc053b2885af4a4ca11746a27c4c220b3457", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7740/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "a605497e0581f4ff174bfd341b596df3253aa7755264cb706e4063d9a1470fc7", "raw": {"w": 4048, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 4032, 4032], "crop_hi_speed": [17, 6064, 4040, 4048, 4040, 1008, 0], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7741/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "06069e1c3fcd4f56e38cfa7508310c9cc0e21dc530cd9f332edea51372056ccc", "raw": {"w": 6064, "h": 3416, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 3400], "crop_hi_speed": [6, 6064, 4040, 6064, 3416, 0, 312], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7742/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "276ef4ad5f02e7917eb959c2f4fcfd19bf2fe9f6b9e3725a93877e43e6ec8eed", "raw": {"w": 4000, "h": 2672, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3984, 2656], "crop_hi_speed": [2, 6064, 4040, 4000, 2672, 1032, 684], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7743/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "b44bb31787098631a396b553454a8ebf960766d9b538521ce9852896bd6df3a8", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7744/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "133d8a624f2d39769dec3cb9e7b9ae81971ae8787b5e8d0675bc6ecd27ddef4b", "raw": {"w": 4048, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 4032, 4032], "crop_hi_speed": [17, 6064, 4040, 4048, 4040, 1008, 0], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7745/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "387d613e1f81e43a704bc349d7c64ef297880a8f306b001baf8be39856c532de", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z5_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7746/nice/Nikon%20-%20Z5_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "ba6c66ece60b18eaf6a3461ecfbccd346af785a1db311b3e651ae0284232a1c8", "raw": {"w": 4000, "h": 2672, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3984, 2656], "crop_hi_speed": [2, 6064, 4040, 4000, 2672, 1032, 684], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7762/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "a3314a026524396e603b461afc4fe61a8a5a3958232f3687bb1c8eca0dcbed93", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7763/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "0add1dad5776669a4896640de31b128032f893e13418f6569da7a4744a05ed42", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7764/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "71c12d398e6f5a01e0e32c2211c40cafdb29e85ba1e17c4f65e7a4e958754c54", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7765/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "ba925fc9fed35a82e279eb4e102d2221e36d35a8df3e21fb24037e0e518b715a", "raw": {"w": 3728, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3712, 3712], "crop_hi_speed": [18, 5600, 3728, 3728, 3728, 936, 0], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7766/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "605f903e4c07a534c812288bfe3b0ce00716cfb0905c134f7e418e228f455040", "raw": {"w": 3728, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3712, 3712], "crop_hi_speed": [18, 5600, 3728, 3728, 3728, 936, 0], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7767/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "99b2a569356fb084d6946fa6e0b501b014e6e004be0a8e8d564a7b9dab91857e", "raw": {"w": 3728, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3712, 3712], "crop_hi_speed": [18, 5600, 3728, 3728, 3728, 936, 0], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7768/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "32909911193eb2a84db377fd8fd984e2d6ca3646f678ce17932766bbfaa42bb4", "raw": {"w": 5600, "h": 3144, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3128], "crop_hi_speed": [9, 5600, 3728, 5600, 3144, 0, 292], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7769/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "ea847c786c0f0524b8587769c5c29d15194b2bc01eef9cc9babf35d5b554f466", "raw": {"w": 5600, "h": 3144, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3128], "crop_hi_speed": [9, 5600, 3728, 5600, 3144, 0, 292], "nef_compression": null}, + {"model": "NIKON Z50_2", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7770/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "663fd340edbf91cae2e754c2c5c0b2e49e3a25b7a471c1379ecffdb01cfe1599", "raw": {"w": 5600, "h": 3144, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3128], "crop_hi_speed": [9, 5600, 3728, 5600, 3144, 0, 292], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7808/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "da2fd1a263bb9985776d8c40aa2bdcb89dd7d8cc55bfa44dd851727c43709af8", "raw": {"w": 4048, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 4032, 4032], "crop_hi_speed": [17, 6064, 4040, 4048, 4040, 1008, 0], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7809/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "890e6bf952bf87d37491f8a6bd7bbfae3c464e08063767d57bf0d97d588d30df", "raw": {"w": 6064, "h": 3416, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 3400], "crop_hi_speed": [6, 6064, 4040, 6064, 3416, 0, 312], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7810/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "a8fa2cacadcf9dc85475ebcf37fcc32614072bc155e39d01491cb9e293c1343d", "raw": {"w": 4000, "h": 2672, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3984, 2656], "crop_hi_speed": [2, 6064, 4040, 4000, 2672, 1032, 684], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7811/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "9ae7346adb3b16f011d1d3c11b4c7ae2ab0231c64111dff3fad8e28dd55353c8", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7812/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "b026ebf6a3ee851925a8a9a44134e39cf3cddd6b383d39aafff15df2b418c89e", "raw": {"w": 4048, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 4032, 4032], "crop_hi_speed": [17, 6064, 4040, 4048, 4040, 1008, 0], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7813/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "b84499212126ef8bab31aa2b150f6dbf8cb6e0e2aab7ca669d85c3259784ecc5", "raw": {"w": 6064, "h": 3416, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 3400], "crop_hi_speed": [6, 6064, 4040, 6064, 3416, 0, 312], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7814/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "1c7be7b9081fcbd8bb75b28a8432531fae4dc56acda6b7f41a32625fb2555600", "raw": {"w": 4000, "h": 2672, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3984, 2656], "crop_hi_speed": [2, 6064, 4040, 4000, 2672, 1032, 684], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7815/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "10eea35531eb5427c171ab5158d064b79844a9fe039f8ca971b20c4f3be36cfd", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (1:1)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7816/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%281:1%29.NEF", "sha256": "ffd2eae3f5bdfd58edea142761107af70ad11a54abdd87b5896d8dba029f038c", "raw": {"w": 4048, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 4032, 4032], "crop_hi_speed": [17, 6064, 4040, 4048, 4040, 1008, 0], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (16:9)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7817/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%2816:9%29.NEF", "sha256": "883db3023a5b502931b3d8470424ae03fe7fa77d0c0e54141687fab949cef6b2", "raw": {"w": 6064, "h": 3416, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 6048, 3400], "crop_hi_speed": [6, 6064, 4040, 6064, 3416, 0, 312], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7818/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "7f214d6ea98e2eea44347d42e4dee0b7fa3ccab12c29275e2d029775685b74b5", "raw": {"w": 4000, "h": 2672, "bits": 14, "compression": 34713}, "crop_area": [8, 8, 3984, 2656], "crop_hi_speed": [2, 6064, 4040, 4000, 2672, 1032, 684], "nef_compression": null}, + {"model": "NIKON Z6_3", "mode": "8bit compressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/7819/nice/Nikon%20-%20Z6_3%20-%208bit%20compressed%20%283:2%29.NEF", "sha256": "17180e80555c149c5954a8a2f09459ede593b49731f1941f7094be105f3520e2", "raw": {"w": 6064, "h": 4040, "bits": 14, "compression": 34713}, "crop_area": [8, 4, 6048, 4032], "crop_hi_speed": [11, 6064, 4040, 6064, 4040, 0, 0], "nef_compression": null}, + {"model": "NIKON D200", "mode": "12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8079/nice/Nikon%20-%20D200%20-%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.nef", "sha256": "b0800a1166b952430b255600373573e1d528adf8de98857781e06a98472b3c35", "raw": {"w": 3904, "h": 2616, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 3904, 2616, 3904, 2616, 0, 0], "nef_compression": [1]}, + {"model": "NIKON D5300", "mode": "12bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8365/nice/Nikon%20-%20D5300%20-%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0ae88fc81f082929d76dd1b932aacf7815cd6bc670b96ef55a81928e11084a6c", "raw": {"w": 6016, "h": 4016, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON D5300", "mode": "14bit compressed (Lossy (type 2)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8366/nice/Nikon%20-%20D5300%20-%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8751a0a3bc491bb5c79ed6b32f83b3923b119ae9d950b851ad26db2a0fbb3b91", "raw": {"w": 6016, "h": 4016, "bits": 14, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 6016, 4016, 6016, 4016, 0, 0], "nef_compression": [4]}, + {"model": "NIKON Z fc", "mode": "12bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8559/nice/Nikon%20-%20Z%20fc%20-%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "a213f29e52f02c0155030d204bf885c7b85ae65a78057a751ccb3ade2d141798", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z fc", "mode": "14bit compressed (Lossless) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8562/nice/Nikon%20-%20Z%20fc%20-%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "038995c5108899cfa3f54d99364752fe761bb0accb8b26a21ac274d107a85d05", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [3]}, + {"model": "NIKON Z fc", "mode": "12bit compressed ((6)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8563/nice/Nikon%20-%20Z%20fc%20-%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "18cbb59cb9ab9deaad835080b3e9dc41d61e50fed9c24f9fe6891673b1e21d2c", "raw": {"w": 5600, "h": 3728, "bits": 12, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [6]}, + {"model": "NIKON Z fc", "mode": "14bit compressed ((10)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/8566/nice/Nikon%20-%20Z%20fc%20-%2014bit%20compressed%20%28%2810%29%29%20%283:2%29.NEF", "sha256": "8ff63bbe2a55aa682112662674b302e50a650a671a3022c68f787672961a0683", "raw": {"w": 5600, "h": 3728, "bits": 14, "compression": 34713}, "crop_area": [16, 8, 5568, 3712], "crop_hi_speed": [12, 5600, 3728, 5600, 3728, 0, 0], "nef_compression": [10]}, + {"model": "NIKON D2X", "mode": "12bit uncompressed (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/9064/nice/Nikon%20-%20D2X%20-%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "4d4b89f85bdfef233ef9b8aa7ecfeb609874ab592cb44e777f5b36814df5883c", "raw": {"w": 4320, "h": 2868, "bits": 12, "compression": 1}, "crop_area": null, "crop_hi_speed": [0, 4320, 2868, 4320, 2868, 0, 0], "nef_compression": null}, + {"model": "NIKON D2X", "mode": "12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/9065/nice/Nikon%20-%20D2X%20-%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "b0b96cc953b8c2f86a216e64e575cbdaee935ddfe75e5a4adc861e41f668b83b", "raw": {"w": 4320, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4320, 2868, 4320, 2868, 0, 0], "nef_compression": [1]}, + {"model": "NIKON D2Xs", "mode": "12bit compressed (Lossy (type 1)) (3:2)", "cc0": true, "url": "https://raw.pixls.us/getfile.php/9066/nice/Nikon%20-%20D2Xs%20-%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "266d4f2bb6e56f5086714ad059871d2184edb06419c5ee7cd0d138f28796b216", "raw": {"w": 4320, "h": 2868, "bits": 12, "compression": 34713}, "crop_area": null, "crop_hi_speed": [0, 4320, 2868, 4320, 2868, 0, 0], "nef_compression": [1]} + ] +} diff --git a/docs/nikon-bodies/samples.json b/docs/nikon-bodies/samples.json new file mode 100644 index 000000000..09e234b75 --- /dev/null +++ b/docs/nikon-bodies/samples.json @@ -0,0 +1,144 @@ +{ + "about": "CC0 raw.pixls.us samples measured for docs/nikon-bodies.md; fetch with `cargo xtask corpus --download nikon` into corpus/raw/nikon-inventory/", + "licence": "CC0-1.0 (each file marked CC0 on raw.pixls.us)", + "files": [ + {"file": "coolpix-a-4992x3280-14bit.nef", "model": "COOLPIX A", "size": [4992, 3280], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2238/nice/Nikon%20-%20Coolpix%20A%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "c5ca0333a7cda8a2fc33fb2157d18dfa89f1844548dc49c2fd2e67f502f72e25"}, + {"file": "coolpix-a1000-4608x3456-12bit.nrw", "model": "COOLPIX A1000", "size": [4608, 3456], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/4337/nice/Nikon%20-%20Coolpix%20A1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "9b4edd03bbb17d66eacc2a72b30f044969e67666446f3f1ed25e88bb947d4f8a"}, + {"file": "coolpix-b700-5200x3902-12bit.nrw", "model": "COOLPIX B700", "size": [5200, 3902], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "a175d5892304e79282add07eacf4bdc498ca3f050af48cf989c080355b4735ec"}, + {"file": "coolpix-p1000-4624x3470-12bit.nrw", "model": "COOLPIX P1000", "size": [4624, 3470], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/3381/nice/Nikon%20-%20COOLPIX%20P1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "1d9470ee083f914e51aa0be782161b432f27f87dbef551a59ec435f5dbc289dd"}, + {"file": "coolpix-p1100-4624x3470-12bit.nrw", "model": "COOLPIX P1100", "size": [4624, 3470], "bits": 12, "mode": "12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/7689/nice/Nikon%20-%20Coolpix%20P1100%20-%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "706840f7a36c3852092d4aad0465e75920e04f1aa653adf31626c0c05ab41258"}, + {"file": "coolpix-p330-4032x3024-12bit.nrw", "model": "COOLPIX P330", "size": [4032, 3024], "bits": 12, "mode": "12bit 12bit compressed ((7)) (4:3)", "url": "https://raw.pixls.us/getfile.php/912/nice/Nikon%20-%20Coolpix%20P330%20-%2012bit%2012bit%20compressed%20%28%287%29%29%20%284:3%29.NRW", "sha256": "bff89a1a985c834f894b6bb2c7989ed265ab96c5f5a2e981a713d6fe836df3f0"}, + {"file": "coolpix-p7700-4032x3024-12bit.nrw", "model": "COOLPIX P7700", "size": [4032, 3024], "bits": 12, "mode": "12bit 12bit compressed ((7)) (4:3)", "url": "https://raw.pixls.us/getfile.php/5495/nice/Nikon%20-%20Coolpix%20P7700%20-%2012bit%2012bit%20compressed%20%28%287%29%29%20%284:3%29.NRW", "sha256": "559aaf6616e092b363e72bb36422c4d1c024da3c0d4ba582126fa16d42e96cb8"}, + {"file": "coolpix-p7800-4032x3024-12bit.nrw", "model": "COOLPIX P7800", "size": [4032, 3024], "bits": 12, "mode": "12bit 12bit compressed ((7)) (4:3)", "url": "https://raw.pixls.us/getfile.php/1425/nice/Nikon%20-%20Coolpix%20P7800%20-%2012bit%2012bit%20compressed%20%28%287%29%29%20%284:3%29.NRW", "sha256": "27ac09537dd0f75523864dcf221278e2db354d490534bc13b6eabdc031060e51"}, + {"file": "coolpix-p950-4624x3470-12bit.nrw", "model": "COOLPIX P950", "size": [4624, 3470], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/3806/nice/Nikon%20-%20COOLPIX%20P950%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", "sha256": "fc6136b9e5a7e133d7dc30fe65baa04c09ff8c18ca3c7ad82b4ffdbb71ce5bef"}, + {"file": "e5700-2576x1924-12bit.nef", "model": "E5700", "size": [2576, 1924], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/4456/nice/Nikon%20-%20E5700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "6efeb5049cb4957f0586c47478caed2c5cfdce23eed6b65f5e65ab50ab7c1294"}, + {"file": "e8400-3280x2454-12bit.nef", "model": "E8400", "size": [3280, 2454], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/2082/nice/Nikon%20-%20E8400%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "84ca3fd7f6e684f1469bd02f1d715b4ccb0716ce0bd42200aeda32ef5b9c8562"}, + {"file": "e8800-3280x2454-12bit.nef", "model": "E8800", "size": [3280, 2454], "bits": 12, "mode": "12bit 12bit uncompressed (4:3)", "url": "https://raw.pixls.us/getfile.php/2769/nice/Nikon%20-%20Coolpix%20E8800%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NEF", "sha256": "4a9bc7214e5eee808f46bc64cfa7d50d830e094f92253b52bb30a6abee684a78"}, + {"file": "nikon-1-aw1-4620x3084-12bit.nef", "model": "NIKON 1 AW1", "size": [4620, 3084], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1171/nice/Nikon%20-%201%20AW1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "519d354ec40907f1cd30329fc1a641ddec5b46e08e88883f3cf2fddc4ddd9974"}, + {"file": "nikon-1-j1-3904x2606-12bit.nef", "model": "NIKON 1 J1", "size": [3904, 2606], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2956/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "1d3b7c26b012cf585d46a28c9c83876d687a6e215b7d0bdf8d062c82ab626401"}, + {"file": "nikon-1-j2-3904x2606-12bit.nef", "model": "NIKON 1 J2", "size": [3904, 2606], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/923/nice/Nikon%20-%201%20J2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "81051ee36135883acea55bd1e609d6d31212c0bf03640b7ae9fe177a1ca51219"}, + {"file": "nikon-1-j3-4620x3084-12bit.nef", "model": "NIKON 1 J3", "size": [4620, 3084], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1814/nice/Nikon%20-%201%20J3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "996d65705bf1ac9f83f055817ea92618ea44c822109196d7d97a803a0268b576"}, + {"file": "nikon-1-j4-5248x3504-12bit.nef", "model": "NIKON 1 J4", "size": [5248, 3504], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/5922/nice/Nikon%20-%201%20J4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0e2887892b8cfc80891b2c510721088fcac6d77eff9ba68089f382a0115b274c"}, + {"file": "nikon-1-j5-5584x3726-12bit.nef", "model": "NIKON 1 J5", "size": [5584, 3726], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1883/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "92b7e0a7637e5e498011372786a988aea3b431b0427ee10f9021469ecd6408e3"}, + {"file": "nikon-1-s1-3948x2604-12bit.nef", "model": "NIKON 1 S1", "size": [3948, 2604], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/5661/nice/Nikon%20-%201%20S1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8e540d40b842f61de69161cb0b8be72b024629c752fd82b6f8d10e460bce88cb"}, + {"file": "nikon-1-v1-3904x2606-12bit.nef", "model": "NIKON 1 V1", "size": [3904, 2606], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2217/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8315c5c5c786703d057ed44339cdff7e82844bb8b21c254ac80942f65d8e6b77"}, + {"file": "nikon-1-v2-4620x3084-12bit.nef", "model": "NIKON 1 V2", "size": [4620, 3084], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1693/nice/Nikon%20-%201%20V2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3d29dfa6397b0aa4e72969bb5b85d040e8bebf2e02f61f6364f580ce39f30bf2"}, + {"file": "nikon-1-v3-5248x3504-12bit.nef", "model": "NIKON 1 V3", "size": [5248, 3504], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2802/nice/Nikon%20-%201%20V3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3f5376481f9ae59e9712b537dcc4eaffa72e311a8768c5396414506ae2eb1d69"}, + {"file": "nikon-d100-3034x2024-12bit.nef", "model": "NIKON D100", "size": [3034, 2024], "bits": 12, "mode": "12bit 12bit compressed (Uncompressed) (3:2)", "url": "https://raw.pixls.us/getfile.php/2996/nice/Nikon%20-%20D100%20-%2012bit%2012bit%20compressed%20%28Uncompressed%29%20%283:2%29.NEF", "sha256": "1889abfd1ed64048c90b0c3384e1e0be9bbb021692249427e90848d5d1e9c8f0"}, + {"file": "nikon-d1h-2012x1324-12bit.nef", "model": "NIKON D1H", "size": [2012, 1324], "bits": 12, "mode": "12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/5364/nice/Nikon%20-%20D1H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "d110b80a8b10a3a67b5cd1c15ef33f1a6397557b73d5d3f4e565739f0c072571"}, + {"file": "nikon-d1x-4028x1324-12bit.nef", "model": "NIKON D1X", "size": [4028, 1324], "bits": 12, "mode": "12bit 12bit compressed (3.0422960725076)", "url": "https://raw.pixls.us/getfile.php/1785/nice/Nikon%20-%20D1X%20-%2012bit%2012bit%20compressed%20%283.0422960725076%29.NEF", "sha256": "ff80b83537a909e6adabeb091a12e4d5c83993e011672705366e7de39601695b"}, + {"file": "nikon-d200-3904x2616-12bit.nef", "model": "NIKON D200", "size": [3904, 2616], "bits": 12, "mode": "12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/8079/nice/Nikon%20-%20D200%20-%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.nef", "sha256": "b0800a1166b952430b255600373573e1d528adf8de98857781e06a98472b3c35"}, + {"file": "nikon-d2h-2496x1648-12bit.nef", "model": "NIKON D2H", "size": [2496, 1648], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/5227/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "155edb938f884ea7372ce98d4ff5f965c3e413b43b95bc9923da6e92082cf914"}, + {"file": "nikon-d2hs-2496x1648-12bit.nef", "model": "NIKON D2Hs", "size": [2496, 1648], "bits": 12, "mode": "12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/2852/nice/Nikon%20-%20D2Hs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "9ea1c3d5d6c8a11f33239079559f1f0c86bbdb9440a2c7d2578368647abf7ea4"}, + {"file": "nikon-d2x-4320x2868-12bit.nef", "model": "NIKON D2X", "size": [4320, 2868], "bits": 12, "mode": "12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/9065/nice/Nikon%20-%20D2X%20-%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "b0b96cc953b8c2f86a216e64e575cbdaee935ddfe75e5a4adc861e41f668b83b"}, + {"file": "nikon-d2xs-4320x2868-12bit.nef", "model": "NIKON D2Xs", "size": [4320, 2868], "bits": 12, "mode": "12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/9066/nice/Nikon%20-%20D2Xs%20-%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "266d4f2bb6e56f5086714ad059871d2184edb06419c5ee7cd0d138f28796b216"}, + {"file": "nikon-d3-4288x2844-12bit.nef", "model": "NIKON D3", "size": [4288, 2844], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/3516/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8"}, + {"file": "nikon-d3-4288x2844-14bit.nef", "model": "NIKON D3", "size": [4288, 2844], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/3519/nice/Nikon%20-%20D3%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0626c09a58ca12ee4c12f919772abd4fd053c7d88c2f2b25e8d9be7c26cc7fd5"}, + {"file": "nikon-d300-4352x2868-12bit.nef", "model": "NIKON D300", "size": [4352, 2868], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/928/nice/Nikon%20-%20D300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "f1162624678ae9b0e0e835fcbe77d97584fc88ceefd996e38ee126942b54bc8c"}, + {"file": "nikon-d300-4352x2868-14bit.nef", "model": "NIKON D300", "size": [4352, 2868], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/925/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0d47cd75b79e172124d0a3c51fb36fdcc21a33157b30bac38028e55465bf9b6d"}, + {"file": "nikon-d3000-3904x2616-12bit.nef", "model": "NIKON D3000", "size": [3904, 2616], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2161/nice/Nikon%20-%20D3000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "a07ee8a0b1ff7a1ad06085306ef8f9a197d95fec639994467317c56108eeb435"}, + {"file": "nikon-d300s-4352x2868-12bit.nef", "model": "NIKON D300S", "size": [4352, 2868], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/3055/nice/Nikon%20-%20D300S%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "bb9834d03bc158731aefebc5bef11ab1607c9c8cc12f8ae964e6c1b031696d05"}, + {"file": "nikon-d300s-4352x2868-14bit.nef", "model": "NIKON D300S", "size": [4352, 2868], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/3052/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "34749388e846408eb1633991ca774b341e5d7d9752df8c5538eab281cecf4c83"}, + {"file": "nikon-d3100-4672x3084-12bit.nef", "model": "NIKON D3100", "size": [4672, 3084], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/4229/nice/Nikon%20-%20D3100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "e8113b6e7285155f9f2b88f81566dab63a59cf5559d6915113b74e8c3a8680f6"}, + {"file": "nikon-d3200-6080x4012-12bit.nef", "model": "NIKON D3200", "size": [6080, 4012], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/743/nice/Nikon%20-%20D3200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "91657dd8e9c7a086826683f6d20799685657dd7ca2056702b145851465f57cd3"}, + {"file": "nikon-d3300-6016x4016-12bit.nef", "model": "NIKON D3300", "size": [6016, 4016], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1734/nice/Nikon%20-%20D3300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "97dfc95aecc00383d6da6197cb759bacb1e43c86071b4ca2d94748e498620f2f"}, + {"file": "nikon-d3s-4288x2844-12bit.nef", "model": "NIKON D3S", "size": [4288, 2844], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/5207/nice/Nikon%20-%20D3S%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "322ec3cea1f0be42ea38b3e4974dcfdadd1ff6574743d82d2a59f07242852297"}, + {"file": "nikon-d3s-4288x2844-14bit.nef", "model": "NIKON D3S", "size": [4288, 2844], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/5204/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "9b7c39fb4c37a117aecdefdbdb52b35482a092681dfe66068d652b19c47c4eca"}, + {"file": "nikon-d3x-6080x4044-14bit.nef", "model": "NIKON D3X", "size": [6080, 4044], "bits": 14, "mode": "14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/3708/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "48dc0339ef86541aabcc6bb68a6452f603e23a7d7a5597dad0d267f69e3e79d4"}, + {"file": "nikon-d4-4992x3292-12bit.nef", "model": "NIKON D4", "size": [4992, 3292], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1978/nice/Nikon%20-%20D4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "1148bcbf80106b21f7788d0b926751bf5a14ebb5b8cf45ff67bf5bfe6b4ac84f"}, + {"file": "nikon-d4-4992x3292-14bit.nef", "model": "NIKON D4", "size": [4992, 3292], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1975/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "aefb8b307361b5d5ed27fadb8a3a810d8c174f258313c77c57187eb829aa30cb"}, + {"file": "nikon-d40-3040x2014-12bit.nef", "model": "NIKON D40", "size": [3040, 2014], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/3618/nice/Nikon%20-%20D40%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "44e88bc77b7a531b22647bcd07b9393c4568062e8f0906d3bbdecb42fbe29e03"}, + {"file": "nikon-d40x-3904x2616-12bit.nef", "model": "NIKON D40X", "size": [3904, 2616], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/804/nice/Nikon%20-%20D40X%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "c3e4956f7e2d1f1b3f846b6ec9552678073db0bcaeb51132ec4c60bd2229d56e"}, + {"file": "nikon-d4s-2464x1640-12bit.nef", "model": "NIKON D4S", "size": [2464, 1640], "bits": 12, "mode": "12bit 12bit compressed ((8)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2227/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28%288%29%29%20%283:2%29.NEF", "sha256": "c8256566c01eed83b8f9c4eeec6276a94031d19f607afc511a90cb0c2fa76f9c"}, + {"file": "nikon-d4s-4936x3288-12bit.nef", "model": "NIKON D4S", "size": [4936, 3288], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2221/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3120d2b3680ec2b9cd017553dd32be2081e62ca220b6e2e7e8988016662042d4"}, + {"file": "nikon-d4s-4936x3288-14bit.nef", "model": "NIKON D4S", "size": [4936, 3288], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2225/nice/Nikon%20-%20D4S%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8726fceea6aaae58b5341e4a78316ee6db463c5a07fa2af1c41b9ed3305fe653"}, + {"file": "nikon-d50-3040x2014-12bit.nef", "model": "NIKON D50", "size": [3040, 2014], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1282/nice/Nikon%20-%20D50%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "9f94c27672a63f946c92d43910ef0c266dffaa14dae0007ae00ac72d2c684935"}, + {"file": "nikon-d5000-4352x2868-12bit.nef", "model": "NIKON D5000", "size": [4352, 2868], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1341/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "5f8e8005be87366bbf7746d35c3c88f82a12a1dd18206c939d8f1254c5922360"}, + {"file": "nikon-d5100-4992x3280-14bit.nef", "model": "NIKON D5100", "size": [4992, 3280], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1596/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "3686f6063ca1c05273c7c1021d47ed57ef50194656e3cfe30d9cae70781b98ef"}, + {"file": "nikon-d5200-6036x4020-14bit.nef", "model": "NIKON D5200", "size": [6036, 4020], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1691/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6c49ca47d189f9d6766f94be7c9d7d779ce45c79c0c6e48a0eba7d92f320fe0e"}, + {"file": "nikon-d5300-6016x4016-12bit.nef", "model": "NIKON D5300", "size": [6016, 4016], "bits": 12, "mode": "12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/8365/nice/Nikon%20-%20D5300%20-%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0ae88fc81f082929d76dd1b932aacf7815cd6bc670b96ef55a81928e11084a6c"}, + {"file": "nikon-d5300-6016x4016-14bit.nef", "model": "NIKON D5300", "size": [6016, 4016], "bits": 14, "mode": "14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/8366/nice/Nikon%20-%20D5300%20-%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8751a0a3bc491bb5c79ed6b32f83b3923b119ae9d950b851ad26db2a0fbb3b91"}, + {"file": "nikon-d5500-6016x4016-12bit.nef", "model": "NIKON D5500", "size": [6016, 4016], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1470/nice/Nikon%20-%20D5500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6e0681b54ef808913a31530e0e320751dbb910f53fbca1b4bd65329f8f129b01"}, + {"file": "nikon-d5500-6016x4016-14bit.nef", "model": "NIKON D5500", "size": [6016, 4016], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1469/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "96d5b85dae24e190691c504781d8e7ee58cb9b63ea715760de9af3d345dc4c13"}, + {"file": "nikon-d5600-6016x4016-12bit.nef", "model": "NIKON D5600", "size": [6016, 4016], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1415/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "82e15f02c4037820e80f14bacfacdc52ea29721b3c70d2cf08b237b50ce8db05"}, + {"file": "nikon-d5600-6016x4016-14bit.nef", "model": "NIKON D5600", "size": [6016, 4016], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1416/nice/Nikon%20-%20D5600%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0917e0ffdd7aa648ed35d99d1858b5a4ab81f62d5a0418d4bcfe3799fc59a7a0"}, + {"file": "nikon-d60-3904x2616-12bit.nef", "model": "NIKON D60", "size": [3904, 2616], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/3698/nice/Nikon%20-%20D60%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "780ce4867e997143acbd1734a889575a5eaeeb40c6afb33b6006c266831caaa6"}, + {"file": "nikon-d600-6080x4028-12bit.nef", "model": "NIKON D600", "size": [6080, 4028], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/919/nice/Nikon%20-%20D600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "d96ff1bfa0bccaf5e900c7e1cdd0ebb87c4b15dda959e1f9a40075c1ba7fbcfa"}, + {"file": "nikon-d600-6080x4028-14bit.nef", "model": "NIKON D600", "size": [6080, 4028], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/917/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6dd9470e0ab44ecf6d1a5341a5314edfbeaf091ecda414f6881c37ea5b0179c0"}, + {"file": "nikon-d610-6080x4028-12bit.nef", "model": "NIKON D610", "size": [6080, 4028], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/889/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "a6de67362a7ae053f36115e639a806f3f5a1c10b89f0a4ed21e95cdc27b7b8cc"}, + {"file": "nikon-d610-6080x4028-14bit.nef", "model": "NIKON D610", "size": [6080, 4028], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/891/nice/Nikon%20-%20D610%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "947ab8a6612a9fd3eaeed193030cbe5fc650d2daa2dff7b5e11051dc6b953366"}, + {"file": "nikon-d70-3040x2014-12bit.nef", "model": "NIKON D70", "size": [3040, 2014], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2060/nice/Nikon%20-%20D70%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "dd6405aeb33b0cd5bf66c98ba98ccbb478a765450cfd130810e470dab8d1f4b4"}, + {"file": "nikon-d700-4288x2844-12bit.nef", "model": "NIKON D700", "size": [4288, 2844], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2255/nice/Nikon%20-%20D700%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "bfd5e164c3fa10ffdc38237000ccb63f52a53bcd080ceb48de1443534dd17036"}, + {"file": "nikon-d700-4288x2844-14bit.nef", "model": "NIKON D700", "size": [4288, 2844], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2250/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "0f279ad4a3e92859109ce78440e562a8d25b91b8704a79e1245c8e98d9b27cf1"}, + {"file": "nikon-d7000-4992x3280-12bit.nef", "model": "NIKON D7000", "size": [4992, 3280], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "986db2bf9ed08cf095bb94cfbaf02f0e89d123edad28b63c05133e7815749486"}, + {"file": "nikon-d7000-4992x3280-14bit.nef", "model": "NIKON D7000", "size": [4992, 3280], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/960/nice/Nikon%20-%20D7000%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "b8953c3e0ff4e4ca7668f4ea8eef0532b04503162d53e31bfcd7946c05a2cbfe"}, + {"file": "nikon-d70s-3040x2014-12bit.nef", "model": "NIKON D70s", "size": [3040, 2014], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692"}, + {"file": "nikon-d7100-6036x4020-12bit.nef", "model": "NIKON D7100", "size": [6036, 4020], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1858/nice/Nikon%20-%20D7100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "48b86be680b631291e193660daeece2ca1515583c9780ab9fcff2ea80628d3d1"}, + {"file": "nikon-d7100-6036x4020-14bit.nef", "model": "NIKON D7100", "size": [6036, 4020], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1857/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "b9e1ac6c38eb06a9d296bbafe81ca403a7f1520d61735603cb238b9ae6a35fce"}, + {"file": "nikon-d7200-6016x4016-12bit.nef", "model": "NIKON D7200", "size": [6016, 4016], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/834/nice/Nikon%20-%20D7200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "8dc6c5c796238056309dbb9c158917e56bbd7033e260e5b0d3736ad71c88cc60"}, + {"file": "nikon-d7200-6016x4016-14bit.nef", "model": "NIKON D7200", "size": [6016, 4016], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/833/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "60f6a525322124949fd3b99e884b78b1ad95d80d8a9e03b897b881a6f50ae2ff"}, + {"file": "nikon-d750-6032x4032-12bit.nef", "model": "NIKON D750", "size": [6032, 4032], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/895/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "e4a3f85e014275fc0a47600a373af95ec850872a5f6cf87649bcaa42b77ef2e4"}, + {"file": "nikon-d750-6032x4032-14bit.nef", "model": "NIKON D750", "size": [6032, 4032], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/896/nice/Nikon%20-%20D750%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "3389da2bd0997be2f8e1408d8d0fba09f5062de05c4017a0bbf2849d8f0cd2a1"}, + {"file": "nikon-d80-3900x2611-16bit.dng", "model": "NIKON D80", "size": [3900, 2611], "bits": 16, "mode": "16bit (3:2)", "url": "https://raw.pixls.us/getfile.php/1988/nice/Nikon%20-%20D80%20-%2016bit%20%283:2%29.dng", "sha256": "5095ca264e763624cd3aa62940286dd1bcff4504eefa1ce61cbb72f50f8426bb"}, + {"file": "nikon-d80-3904x2616-12bit.nef", "model": "NIKON D80", "size": [3904, 2616], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 1)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2104/nice/Nikon%20-%20D80%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", "sha256": "745cb067eea6dbb92d3088ee66e8a28bf4912848b43adb6ee30bec3ca0b1d066"}, + {"file": "nikon-d800-7424x4924-12bit.nef", "model": "NIKON D800", "size": [7424, 4924], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/757/nice/Nikon%20-%20D800%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "317766527a1a9019985d6b41c4b400f89e8fb8621f468a0826b77d17263ab648"}, + {"file": "nikon-d800-7424x4924-14bit.nef", "model": "NIKON D800", "size": [7424, 4924], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/751/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "f5b7906869da3e48cb85ec08c5fa97c4c3ff8ab491825a89ac5ed0fff11fef5d"}, + {"file": "nikon-d800e-7424x4924-14bit.nef", "model": "NIKON D800E", "size": [7424, 4924], "bits": 14, "mode": "14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/2552/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ec31bc8517cbb1247867aebd7b604c29de015f09bb1d2610b68a8fdb0d219dd7"}, + {"file": "nikon-d810-3680x2456-12bit.nef", "model": "NIKON D810", "size": [3680, 2456], "bits": 12, "mode": "12bit 12bit compressed ((8)), small raw (3:2)", "url": "https://raw.pixls.us/getfile.php/2036/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28%288%29%29%2C%20small%20raw%20%283:2%29.NEF", "sha256": "0cb5b8193ddc1de57fcc5f2091d0f7e8476f3b3af4beeb1ccc73d45f1c7a6bed"}, + {"file": "nikon-d810-7380x4928-12bit.nef", "model": "NIKON D810", "size": [7380, 4928], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2034/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "6aa49c8cdfcde936b10a342d4901391fe4dcdb54cfb6b9b5d02ce46c65ac5ebf"}, + {"file": "nikon-d810-7380x4928-14bit.nef", "model": "NIKON D810", "size": [7380, 4928], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2031/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "ea612ad3a01aaa76f6e9064eadb5c1746771875bd2ec81984667b28359b5c78d"}, + {"file": "nikon-d90-4352x2868-12bit.nef", "model": "NIKON D90", "size": [4352, 2868], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1325/nice/Nikon%20-%20D90%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "9eb3daa4e3d1c1614bccc4c6d136c4166960f936f17c437cc4d11a5786b2df06"}, + {"file": "nikon-df-4992x3292-12bit.nef", "model": "NIKON Df", "size": [4992, 3292], "bits": 12, "mode": "12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1386/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "902483437706df59bed669ebb4eb7d9ecaf95b23a70d6c79e319316ce50ee884"}, + {"file": "nikon-df-4992x3292-14bit.nef", "model": "NIKON Df", "size": [4992, 3292], "bits": 14, "mode": "14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/1388/nice/Nikon%20-%20Df%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "452ed50be5e212cf7832cd5fc992d920206cebde8864c073988aeaf5a9d07708"}, + {"file": "extra/nikon-d200-3904x2616-12bit-2.nef", "model": "NIKON D200", "size": [3904, 2616], "bits": 12, "mode": "Nikon - D200 - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/824/nice/Nikon%20-%20D200%20-%2012bit%2012bit%20uncompressed%20%283:2%29.nef", "sha256": "d468895b0b30a60d05a7f6468d69f2f15830dd49b2d82698b34245072a22e747"}, + {"file": "extra/nikon-d2xs-4320x2868-12bit-2.nef", "model": "NIKON D2Xs", "size": [4320, 2868], "bits": 12, "mode": "Nikon - D2Xs - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/2871/nice/Nikon%20-%20D2Xs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "2204bc2e2178bf594e17886ca92a50a94350403a110846ca6e83bdc5f3352706"}, + {"file": "extra/nikon-d3-4288x2844-12bit-2.nef", "model": "NIKON D3", "size": [4288, 2844], "bits": 12, "mode": "Nikon - D3 - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/3517/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "bd1efee38aab8daf79c199e076785125ece01eb8f5ef6741121b0f313ee4ac04"}, + {"file": "extra/nikon-d3-4288x2844-12bit-3.nef", "model": "NIKON D3", "size": [4288, 2844], "bits": 12, "mode": "Nikon - D3 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/3518/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "6c4ca5ac7525c01c972f517503c47389f2f244c1fab2ad4ea9d10c63d36c42e6"}, + {"file": "extra/nikon-d3-4288x2844-14bit-2.nef", "model": "NIKON D3", "size": [4288, 2844], "bits": 14, "mode": "Nikon - D3 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/3520/nice/Nikon%20-%20D3%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c280a2afc31fb15f8c0bc8d8cf6fb13b1972f7d913fa673c5db9d1c1f50beddd"}, + {"file": "extra/nikon-d3-4288x2844-14bit-3.nef", "model": "NIKON D3", "size": [4288, 2844], "bits": 14, "mode": "Nikon - D3 - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/3521/nice/Nikon%20-%20D3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "12c75d4ee829261d15031e02d17a8461f05e72575be76b3e19d1fbdba549de54"}, + {"file": "extra/nikon-d300-4352x2868-12bit-2.nef", "model": "NIKON D300", "size": [4352, 2868], "bits": 12, "mode": "Nikon - D300 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/927/nice/Nikon%20-%20D300%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "7e6d38ddebe82784dc8c84cdf02dce7bcfe74b71b0dec91b28551936102c39b9"}, + {"file": "extra/nikon-d300-4352x2868-12bit-3.nef", "model": "NIKON D300", "size": [4352, 2868], "bits": 12, "mode": "Nikon - D300 - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/929/nice/Nikon%20-%20D300%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "1e0ff56bba6561182c80cff3cca5b912746990523ea777123b4860c42a7f080a"}, + {"file": "extra/nikon-d300-4352x2868-14bit-2.nef", "model": "NIKON D300", "size": [4352, 2868], "bits": 14, "mode": "Nikon - D300 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/924/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "8b989f53c96995f7014ddd16124530cf6d8f3341ea9570d6312d9da1eb0a117a"}, + {"file": "extra/nikon-d300-4352x2868-14bit-3.nef", "model": "NIKON D300", "size": [4352, 2868], "bits": 14, "mode": "Nikon - D300 - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/926/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "0261523d0ab4694df0219404eb08af5db04697a05e85718e7f7bd42d283205f4"}, + {"file": "extra/nikon-d300s-4352x2868-14bit-2.nef", "model": "NIKON D300S", "size": [4352, 2868], "bits": 14, "mode": "Nikon - D300S - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/3049/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "de950a2263c87f085ee611a21e69fc4e0ecb6e417fff61ff6a23ea3121e21a81"}, + {"file": "extra/nikon-d300s-4352x2868-14bit-3.nef", "model": "NIKON D300S", "size": [4352, 2868], "bits": 14, "mode": "Nikon - D300S - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/3053/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "a7dea59999273727cb73d3a7854aa01162c30f06d7fbb5c826dd61a54ba07934"}, + {"file": "extra/nikon-d3s-4288x2844-12bit-2.nef", "model": "NIKON D3S", "size": [4288, 2844], "bits": 12, "mode": "Nikon - D3S - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/5206/nice/Nikon%20-%20D3S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c9784f9cfca635be29beb08aad4ed40c60be1ad536eb1186bcd565381fd1056c"}, + {"file": "extra/nikon-d3s-4288x2844-12bit-3.nef", "model": "NIKON D3S", "size": [4288, 2844], "bits": 12, "mode": "Nikon - D3S - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/5208/nice/Nikon%20-%20D3S%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "cb7f0877f20d32e8590c0c0889fb365f73037227eb565cf2629cd9725ead6e0a"}, + {"file": "extra/nikon-d3s-4288x2844-14bit-2.nef", "model": "NIKON D3S", "size": [4288, 2844], "bits": 14, "mode": "Nikon - D3S - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/5203/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "402898e57007a4176b8686019ef2878c28481f7520c6136127f00148de9960b5"}, + {"file": "extra/nikon-d3s-4288x2844-14bit-3.nef", "model": "NIKON D3S", "size": [4288, 2844], "bits": 14, "mode": "Nikon - D3S - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/5205/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "6330d1ab9adaa65de609c2c6cb29948b85579be215de71385db63520825d0db8"}, + {"file": "extra/nikon-d4-4992x3292-12bit-2.nef", "model": "NIKON D4", "size": [4992, 3292], "bits": 12, "mode": "Nikon - D4 - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/1977/nice/Nikon%20-%20D4%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "90a8fe82c2ca66cb925590370187a16773b455fb655671967443fcc423bf0351"}, + {"file": "extra/nikon-d4-4992x3292-12bit-3.nef", "model": "NIKON D4", "size": [4992, 3292], "bits": 12, "mode": "Nikon - D4 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/1979/nice/Nikon%20-%20D4%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "e549dfba6667148c726100b5e1871e449657284aa5023b2a5f51a35394750480"}, + {"file": "extra/nikon-d4-4992x3292-14bit-2.nef", "model": "NIKON D4", "size": [4992, 3292], "bits": 14, "mode": "Nikon - D4 - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/1974/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "23aa2dd3c409b3cbbfeea3e3f7b6752180efa15430d6e059e87404e969875cd7"}, + {"file": "extra/nikon-d4-4992x3292-14bit-3.nef", "model": "NIKON D4", "size": [4992, 3292], "bits": 14, "mode": "Nikon - D4 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/1976/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "35ed038a1a55988b62fa29eb27f9c1f6088b79de10ff2f447861b1accdb0a317"}, + {"file": "extra/nikon-d4s-4936x3288-12bit-2.nef", "model": "NIKON D4S", "size": [4936, 3288], "bits": 12, "mode": "Nikon - D4S - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/2220/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "49067c81374549465f3c621acd249bf05dbdef4e44b9201fef1fb11c93572983"}, + {"file": "extra/nikon-d4s-4936x3288-12bit-3.nef", "model": "NIKON D4S", "size": [4936, 3288], "bits": 12, "mode": "Nikon - D4S - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/2222/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "1613a1622ac7fcc67ece0ef65b386d03a066ace2ab15933dea79efa3fa371ae9"}, + {"file": "extra/nikon-d5600-6016x4016-12bit-2.nef", "model": "NIKON D5600", "size": [6016, 4016], "bits": 12, "mode": "Nikon - D5600 - 12bit 12bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2261/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "7ea7d00ec7c4de55398ad258c6dd9952237ab8110ea0bfc7b29b4e17c1090719"}, + {"file": "extra/nikon-d5600-6016x4016-14bit-2.nef", "model": "NIKON D5600", "size": [6016, 4016], "bits": 14, "mode": "Nikon - D5600 - 14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2260/nice/Nikon%20-%20D5600%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", "sha256": "4d9b49e6eb8a9e055b10be67f580a9c42538132e3a6e808d2fd08826c644827e"}, + {"file": "extra/nikon-d600-6080x4028-12bit-2.nef", "model": "NIKON D600", "size": [6080, 4028], "bits": 12, "mode": "Nikon - D600 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/918/nice/Nikon%20-%20D600%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c434e4d3f225a09d8d9d05f8709a7ac537fedda295062f9eb392092038305038"}, + {"file": "extra/nikon-d600-6080x4028-14bit-2.nef", "model": "NIKON D600", "size": [6080, 4028], "bits": 14, "mode": "Nikon - D600 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/916/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "a70c3e54501efa7e4391ec4cf5705912a10c356a029e984ee13be5f68bc7482d"}, + {"file": "extra/nikon-d610-6080x4028-12bit-2.nef", "model": "NIKON D610", "size": [6080, 4028], "bits": 12, "mode": "Nikon - D610 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/888/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "11f0a71bb4141a8ed44c7e1c64f9a12571f5f67a11571f2bde8516aa95a7bea3"}, + {"file": "extra/nikon-d610-6080x4028-14bit-2.nef", "model": "NIKON D610", "size": [6080, 4028], "bits": 14, "mode": "Nikon - D610 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/890/nice/Nikon%20-%20D610%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ec3676c6a17d23d1d2e29519b9bd0bd32dc54f560784d61ef8bc9aaf0a5436c0"}, + {"file": "extra/nikon-d700-4288x2844-12bit-2.nef", "model": "NIKON D700", "size": [4288, 2844], "bits": 12, "mode": "Nikon - D700 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/2254/nice/Nikon%20-%20D700%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "6735ee78c8e6718f6333127f6bbd73bbd8ef745b360e3e2033defee4a8d7c2a0"}, + {"file": "extra/nikon-d700-4288x2844-12bit-3.nef", "model": "NIKON D700", "size": [4288, 2844], "bits": 12, "mode": "Nikon - D700 - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/2256/nice/Nikon%20-%20D700%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "0831d8103a2f2275e479095a75a1217b49e1d415ced439f43c331eb3927f2675"}, + {"file": "extra/nikon-d700-4288x2844-14bit-2.nef", "model": "NIKON D700", "size": [4288, 2844], "bits": 14, "mode": "Nikon - D700 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "00c3314f2ccf92aff17644943e6149e8793b517c24bb2c38a15ec41f3af109cb"}, + {"file": "extra/nikon-d700-4288x2844-14bit-3.nef", "model": "NIKON D700", "size": [4288, 2844], "bits": 14, "mode": "Nikon - D700 - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/2251/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "44a45eb60cde6904894b0191590256f90692474ee90de451822c29d46a927667"}, + {"file": "extra/nikon-d7000-4992x3280-12bit-2.nef", "model": "NIKON D7000", "size": [4992, 3280], "bits": 12, "mode": "Nikon - D7000 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/959/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "65d8d063de99befa2f8463c3b1114cb5bf71e35aed6466d36fca39e83d0353bc"}, + {"file": "extra/nikon-d7000-4992x3280-14bit-2.nef", "model": "NIKON D7000", "size": [4992, 3280], "bits": 14, "mode": "Nikon - D7000 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/958/nice/Nikon%20-%20D7000%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "639945753240005d80488c0462c01e73a7ab82c96d3d1cdb6661f02e1b3c1319"}, + {"file": "extra/nikon-d7000-4992x3280-14bit-3.nef", "model": "NIKON D7000", "size": [4992, 3280], "bits": 14, "mode": "Nikon - D7000 - 14bit 14bit compressed (Lossy (type 2)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2578/nice/Nikon%20-%20D7000%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", "sha256": "6d5aab3dbab2d77deab82ea5dfc72608e60a97315d869d6a55dac596e8691616"}, + {"file": "extra/nikon-d750-6032x4032-12bit-2.nef", "model": "NIKON D750", "size": [6032, 4032], "bits": 12, "mode": "Nikon - D750 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/897/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "baae8dfc05a81a4a4034831a49ed99d129e797858ad876d31173a46d33b7d3ea"}, + {"file": "extra/nikon-d750-6032x4032-14bit-2.nef", "model": "NIKON D750", "size": [6032, 4032], "bits": 14, "mode": "Nikon - D750 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/898/nice/Nikon%20-%20D750%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "f90deb2819863f5a6ca9913c255fdd83a94963f4284eb88df9d389da7a6c7665"}, + {"file": "extra/nikon-d800-7424x4924-12bit-2.nef", "model": "NIKON D800", "size": [7424, 4924], "bits": 12, "mode": "Nikon - D800 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/756/nice/Nikon%20-%20D800%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "2cc08a8722e1a53d7a19631b8ac1ce1084861358d9eec751a4d639744e3c657f"}, + {"file": "extra/nikon-d800-7424x4924-12bit-3.nef", "model": "NIKON D800", "size": [7424, 4924], "bits": 12, "mode": "Nikon - D800 - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/759/nice/Nikon%20-%20D800%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "e096d28a860f8b7afe3abde4ccc5cec5bcda229d137b4919cd821a2fc9bf87b2"}, + {"file": "extra/nikon-d800-7424x4924-14bit-2.nef", "model": "NIKON D800", "size": [7424, 4924], "bits": 14, "mode": "Nikon - D800 - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/750/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "26d58c21ed3019af5a22ef6bc02f017b863aad9eafcf84252ed375cd495c0efb"}, + {"file": "extra/nikon-d800-7424x4924-14bit-3.nef", "model": "NIKON D800", "size": [7424, 4924], "bits": 14, "mode": "Nikon - D800 - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/755/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "fd34b869e799fa6b1a4940ddcd60cd74b1624b6244e923caf1dbdbe7866d9eac"}, + {"file": "extra/nikon-d800e-7424x4924-14bit-2.nef", "model": "NIKON D800E", "size": [7424, 4924], "bits": 14, "mode": "Nikon - D800E - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/1303/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "4559b9614ab6d558192d9d30e1db4a4ec44752553e45b2bd26c1569c1820441a"}, + {"file": "extra/nikon-d810-7380x4928-12bit-2.nef", "model": "NIKON D810", "size": [7380, 4928], "bits": 12, "mode": "Nikon - D810 - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/2033/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "ea8baa753dc1d77fdabb3f35048f71aa3c99d5b675f50f6a8d301ab287f5fd68"}, + {"file": "extra/nikon-d810-7380x4928-12bit-3.nef", "model": "NIKON D810", "size": [7380, 4928], "bits": 12, "mode": "Nikon - D810 - 12bit 12bit compressed ((6)) (3:2)", "url": "https://raw.pixls.us/getfile.php/2035/nice/Nikon%20-%20D810%20-%2012bit%2012bit%20compressed%20%28%286%29%29%20%283:2%29.NEF", "sha256": "19519ee6c6b713a1afe45daf5ce9be4054aa6fae3b5db9df915b4b30be63126b"}, + {"file": "extra/nikon-df-4992x3292-12bit-2.nef", "model": "NIKON Df", "size": [4992, 3292], "bits": 12, "mode": "Nikon - Df - 12bit 12bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/1385/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", "sha256": "38ea7aba6b7e0cdbd338fe6703d3c98291f4a9a28b0d1a81bfb1f77b21712c63"}, + {"file": "extra/nikon-df-4992x3292-12bit-3.nef", "model": "NIKON Df", "size": [4992, 3292], "bits": 12, "mode": "Nikon - Df - 12bit 12bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/1387/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "c235099efa19a02f50174dfe473c5d4da1e190d44d9153604486a79cbcf120df"}, + {"file": "extra/nikon-df-4992x3292-14bit-2.nef", "model": "NIKON Df", "size": [4992, 3292], "bits": 14, "mode": "Nikon - Df - 14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/1389/nice/Nikon%20-%20Df%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", "sha256": "b3240362af7342d23afe69e5947b34c6ba7cfc46d49aae767c286f493ecc24d0"}, + {"file": "extra/nikon-df-4992x3292-14bit-3.nef", "model": "NIKON Df", "size": [4992, 3292], "bits": 14, "mode": "Nikon - Df - 14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/1390/nice/Nikon%20-%20Df%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", "sha256": "65b940bc1436e5df406bdfd83dcfeffcbed9da5b96db67fa03036ec840cecbf9"}, + {"file": "extra/nikon-d5100-4992x3280-14bit-lossless.nef", "model": "NIKON D5100", "size": [4992, 3280], "bits": 14, "mode": "14bit 14bit compressed (Lossless) (3:2)", "url": "https://raw.pixls.us/getfile.php/1597/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", "sha256": "3ce328830942381648be26b372a54b317fdb27b63c1aa9c2ee1fce076a2e1b4f"}, + {"file": "extra/nikon-d5100-4992x3280-14bit-uncompressed.nef", "model": "NIKON D5100", "size": [4992, 3280], "bits": 14, "mode": "14bit 14bit uncompressed (3:2)", "url": "https://raw.pixls.us/getfile.php/1598/nice/Nikon%20-%20D5100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.nef", "sha256": "2fb2f1b87a3b89de5ba9e56527f21a912ca624f2fa2a594cef108ee8d7180412"} + ] +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..a320210a6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); every Nikon body without CropArea is surveyed in `docs/nikon-bodies.md` (`cargo xtask raw-frame`): 36 model / size / depth groups whose data keeps masked or dark lines (D7000 and D5100 issue #976, D800 issue #914, Df, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D800E, D4 12-bit) crop to their camera JPEG's frame measured on raw.pixls.us CC0 samples; D3200, D3100, D200, D50 and Nikon 1 V1 / V2 still need more samples. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..27eaaccc2 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Nikon image areas of bodies without CropArea surveyed and measured, issues #976 / #914; NEF decode gaps listed) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -89,7 +89,7 @@ committed table is stale. |---|---:|---:|---:|---| | Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | +| Nikon | 16% | 91% | 12% | decoders cover NEF codings but HE and the compressed NEFs without a linearization table (D100, D1X, Coolpix P330 / P7700 / P7800, D4S and D810 small raw); few bodies verified; image area of every body without `CropArea` surveyed ([nikon-bodies.md](nikon-bodies.md)) | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | @@ -121,6 +121,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633 | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | +| NEF | compressed without a linearization table (D100, D1X, Coolpix P330 / P7700 / P7800 NRW, D4S and D810 small raw) | ⬜ | refused as unsupported; one raw.pixls.us sample each ([nikon-bodies.md](nikon-bodies.md)) | +| NEF | image area of bodies without `CropArea` | 🟡 | 36 model / size / depth groups cropped to their measured camera frame (D800 #914, D7000 #976, Df, D3, D300, D600 …); D3200, D3100, D200, D50, Nikon 1 V1 / V2 pending more samples: [nikon-bodies.md](nikon-bodies.md) | | ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024 | | ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | | ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` | @@ -266,6 +268,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Nikon: every body without `CropArea` surveyed ([nikon-bodies.md](nikon-bodies.md)); 36 model / size / depth groups cropped to their measured camera frame (#976, #914), 6 pending; NEF row no longer claims every coding but HE decodes (compressed NEFs without a linearization table listed) | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..722a73e91 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-11 +### Nikon raws without coloured edge bars +- Raws from older Nikon bodies no longer show a thin coloured or dark bar along one edge (the right edge, or the top + of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914). The + D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, + D60, D80 and D2Xs now show the area the camera itself frames, a few pixels smaller than before. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..e0f243ea3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -34,9 +34,15 @@ commands: build the browser app (apps/lightcraft-web) into <target>/web-site/; --serve serves it on http://127.0.0.1:<port> (default 8080) ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure) - corpus [--download] - show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 + corpus [--download [nikon]] + show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256; + --download nikon fetches the Nikon survey's 138 CC0 samples (docs/nikon-bodies/samples.json, ~2.7 GB) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + raw-frame [--json] FILE… + measure where each raw's embedded camera JPEG sits in its sensor data, which edge bands hold no + picture and what the decoder's crop keeps beyond the camera frame (any make with a full-size + embedded JPEG); print image-area entries for Nikon bodies that record no crop (MEASURED_BODIES in + crates/raw/src/vendor/nef.rs; survey in docs/nikon-bodies.md) "; fn main() -> ExitCode { @@ -52,8 +58,9 @@ fn main() -> ExitCode { Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), Some("ci") => cmd_ci(), - Some("corpus") => cmd_corpus(rest.contains(&"--download")), + Some("corpus") => cmd_corpus(rest.contains(&"--download"), rest.contains(&"nikon")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("raw-frame") => cmd_raw_frame(&rest), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) @@ -69,6 +76,20 @@ fn main() -> ExitCode { } } +/// `cargo xtask raw-frame FILE…`: runs the `raw_frame` example of `lightcraft-engine` on the files. +fn cmd_raw_frame(files: &[&str]) -> Result<(), String> { + if files.is_empty() { + return Err("raw-frame needs one or more raw files (several per camera, with detail across the frame)".into()); + } + eprintln!("$ cargo run --release -p lightcraft-engine --example raw_frame -- {}", files.join(" ")); + let status = cargo() + .args(["run", "--release", "-q", "-p", "lightcraft-engine", "--example", "raw_frame", "--"]) + .args(files) + .status() + .map_err(|e| format!("raw_frame: {e}"))?; + if status.success() { Ok(()) } else { Err("raw_frame failed".into()) } +} + /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { // `cargo run` sets it at run time; prefer that over the compile-time value, which goes stale @@ -887,7 +908,7 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ ), ]; -fn cmd_corpus(download: bool) -> Result<(), String> { +fn cmd_corpus(download: bool, nikon: bool) -> Result<(), String> { let corpus = root().join("corpus"); println!( "Test corpora live under {} (git-ignored, never committed). @@ -895,17 +916,26 @@ Tests that use a corpus skip cleanly when it is absent. corpus/raw/ raw.pixls.us samples (CC0) — lightcraft-raw decodes every file. corpus/images/ CC0 / public-domain JPEG/PNG/TIFF/HEIC samples (optional) + corpus/raw/nikon-inventory/ + the Nikon survey's CC0 samples (docs/nikon-bodies/samples.json; `--download nikon`) ", corpus.display() ); if !download { return Ok(()); } - let dest = corpus.join("raw"); + let (dest, samples) = if nikon { + (corpus.join("raw/nikon-inventory"), nikon_samples()?) + } else { + (corpus.join("raw"), RAW_SAMPLES.iter().map(|(n, u, s)| (n.to_string(), u.to_string(), s.to_string())).collect()) + }; std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?; let mut bad = Vec::new(); - for (name, url, sha256) in RAW_SAMPLES { + for (name, url, sha256) in &samples { let out = dest.join(name); + if let Some(dir) = out.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; + } if !out.exists() { let mut curl = Command::new("curl"); curl.args(["-fsSL", "-o"]).arg(&out).arg(url); @@ -932,6 +962,28 @@ Tests that use a corpus skip cleanly when it is absent. )) } +/// The Nikon survey's samples: (file under `corpus/raw/nikon-inventory/`, URL, sha256) from +/// `docs/nikon-bodies/samples.json`. +fn nikon_samples() -> Result<Vec<(String, String, String)>, String> { + let path = root().join("docs/nikon-bodies/samples.json"); + let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?; + let json: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + let files = json.get("files").and_then(|f| f.as_array()).ok_or_else(|| format!("{}: no `files` list", path.display()))?; + files + .iter() + .map(|f| { + let field = + |k: &str| f.get(k).and_then(|v| v.as_str()).map(str::to_string).ok_or_else(|| format!("{}: an entry has no `{k}`", path.display())); + let file = field("file")?; + // a relative path inside the folder: no `..`, no root + if file.split('/').any(|c| c.is_empty() || c == "." || c == "..") { + return Err(format!("{}: unsafe file name {file:?}", path.display())); + } + Ok((file, field("url")?, field("sha256")?)) + }) + .collect() +} + fn file_sha256(path: &Path) -> Result<String, String> { use sha2::{Digest, Sha256}; let mut file = std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; From bb925f6d2ecf957c962470ad3f9718ee20b5ceda Mon Sep 17 00:00:00 2001 From: OneSlips <alurkoff@yahoo.com> Date: Mon, 12 Oct 2026 00:08:11 +1000 Subject: [PATCH 021/315] LR-MASK-EFFECTS: Effect menu, New Effect Preset dialog and Manage window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Effect menu above the mask sliders is grouped (★ Favorites first, then groups in menu order), searchable, scrolls, works with ↑ ↓ Return, marks the matching effect (else Custom) and previews an effect on the photo while the pointer rests on it. Right-click: apply to all masks or to the same-named mask on the selected photos, update, rename, duplicate, star, move to group, delete (asks first). Reset Sliders; Update "<last effect>" with Current Settings. New Effect Preset dialog: name, group, which sliders (All / None / Changed Only), Amount, brush size/feather/flow. Manage Effect Presets window: search, inline rename, update with mask, duplicate, stars, groups, export, delete with confirmation. Names padded with runs of spaces (as LrC users do) are shown collapsed. Import toasts name the groups and the settings kept but not applied. New messages translated in every catalog. Docs: parity row and xmp-interop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de-formats.json | 14 +- crates/ui-egui/locales/es-formats.json | 14 +- crates/ui-egui/locales/fr-formats.json | 14 +- crates/ui-egui/locales/ja-formats.json | 14 +- crates/ui-egui/locales/pt-br-formats.json | 14 +- crates/ui-egui/locales/ru-formats.json | 14 +- crates/ui-egui/locales/uk-formats.json | 14 +- crates/ui-egui/locales/zh-hans-formats.json | 14 +- crates/ui-egui/locales/zh-hant-formats.json | 14 +- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 51 +- crates/ui-egui/src/panels/dialogs.rs | 32 +- crates/ui-egui/src/panels/mask_effects.rs | 703 ++++++++++++++++++++ crates/ui-egui/src/panels/masking.rs | 52 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 22 + crates/ui-egui/src/tests_mask_effects.rs | 215 ++++++ crates/ui-egui/src/tests_masking.rs | 14 +- docs/parity-checklist.md | 14 +- docs/xmp-interop.md | 34 +- 21 files changed, 1190 insertions(+), 81 deletions(-) create mode 100644 crates/ui-egui/src/panels/mask_effects.rs create mode 100644 crates/ui-egui/src/tests_mask_effects.rs diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..b517767b9 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + " in {g} group{}": " in {g} Gruppe(n){:.0}", + " · kept, not applied: {}": " · behalten, nicht angewendet: {}", + " — kept but not applied: {}": " — behalten, aber nicht angewendet: {}", + "Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden", + "Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?", + "Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?", + "Delete {n} effect preset{}? This can't be undone.": "{n} Effektvorgabe(n) löschen?{:.0} Dies kann nicht rückgängig gemacht werden.", + "Effect: {name}": "Effekt: {name}", + "Exported {} effect preset{}": "Exportierte Effektvorgaben: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} Maskeneffekt(e) importiert{:.0} (Maskieren → Effekt)", + "Update “{name}” with Current Settings": "„{name}“ mit aktuellen Einstellungen aktualisieren", + "{n} preset{}": "{n} Vorgabe(n){:.0}" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..0bdf189c9 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + " in {g} group{}": " en {g} grupo(s){:.0}", + " · kept, not applied: {}": " · conservado, no aplicado: {}", + " — kept but not applied: {}": " — se conservan pero no se aplican: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a «{mask}» en las fotos seleccionadas", + "Delete the effect preset “{name}”?": "¿Eliminar el ajuste preestablecido de efecto «{name}»?", + "Delete these {n} effect presets?": "¿Eliminar estos {n} ajustes preestablecidos de efecto?", + "Delete {n} effect preset{}? This can't be undone.": "¿Eliminar {n} ajuste(s) preestablecido(s) de efecto?{:.0} No se puede deshacer.", + "Effect: {name}": "Efecto: {name}", + "Exported {} effect preset{}": "Ajustes de efecto exportados: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efecto(s) de máscara importado(s){:.0} (Máscaras → Efecto)", + "Update “{name}” with Current Settings": "Actualizar «{name}» con los ajustes actuales", + "{n} preset{}": "{n} ajuste(s){:.0}" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..1012a9eea 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + " in {g} group{}": " dans {g} groupe(s){:.0}", + " · kept, not applied: {}": " · conservé, non appliqué : {}", + " — kept but not applied: {}": " — conservés mais non appliqués : {}", + "Apply to “{mask}” on Selected Photos": "Appliquer à « {mask} » sur les photos sélectionnées", + "Delete the effect preset “{name}”?": "Supprimer le paramètre prédéfini d’effet « {name} » ?", + "Delete these {n} effect presets?": "Supprimer ces {n} paramètres prédéfinis d’effet ?", + "Delete {n} effect preset{}? This can't be undone.": "Supprimer {n} paramètre(s) prédéfini(s) d’effet ?{:.0} Cette action est irréversible.", + "Effect: {name}": "Effet : {name}", + "Exported {} effect preset{}": "Paramètres d’effet exportés : {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} effet(s) de masque importé(s){:.0} (Masquage → Effet)", + "Update “{name}” with Current Settings": "Mettre à jour « {name} » avec les réglages actuels", + "{n} preset{}": "{n} paramètre(s){:.0}" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..137c3775e 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + " in {g} group{}": "({g}グループ){:.0}", + " · kept, not applied: {}": " · 保持(適用なし):{}", + " — kept but not applied: {}": " — 保持(適用なし):{}", + "Apply to “{mask}” on Selected Photos": "選択した写真の「{mask}」に適用", + "Delete the effect preset “{name}”?": "効果プリセット「{name}」を削除しますか?", + "Delete these {n} effect presets?": "これらの効果プリセット{n}件を削除しますか?", + "Delete {n} effect preset{}? This can't be undone.": "効果プリセット{n}件を削除しますか?{:.0}元に戻せません。", + "Effect: {name}": "効果:{name}", + "Exported {} effect preset{}": "効果プリセットを{}件書き出しました{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "マスク効果を{e}件読み込みました{:.0}(マスク → 効果)", + "Update “{name}” with Current Settings": "「{name}」を現在の設定で更新", + "{n} preset{}": "{n}件のプリセット{:.0}" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..65e69e9a8 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + " in {g} group{}": " em {g} grupo(s){:.0}", + " · kept, not applied: {}": " · mantido, não aplicado: {}", + " — kept but not applied: {}": " — mantidos, mas não aplicados: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a “{mask}” nas fotos selecionadas", + "Delete the effect preset “{name}”?": "Excluir a predefinição de efeito “{name}”?", + "Delete these {n} effect presets?": "Excluir estas {n} predefinições de efeito?", + "Delete {n} effect preset{}? This can't be undone.": "Excluir {n} predefinição(ões) de efeito?{:.0} Não é possível desfazer.", + "Effect: {name}": "Efeito: {name}", + "Exported {} effect preset{}": "Predefinições de efeito exportadas: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efeito(s) de máscara importado(s){:.0} (Máscaras → Efeito)", + "Update “{name}” with Current Settings": "Atualizar “{name}” com as configurações atuais", + "{n} preset{}": "{n} predefinição(ões){:.0}" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..1facff2a1 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + " in {g} group{}": " в группах: {g}{:.0}", + " · kept, not applied: {}": " · сохранено, не применяется: {}", + " — kept but not applied: {}": " — сохранено, но не применяется: {}", + "Apply to “{mask}” on Selected Photos": "Применить к «{mask}» на выбранных фото", + "Delete the effect preset “{name}”?": "Удалить пресет эффекта «{name}»?", + "Delete these {n} effect presets?": "Удалить эти пресеты эффектов ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Удалить пресеты эффектов ({n})?{:.0} Это нельзя отменить.", + "Effect: {name}": "Эффект: {name}", + "Exported {} effect preset{}": "Экспортировано пресетов эффектов: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Импортировано эффектов маски: {e}{:.0} (Маски → Эффект)", + "Update “{name}” with Current Settings": "Обновить «{name}» текущими настройками", + "{n} preset{}": "Пресетов: {n}{:.0}" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..5eb36d033 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + " in {g} group{}": " у групах: {g}{:.0}", + " · kept, not applied: {}": " · збережено, не застосовується: {}", + " — kept but not applied: {}": " — збережено, але не застосовується: {}", + "Apply to “{mask}” on Selected Photos": "Застосувати до «{mask}» на вибраних фото", + "Delete the effect preset “{name}”?": "Видалити пресет ефекту «{name}»?", + "Delete these {n} effect presets?": "Видалити ці пресети ефектів ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Видалити пресети ефектів ({n})?{:.0} Це не можна скасувати.", + "Effect: {name}": "Ефект: {name}", + "Exported {} effect preset{}": "Експортовано пресетів ефектів: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Імпортовано ефектів маски: {e}{:.0} (Маски → Ефект)", + "Update “{name}” with Current Settings": "Оновити «{name}» поточними налаштуваннями", + "{n} preset{}": "Пресетів: {n}{:.0}" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..e57dd2a45 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + " in {g} group{}": "({g} 个分组){:.0}", + " · kept, not applied: {}": " · 已保留,未应用:{}", + " — kept but not applied: {}": " — 已保留但未应用:{}", + "Apply to “{mask}” on Selected Photos": "应用到所选照片的“{mask}”", + "Delete the effect preset “{name}”?": "删除效果预设“{name}”?", + "Delete these {n} effect presets?": "删除这 {n} 个效果预设?", + "Delete {n} effect preset{}? This can't be undone.": "删除 {n} 个效果预设?{:.0}此操作无法撤销。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已导出 {} 个效果预设{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已导入 {e} 个蒙版效果{:.0}(蒙版 → 效果)", + "Update “{name}” with Current Settings": "用当前设置更新“{name}”", + "{n} preset{}": "{n} 个预设{:.0}" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..a30e6d595 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,17 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + " in {g} group{}": "({g} 個群組){:.0}", + " · kept, not applied: {}": " · 已保留,未套用:{}", + " — kept but not applied: {}": " — 已保留但未套用:{}", + "Apply to “{mask}” on Selected Photos": "套用到所選照片的「{mask}」", + "Delete the effect preset “{name}”?": "刪除效果預設集「{name}」?", + "Delete these {n} effect presets?": "刪除這 {n} 個效果預設集?", + "Delete {n} effect preset{}? This can't be undone.": "刪除 {n} 個效果預設集?{:.0}此動作無法復原。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已匯出 {} 個效果預設集{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已匯入 {e} 個遮色片效果{:.0}(遮色片 → 效果)", + "Update “{name}” with Current Settings": "以目前設定更新「{name}」", + "{n} preset{}": "{n} 個預設集{:.0}" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..1f68507ab 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -364,7 +364,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest if (r.is_err() && matches!( d, - crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } + crate::state::Dialog::Import { .. } + | crate::state::Dialog::SamModel { .. } + | crate::state::Dialog::SmartRules { .. } + | crate::state::Dialog::SaveEffect { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5df4b66bd..7ee7b7dbd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -88,6 +88,8 @@ mod tests_library_problem; #[cfg(test)] mod tests_lights_out; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_masking_layout; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81811fe0e..293527f13 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -184,6 +184,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Profiles & Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), + // Masking panel ▸ Effect menu + ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), + ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), + ("file.exportMaskEffects", "Export Effect Presets…", None, ""), ("file.importKeywords", "Import Keywords…", None, "File"), ("file.exportKeywords", "Export Keywords…", None, "File"), // Edit panel ▸ Curve ▸ Point Curve dropdown @@ -1562,7 +1566,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast let effects = v["effects"].as_array().map_or(0, Vec::len); if effects > 0 { - parts.push(crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if effects == 1 { "" } else { "s" }, e = effects)); + parts.push(crate::panels::mask_effects::import_toast(v)); } let profile_news = !parts.is_empty() || !problems.is_empty(); let msg = match (n, failed) { @@ -1696,6 +1700,49 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } return Some(r); } + "dialog.saveMaskEffect" => { + if app.session.active_mask.is_none() { + return Some(Err("select a mask first".into())); + } + crate::panels::mask_effects::open_save(app); + Ok(Value::Null) + } + "dialog.manageMaskEffects" => { + crate::panels::mask_effects::open_manage(app); + Ok(Value::Null) + } + "file.exportMaskEffects" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", ¶ms).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(&ctx, e.clone()), + } + return Some(r); + } "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1821,6 +1868,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), + "dialog.saveMaskEffect" => app.session.active().is_some() && app.session.active_mask.is_some(), "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..c14d086d5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -255,6 +255,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", + Dialog::SaveEffect { .. } => "New Effect Preset", + Dialog::ManageEffects { .. } => "Manage Effect Presets", + Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset", + Dialog::DeleteEffects { .. } => "Delete Effect Presets", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", Dialog::SynchronizeFolder { .. } => "Synchronize Folder", @@ -291,6 +295,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::AllMetadata { .. } => 620.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, + Dialog::ManageEffects { .. } => 560.0, _ => 380.0, }) .show(ctx, |ui| { @@ -1088,6 +1093,22 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { names } => { + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); + } Dialog::ConfirmDelete { count } => { let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); @@ -1177,6 +1198,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { || matches!( dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + | Dialog::ManageEffects { .. } ); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); @@ -1208,7 +1230,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::DenoiseModel { .. } => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1282,6 +1305,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::KeywordTag { .. } | Dialog::KeywordSet { .. } | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } ) => { app.toast(ctx, e) @@ -1680,9 +1704,13 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso Dialog::LightroomCatalog { catalog, .. } => { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) + } + Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs new file mode 100644 index 000000000..505dde129 --- /dev/null +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -0,0 +1,703 @@ +//! Mask effect presets in the UI: the Effect menu above a mask's sliders (grouped, searchable, +//! keyboard-driven, previews the effect on the photo while the pointer rests on it), the +//! New Effect Preset dialog (which sliders, Amount, brush settings) and the Manage Effect Presets +//! window (search, rename, update, duplicate, stars, groups, delete). Every change runs a +//! `mask.*Effect` command. + +use egui::{Sense, vec2}; +use lightcraft_develop::Mask; +use lightcraft_engine::cmd::mask_effects::{self as fx, FIELDS, MaskEffect}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A slider's label in the effect lists (the colour tint's hue and saturation are one "Color"). +pub fn field_label(k: &str) -> &'static str { + match k { + "color_hue" | "color_sat" => "Color", + _ => super::masking::LOCAL.iter().find(|s| s.id == k).map_or("", |s| s.label), + } +} + +/// The choices of the New Effect Preset checklist: each slider once (Color = hue + saturation). +fn checklist() -> Vec<&'static str> { + FIELDS.iter().copied().filter(|k| *k != "color_hue").collect() +} + +/// "Exposure +0.50 · Clarity −20" — what an effect sets, for the lists. +pub fn summary(e: &MaskEffect) -> String { + let fields = e.field_list(); + let mut parts: Vec<String> = fx::changed_fields(&e.adjust) + .iter() + .filter(|k| fields.contains(&k.as_str()) && k.as_str() != "color_hue") + .map(|k| { + let v = fx::get(&e.adjust, k); + match k.as_str() { + "exposure" => format!("{} {:+.2}", crate::i18n::tr(field_label(k)), v), + "color_sat" => format!("{} {:.0}°/{:.0}", crate::i18n::tr("Color"), e.adjust.color_hue, v), + _ => format!("{} {:+.0}", crate::i18n::tr(field_label(k)), v), + } + }) + .collect(); + if e.with_amount { + parts.push(format!("{} {:.0}", crate::i18n::tr("Amount"), e.adjust.amount)); + } + if parts.is_empty() { + parts.push(crate::i18n::tr("No change").to_string()); + } + parts.join(" · ").replace('-', "−") +} + +/// An effect's name as shown: runs of spaces collapsed (Lightroom users pad names to line up +/// parts of them in its menu; the stored name keeps them). +pub fn display_name(name: &str) -> String { + name.split_whitespace().collect::<Vec<_>>().join(" ") +} + +/// The effect the selected mask's tool last applied (what Update / Rename in the menu act on). +fn last_applied(app: &LightcraftApp, mask: &Mask) -> Option<String> { + let tool = fx::mask_tool(mask)?; + let name = app.session.mask_tool_effects.get(&tool)?; + app.session.mask_effects.iter().find(|e| e.name.eq_ignore_ascii_case(name)).map(|e| e.name.clone()) +} + +/// Apply an effect to `mask` (and take the brush settings it carries). +pub fn apply(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, params: Value) { + let mut p = params; + p["name"] = json!(name); + match app.run("mask.applyEffect", p) { + Ok(v) => take_brush(app, &v["brush"]), + Err(e) => app.toast_error(ctx, e), + } +} + +fn take_brush(app: &mut LightcraftApp, b: &Value) { + let Some(b) = b.as_object() else { return }; + let f = |k: &str| b.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(v) = f("size") { + app.ui.brush_size = (v as f32).clamp(0.002, 0.5); + } + if let Some(v) = f("feather") { + app.ui.brush_feather = (v as f32).clamp(0.0, 100.0); + } + if let Some(v) = f("flow") { + app.ui.brush_flow = (v as f32).clamp(1.0, 100.0); + } + if let Some(v) = b.get("auto_mask").and_then(Value::as_bool) { + app.ui.brush_auto_mask = v; + } +} + +/// The brush settings in use, as an effect carries them. +fn brush_now(app: &LightcraftApp) -> Value { + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "auto_mask": app.ui.brush_auto_mask}) +} + +/// Open the New Effect Preset dialog for the selected mask. +pub fn open_save(app: &mut LightcraftApp) { + let brush = app.ui.tool == "brush"; + app.ui.dialog = Some(Dialog::SaveEffect { + name: String::new(), + group: String::new(), + fields: checklist().iter().map(|k| k.to_string()).collect(), + amount: false, + brush, + }); +} + +/// Open the Manage Effect Presets window. +pub fn open_manage(app: &mut LightcraftApp) { + app.ui.dialog = + Some(Dialog::ManageEffects { search: String::new(), selected: Vec::new(), rename: None, confirm_delete: false, new_group: String::new() }); +} + +/// The menu's search text and keyboard highlight. +#[derive(Clone, Default)] +struct MenuState { + search: String, + highlight: Option<usize>, + focused: bool, +} + +fn state_id() -> egui::Id { + egui::Id::new("maskEffectMenuState") +} + +/// The effects the menu lists for `query`: (section heading, effect name, its row widget id). +fn menu_rows(effects: &[MaskEffect], query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_lowercase(); + let hit = |e: &MaskEffect| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q); + let mut rows = Vec::new(); + if q.is_empty() { + let mut favs: Vec<&MaskEffect> = effects.iter().filter(|e| e.favorite).collect(); + favs.sort_by(|a, b| fx::natural_cmp(&a.name, &b.name)); + rows.extend(favs.into_iter().map(|e| ("★ Favorites".to_string(), e.name.clone(), format!("maskEffectFav:{}", e.name)))); + } + for (group, ix) in fx::menu_order(effects) { + for e in ix.iter().filter_map(|i| effects.get(*i)).filter(|e| hit(e)) { + rows.push((group.clone(), e.name.clone(), format!("maskEffect:{}", e.name))); + } + } + rows +} + +/// The Effect menu above the mask's sliders. +pub fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); + let current = fx::matching_effect(&app.session, &mask.adjust); + let label = current.as_deref().map(display_name).unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); + let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); + if r.clicked() { + ui.data_mut(|d| d.insert_temp(state_id(), MenuState::default())); + } + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| menu(app, ui, mask, current.as_deref())); + }); + }); +} + +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option<&str>) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(260.0); + ui.set_max_width(320.0); + let mut st: MenuState = ui.data(|d| d.get_temp(state_id())).unwrap_or_default(); + let before = st.search.clone(); + let r = crate::text_field::TextField::singleline("maskEffectSearch", &mut st.search) + .hint(crate::i18n::tr("Search effects")) + .width(f32::INFINITY) + .show(ui); + if !st.focused { + r.response.request_focus(); + st.focused = true; + } + if st.search != before { + st.highlight = None; + } + let rows = menu_rows(&app.session.mask_effects, &st.search); + // ↑ ↓ move through the list, Return applies + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.key_pressed(egui::Key::Enter) && !rows.is_empty(), + ) + }); + let moved = down || up; + if !rows.is_empty() { + let last = rows.len() - 1; + st.highlight = match (st.highlight, down, up) { + (None, true, _) => Some(0), + (None, _, true) => Some(last), + (Some(h), true, _) => Some((h + 1).min(last)), + (Some(h), _, true) => Some(h.saturating_sub(1)), + (h, _, _) => h.filter(|h| *h <= last), + }; + } + let mut chosen: Option<String> = None; + if enter { + chosen = rows.get(st.highlight.unwrap_or(0)).map(|(_, n, _)| n.clone()); + } + let mut hovered: Option<String> = None; + if app.session.mask_effects.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect presets yet")).color(t.text_dim)); + } else if rows.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect matches")).color(t.text_dim)); + } + egui::ScrollArea::vertical().id_salt("maskEffectList").max_height(420.0).auto_shrink([false, true]).show(ui, |ui| { + let mut section: Option<&str> = None; + for (i, (group, name, widget)) in rows.iter().enumerate() { + if section != Some(group.as_str()) { + section = Some(group.as_str()); + if !group.is_empty() { + ui.add_space(4.0); + let heading = if group == "★ Favorites" { crate::i18n::tr("★ Favorites").to_string() } else { group.clone() }; + ui.label(egui::RichText::new(heading).font(t.semibold(11.5)).color(t.text_dim)); + } + } + let fav = app.session.mask_effects.iter().any(|e| e.favorite && &e.name == name); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(rect) { + register(ui.ctx(), widget.clone(), rect); + } + let lit = st.highlight == Some(i) || resp.hovered(); + if lit { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + if current == Some(name.as_str()) { + ui.painter().text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, "✓", t.font(12.0), t.accent); + } + let room = rect.width() - 22.0 - if fav { 16.0 } else { 0.0 }; + let g = crate::widgets::one_line(ui.painter(), &display_name(name), t.font(12.5), t.text, room); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); + if fav { + ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + } + if moved && st.highlight == Some(i) { + resp.scroll_to_me(None); + } + let resp = if elided { resp.on_hover_text(display_name(name)) } else { resp }; + if resp.hovered() || (st.highlight == Some(i) && moved) { + hovered = Some(name.clone()); + } + if resp.clicked() { + chosen = Some(name.clone()); + } + resp.context_menu(|ui| row_menu(app, ui, mask, name, fav)); + } + }); + // resting on an effect previews it on the photo (no history entry) + if let Some(name) = hovered.or_else(|| st.highlight.and_then(|h| rows.get(h)).map(|(_, n, _)| n.clone())) { + preview(app, mask, &name); + } + ui.data_mut(|d| d.insert_temp(state_id(), st)); + if let Some(name) = chosen { + apply(app, ui.ctx(), &name, json!({"id": mask.id})); + ui.close(); + return; + } + ui.separator(); + let item = |ui: &mut egui::Ui, id: &str, text: String, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(text).frame(false)); + register(ui.ctx(), id.to_string(), r.rect); + r.clicked() + }; + if item(ui, "maskEffectMenu:reset", crate::i18n::tr("Reset Sliders").to_string(), true) { + let zero: serde_json::Map<String, Value> = FIELDS.iter().map(|k| (k.to_string(), json!(0.0))).collect(); + let _ = app.run("mask.adjust", json!({"id": mask.id, "values": zero})); + if let Some(tool) = fx::mask_tool(mask) { + let _ = app.run("mask.toolEffect", json!({"tool": tool})); + } + ui.close(); + } + if item(ui, "maskEffectMenu:save", crate::i18n::tr("Save Current Settings as New Preset…").to_string(), true) { + open_save(app); + ui.close(); + } + if let Some(last) = last_applied(app, mask) { + let text = crate::i18n::tr_format!("Update “{name}” with Current Settings", name = last); + if item(ui, "maskEffectMenu:update", text, current != Some(last.as_str())) { + let _ = app.run("mask.updateEffect", json!({"name": last, "id": mask.id})); + ui.close(); + } + } + if item(ui, "maskEffectMenu:manage", crate::i18n::tr("Manage Presets…").to_string(), true) { + open_manage(app); + ui.close(); + } + if item(ui, "maskEffectMenu:import", crate::i18n::tr("Import Presets…").to_string(), app.services.pick_preset_files.is_some()) { + let _ = app.run("file.importPresets", json!({})); + ui.close(); + } + let any = !app.session.mask_effects.is_empty(); + if item(ui, "maskEffectMenu:export", crate::i18n::tr("Export Presets…").to_string(), any && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({})); + ui.close(); + } +} + +/// The photo with `name` applied to `mask`, shown while the pointer rests on it. +fn preview(app: &mut LightcraftApp, mask: &Mask, name: &str) { + let Some(id) = app.session.active() else { return }; + let Some(e) = app.session.mask_effects.iter().find(|e| e.name == name) else { return }; + let Some(d) = app.session.develop_of(id) else { return }; + let mut d = (*d).clone(); + let Some(m) = d.masks.iter_mut().find(|m| m.id == mask.id) else { return }; + m.adjust = e.apply(&m.adjust); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Effect: {name}", name = display_name(name)), settings: d }); +} + +/// The right-click menu of an effect in the Effect menu. +fn row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, name: &str, fav: bool) { + let run = |app: &mut LightcraftApp, ui: &mut egui::Ui, text: &str, cmd: &str, p: Value| { + let r = ui.button(crate::i18n::tr(text)); + register(ui.ctx(), format!("maskEffectRow:{text}"), r.rect); + if r.clicked() { + if let Err(e) = app.run(cmd, p) { + app.toast_error(ui.ctx(), e); + } + ui.close(); + } + }; + let masks = app.session.active().and_then(|id| app.session.develop_of(id)).map_or(0, |d| d.masks.len()); + if ui.add_enabled(masks > 1, egui::Button::new(crate::i18n::tr("Apply to All Masks"))).clicked() { + apply(app, ui.ctx(), name, json!({"all": true})); + ui.close(); + } + if app.session.selection.ids.len() > 1 { + let text = crate::i18n::tr_format!("Apply to “{mask}” on Selected Photos", mask = mask.name); + if ui.button(text).on_hover_text(crate::i18n::tr("Also the masks of the same name on the other selected photos")).clicked() { + apply(app, ui.ctx(), name, json!({"id": mask.id, "photos": "selected"})); + ui.close(); + } + } + ui.separator(); + run(app, ui, "Update with Current Settings", "mask.updateEffect", json!({"name": name, "id": mask.id})); + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Effect Preset", "Preset name", name, "mask.renameEffect", json!({"name": name}), "to"); + ui.close(); + } + run(app, ui, "Duplicate", "mask.duplicateEffect", json!({"name": name})); + run(app, ui, if fav { "Remove from Favorites" } else { "Add to Favorites" }, "mask.favoriteEffect", json!({"name": name, "favorite": !fav})); + ui.menu_button(crate::i18n::tr("Move to Group"), |ui| group_menu(app, ui, &[name.to_string()])); + ui.separator(); + if ui.button(crate::i18n::tr("Delete…")).clicked() { + app.ui.dialog = Some(Dialog::DeleteEffects { names: vec![name.to_string()] }); + ui.close(); + } +} + +/// The groups to move effects `names` to, None, and New Group…. +fn group_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, names: &[String]) { + let groups: Vec<String> = fx::menu_order(&app.session.mask_effects).into_iter().map(|(g, _)| g).filter(|g| !g.is_empty()).collect(); + for g in groups.iter().map(String::as_str).chain(std::iter::once("")) { + let label = if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.to_string() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": g})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("New Group…")).clicked() { + let names = json!({"names": names}); + crate::panels::dialogs::prompt(app, "New Effect Group", "Group name", "", "mask.setEffectGroup", names, "group"); + ui.close(); + } +} + +/// The New Effect Preset dialog's body. +pub fn save_body( + app: &LightcraftApp, + ui: &mut egui::Ui, + name: &mut String, + group: &mut String, + fields: &mut Vec<String>, + amount: &mut bool, + brush: &mut bool, +) -> bool { + let t = Tokens::get(ui.ctx()); + let r = crate::text_field::TextField::singleline("field:effectName", name).hint(crate::i18n::tr("Preset name")).width(f32::INFINITY).show(ui); + if !r.editing && name.is_empty() && ui.memory(|m| m.focused().is_none()) { + r.response.request_focus(); + } + let enter = r.ending == Some(crate::text_field::Ending::Return); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectGroup", group) + .hint(crate::i18n::tr("Group (optional)")) + .width(ui.available_width() - 30.0) + .show(ui); + let pick = ui.small_button("…").on_hover_text(crate::i18n::tr("Choose a group")); + register(ui.ctx(), "button:effectGroupPick", pick.rect); + egui::Popup::menu(&pick).show(|ui| { + for (g, _) in fx::menu_order(&app.session.mask_effects) { + if ui.button(if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.clone() }).clicked() { + *group = g; + } + } + }); + }); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + let adjust = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .and_then(|d| app.session.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))); + let changed = adjust.as_ref().map(fx::changed_fields).unwrap_or_default(); + let list = checklist(); + ui.columns(2, |cols| { + for (i, k) in list.iter().enumerate() { + let mut on = fields.iter().any(|f| f == k); + let text = egui::RichText::new(crate::i18n::tr(field_label(k))); + // the sliders this mask changes stand out; the others are dimmed + let text = if changed.iter().any(|c| c == k) { text.color(t.text).strong() } else { text.color(t.text_dim) }; + let r = cols[i % 2].checkbox(&mut on, text); + register(&r.ctx, format!("effectInclude:{k}"), r.rect); + if r.changed() { + fields.retain(|f| f != k); + if on { + fields.push(k.to_string()); + } + } + } + }); + ui.horizontal(|ui| { + for (id, text) in [("all", "All"), ("none", "None"), ("changed", "Changed Only")] { + let b = ui.small_button(crate::i18n::tr(text)); + register(ui.ctx(), format!("effectInclude:{id}"), b.rect); + if b.clicked() { + *fields = match id { + "all" => list.iter().map(|k| k.to_string()).collect(), + "none" => Vec::new(), + _ => changed.iter().filter(|k| k.as_str() != "color_hue").cloned().collect(), + }; + } + } + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.")) + .color(t.text_dim) + .size(11.0), + ); + let r = ui.checkbox(amount, crate::i18n::tr("Include Amount")); + register(ui.ctx(), "check:effectAmount", r.rect); + let r = ui.checkbox(brush, crate::i18n::tr("Include brush size, feather and flow")); + register(ui.ctx(), "check:effectBrush", r.rect); + enter +} + +/// Save from the New Effect Preset dialog. +pub fn save_confirm(app: &mut LightcraftApp, name: &str, group: &str, fields: &[String], amount: bool, brush: bool) -> Result<Value, String> { + if name.trim().is_empty() { + return Err(crate::i18n::tr("Name the preset").to_string()); + } + if fields.is_empty() { + return Err(crate::i18n::tr("Choose at least one setting to include").to_string()); + } + let mut keep: Vec<String> = fields.to_vec(); + if keep.iter().any(|f| f == "color_sat") { + keep.push("color_hue".into()); + } + let all = checklist().iter().all(|k| keep.iter().any(|f| f == k)); + let mut p = json!({"name": name.trim(), "group": group.trim(), "amount": amount}); + p["fields"] = if all { json!("all") } else { json!(keep) }; + if brush { + p["brush"] = brush_now(app); + } + app.run("mask.saveEffect", p) +} + +/// The Manage Effect Presets window's body. +pub fn manage_body( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + search: &mut String, + selected: &mut Vec<String>, + rename: &mut Option<(String, String)>, + confirm_delete: &mut bool, + new_group: &mut String, +) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(500.0); + // effects that went away (deleted, renamed) leave the selection + selected.retain(|n| app.session.mask_effects.iter().any(|e| &e.name == n)); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectSearch", search) + .hint(crate::i18n::tr("Search effects")) + .width(ui.available_width() - 120.0) + .show(ui); + let n = app.session.mask_effects.len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} preset{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + }); + let q = search.trim().to_lowercase(); + let effects = app.session.mask_effects.clone(); + let mut toggle_star: Option<(String, bool)> = None; + let mut commit_rename: Option<(String, String)> = None; + egui::ScrollArea::vertical().id_salt("manageEffects").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + for (group, ix) in fx::menu_order(&effects) { + let list: Vec<&MaskEffect> = ix + .iter() + .filter_map(|i| effects.get(*i)) + .filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q)) + .collect(); + if list.is_empty() { + continue; + } + ui.add_space(4.0); + let heading = if group.is_empty() { crate::i18n::tr("Ungrouped").to_string() } else { group.clone() }; + let h = ui.add( + egui::Label::new(egui::RichText::new(format!("{heading} ({})", list.len())).font(t.semibold(12.0)).color(t.text_label)) + .sense(Sense::click()), + ); + register(ui.ctx(), format!("effectGroup:{group}"), h.rect); + if h.clicked() { + // a click on the heading selects the group + *selected = list.iter().map(|e| e.name.clone()).collect(); + } + for e in list { + let sel = selected.contains(&e.name); + ui.horizontal(|ui| { + let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); + if ui.is_rect_visible(star.rect) { + register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); + } + if star.clicked() { + toggle_star = Some((e.name.clone(), !e.favorite)); + } + if let Some((old, text)) = rename.as_mut().filter(|(old, _)| *old == e.name) { + let r = crate::text_field::TextField::singleline("field:effectRename", text).width(220.0).select_on_focus(true).show(ui); + if !r.editing && r.ending.is_none() { + r.response.request_focus(); + } + if r.cancelled() { + *rename = None; + } else if r.committed() { + commit_rename = Some((old.clone(), text.trim().to_string())); + } + return; + } + let (rect, r) = ui.allocate_exact_size(vec2(240.0, 22.0), Sense::click()); + if ui.is_rect_visible(rect) { + register(ui.ctx(), format!("effectRow:{}", e.name), rect); + } + if sel { + ui.painter().rect_filled(rect, 3.0, t.tool_active); + } else if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let g = crate::widgets::one_line(ui.painter(), &display_name(&e.name), t.font(12.5), t.text, rect.width() - 8.0); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(4.0, -g.size().y / 2.0), g, t.text); + let r = if elided { r.on_hover_text(display_name(&e.name)) } else { r }; + if r.clicked() { + let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); + if add { + if sel { + selected.retain(|n| n != &e.name); + } else { + selected.push(e.name.clone()); + } + } else { + *selected = vec![e.name.clone()]; + } + } + if r.double_clicked() { + *rename = Some((e.name.clone(), e.name.clone())); + } + let mut info = summary(e); + if !e.extra.is_empty() { + info += &crate::i18n::tr_format!(" · kept, not applied: {}", e.extra.keys().cloned().collect::<Vec<_>>().join(", ")); + } + ui.add(egui::Label::new(egui::RichText::new(info).color(t.text_dim).size(11.0)).truncate()); + }); + } + } + }); + if let Some((name, on)) = toggle_star { + let _ = app.run("mask.favoriteEffect", json!({"name": name, "favorite": on})); + } + if let Some((old, to)) = commit_rename { + *rename = None; + if !to.is_empty() && to != old { + match app.run("mask.renameEffect", json!({"name": old, "to": to})) { + Ok(_) => *selected = vec![to], + Err(e) => app.toast_error(ui.ctx(), e), + } + } + } + ui.separator(); + let n = selected.len(); + let one = (n == 1).then(|| selected.first().cloned()).flatten(); + let mask = app.session.active_mask.filter(|_| app.session.active().is_some()); + if *confirm_delete && n > 0 { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Delete {n} effect preset{}? This can't be undone.", if n == 1 { "" } else { "s" }, n = n)) + .color(t.text), + ); + ui.horizontal(|ui| { + let d = ui.button(crate::i18n::tr("Delete")); + register(ui.ctx(), "button:effectDeleteConfirm", d.rect); + if d.clicked() { + let _ = app.run("mask.deleteEffect", json!({"names": selected.clone()})); + selected.clear(); + *confirm_delete = false; + } + let c = ui.button(crate::i18n::tr("Cancel")); + register(ui.ctx(), "button:effectDeleteCancel", c.rect); + if c.clicked() { + *confirm_delete = false; + } + }); + return; + } + ui.horizontal_wrapped(|ui| { + let button = |ui: &mut egui::Ui, id: &str, text: &str, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(text))); + register(ui.ctx(), format!("button:effect{id}"), r.rect); + r.clicked() + }; + if button(ui, "Apply", "Apply", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + apply(app, ui.ctx(), name, json!({"id": mid})); + } + if button(ui, "Update", "Update with Mask", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + let _ = app.run("mask.updateEffect", json!({"name": name, "id": mid})); + } + if button(ui, "Rename", "Rename", one.is_some()) + && let Some(name) = &one + { + *rename = Some((name.clone(), name.clone())); + } + if button(ui, "Duplicate", "Duplicate", one.is_some()) + && let Some(name) = &one + && let Ok(v) = app.run("mask.duplicateEffect", json!({"name": name})) + && let Some(copy) = v["name"].as_str() + { + *selected = vec![copy.to_string()]; + } + if button(ui, "Favorite", "★ Favorite", n > 0) { + let all_fav = selected.iter().all(|s| effects.iter().any(|e| &e.name == s && e.favorite)); + let _ = app.run("mask.favoriteEffect", json!({"names": selected.clone(), "favorite": !all_fav})); + } + let g = ui.add_enabled(n > 0, egui::Button::new(crate::i18n::tr("Move to Group…"))); + register(ui.ctx(), "button:effectGroup", g.rect); + egui::Popup::menu(&g).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let names = selected.clone(); + for (grp, _) in fx::menu_order(&app.session.mask_effects) { + let label = if grp.is_empty() { crate::i18n::tr("None").to_string() } else { grp.clone() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": grp})); + ui.close(); + } + } + ui.separator(); + let r = crate::text_field::TextField::singleline("field:effectNewGroup", new_group) + .hint(crate::i18n::tr("New group — Return")) + .width(180.0) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) && !new_group.trim().is_empty() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": new_group.trim()})); + new_group.clear(); + ui.close(); + } + }); + if button(ui, "Export", "Export…", n > 0 && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({"names": selected.clone()})); + } + if button(ui, "Delete", "Delete…", n > 0) { + *confirm_delete = true; + } + }); + if mask.is_none() { + ui.label(egui::RichText::new(crate::i18n::tr("Select a mask to apply or update presets.")).color(t.text_dim).size(11.0)); + } +} + +/// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, +/// in how many groups, and the settings they carry that LightCraft can't apply. +pub fn import_toast(v: &Value) -> String { + let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); + let n = rows.len(); + let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); + let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); + groups.sort_unstable(); + groups.dedup(); + if !groups.is_empty() { + msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + } + let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + } + msg +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 5e3f8dc6d..1cfe5ab27 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -16,56 +16,6 @@ const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track } } -/// The Effect menu above the mask's sliders: apply, save, import and delete effect presets. -fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, adjust: &LocalAdjustments) { - use lightcraft_engine::cmd::mask_effects::matching_effect; - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); - let current = matching_effect(&app.session, adjust); - let label = current.clone().unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); - let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); - egui::Popup::menu(&r).show(|ui| { - ui.set_min_width(220.0); - let effects: Vec<String> = app.session.mask_effects.iter().map(|e| e.name.clone()).collect(); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for name in &effects { - let r = ui.selectable_label(current.as_deref() == Some(name.as_str()), name); - register(ui.ctx(), format!("maskEffect:{name}"), r.rect); - if r.clicked() { - let _ = app.run("mask.applyEffect", json!({"name": name})); - ui.close(); - } - r.context_menu(|ui| { - if ui.button(crate::i18n::tr("Delete Preset")).clicked() { - let _ = app.run("mask.deleteEffect", json!({"name": name})); - ui.close(); - } - }); - } - }); - if !effects.is_empty() { - ui.separator(); - } - let r = ui.button(crate::i18n::tr("Save Current Settings as New Preset…")); - register(ui.ctx(), "maskEffectMenu:save", r.rect); - if r.clicked() { - crate::panels::dialogs::prompt(app, "Save Effect Preset", "Preset name", "", "mask.saveEffect", json!({}), "name"); - ui.close(); - } - if ui.add_enabled(app.services.pick_preset_files.is_some(), egui::Button::new(crate::i18n::tr("Import Presets…"))).clicked() { - let _ = app.run("file.importPresets", json!({})); - ui.close(); - } - if !effects.is_empty() { - ui.label(egui::RichText::new(crate::i18n::tr("Right-click a preset to delete it")).font(t.font(11.0)).color(t.text_dim)); - } - }); - }); - }); -} - /// Local adjustment sliders (key = `LocalAdjustments` field name). pub const LOCAL: &[ControlSpec] = &[ spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp), @@ -418,7 +368,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui); - effect_row(app, ui, &m.adjust); + super::mask_effects::effect_row(app, ui, &m); for s in LOCAL { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..1fd55c9a6 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -17,6 +17,7 @@ pub mod keyword_list; pub mod keywording; pub mod left; pub mod library_problem; +pub mod mask_effects; pub mod masking; pub mod notices; pub mod people; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..16ddddbf7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -963,6 +963,28 @@ pub enum Dialog { folder: String, catalog: Option<String>, }, + /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, + /// the colour as `color_sat`), whether it carries Amount and the brush settings. + SaveEffect { + name: String, + group: String, + fields: Vec<String>, + amount: bool, + brush: bool, + }, + /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), + /// a delete waiting for confirmation, a new group's name being typed. + ManageEffects { + search: String, + selected: Vec<String>, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec<String>, + }, /// Confirm moving photos to Recently Deleted. ConfirmDelete { count: usize, diff --git a/crates/ui-egui/src/tests_mask_effects.rs b/crates/ui-egui/src/tests_mask_effects.rs new file mode 100644 index 000000000..53377c7cd --- /dev/null +++ b/crates/ui-egui/src/tests_mask_effects.rs @@ -0,0 +1,215 @@ +//! Headless tests of mask effect presets: the Effect menu (groups, search, keys, hover preview, +//! right-click actions), the New Effect Preset dialog and the Manage Effect Presets window. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "panel.masking", json!({})); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn req(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{method}: {r}"); +} + +fn click(h: &mut Headless, id: &str) { + req(h, "ui.clickWidget", json!({"id": id})); +} + +fn mask(h: &Headless) -> lightcraft_develop::Mask { + let id = h.app.session.active().expect("active photo"); + let d = h.app.session.develop_of(id).unwrap_or_default(); + d.masks.iter().find(|m| Some(m.id) == h.app.session.active_mask).cloned().expect("a selected mask") +} + +/// Three effects in two groups, one starred; a radial mask selected. +fn setup() -> Headless { + let mut h = detail(); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + for (name, values, group) in [ + ("01 Dodge", json!({"exposure": 0.5}), ""), + ("12 Sky", json!({"highlights": -60}), "Detail"), + ("21 Skin", json!({"clarity": -40}), "Retouch"), + ] { + exec(&mut h, "mask.adjust", json!({"values": values})); + exec(&mut h, "mask.saveEffect", json!({"name": name, "group": group})); + let zero: serde_json::Map<String, serde_json::Value> = + lightcraft_engine::cmd::mask_effects::FIELDS.iter().map(|k| (k.to_string(), json!(0))).collect(); + exec(&mut h, "mask.adjust", json!({"values": zero})); + } + exec(&mut h, "mask.favoriteEffect", json!({"name": "21 Skin"})); + h.step(); + h +} + +#[test] +fn effect_menu_groups_searches_and_applies_with_keys() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec<String> = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + for id in ["maskEffect:01 Dodge", "maskEffect:12 Sky", "maskEffect:21 Skin", "maskEffectFav:21 Skin", "maskEffectSearch", "maskEffectMenu:manage"] + { + assert!(ids.iter().any(|x| x == id), "{id} in {ids:?}"); + } + // the pointer resting on an effect previews it on the photo + req(&mut h, "ui.hoverWidget", json!({"id": "maskEffect:12 Sky"})); + h.step(); + let preview = h.app.hover_preview.as_ref().expect("a hover preview"); + assert_eq!(preview.settings.masks.last().map(|m| m.adjust.highlights), Some(-60.0)); + assert_eq!(mask(&h).adjust.highlights, 0.0, "only previewed"); + // typing filters; ↓ and Return apply the first match + req(&mut h, "ui.text", json!({"text": "skin"})); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec<String> = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + assert!(!ids.iter().any(|x| x == "maskEffect:12 Sky"), "filtered out: {ids:?}"); + req(&mut h, "ui.key", json!({"key": "ArrowDown"})); + h.step(); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert_eq!(mask(&h).adjust.clarity, -40.0); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "21 Skin"); + // a new radial mask now starts with it (the menu remembers per tool) + exec(&mut h, "mask.add", json!({"kind": "radial"})); + assert_eq!(mask(&h).adjust.clarity, -40.0); +} + +#[test] +fn effect_menu_right_click_and_delete_asks_first() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + // the row's actions are commands; delete goes through a confirmation + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3, "nothing deleted before confirming"); + click(&mut h, "button:dialogCancel"); + assert_eq!(h.app.session.mask_effects.len(), 3); + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + req(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(h.app.session.mask_effects.len(), 2); + // Reset Sliders zeroes the mask and forgets the tool's effect + exec(&mut h, "mask.applyEffect", json!({"name": "12 Sky"})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:reset"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, 0.0); + assert!(h.app.session.mask_tool_effects.is_empty()); +} + +#[test] +fn save_dialog_picks_sliders_amount_and_brush() { + let mut h = setup(); + exec(&mut h, "mask.adjust", json!({"values": {"shadows": 30, "texture": 15, "amount": 70}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:save"); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SaveEffect { .. })), "{:?}", h.app.ui.dialog); + // nothing named: an error, the dialog stays + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "{r}"); + click(&mut h, "effectInclude:changed"); + h.step(); + click(&mut h, "check:effectAmount"); + h.step(); + req(&mut h, "ui.clickWidget", json!({"id": "field:effectName"})); + req(&mut h, "ui.text", json!({"text": "Lift"})); + h.step(); + let Some(Dialog::SaveEffect { fields, amount, .. }) = &h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((fields.clone(), *amount), (vec!["shadows".to_string(), "texture".into()], true)); + req(&mut h, "ui.dialog.confirm", json!({})); + let e = h.app.session.mask_effects.iter().find(|e| e.name == "Lift").expect("saved"); + assert_eq!((e.fields.clone(), e.with_amount, e.adjust.amount), (vec!["shadows".to_string(), "texture".into()], true, 70.0)); + assert!(e.brush.is_none(), "not the brush tool"); +} + +#[test] +fn manage_window_renames_groups_stars_duplicates_and_deletes() { + let mut h = setup(); + exec(&mut h, "dialog.manageMaskEffects", json!({})); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. }))); + // select and rename inline + click(&mut h, "effectRow:12 Sky"); + h.step(); + click(&mut h, "button:effectRename"); + h.step(); + req(&mut h, "ui.key", json!({"key": "A", "modifiers": {"command": true}})); + req(&mut h, "ui.text", json!({"text": "12 Sky Darken"})); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert!( + h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken"), + "{:?}", + h.app.session.mask_effects.iter().map(|e| &e.name).collect::<Vec<_>>() + ); + // duplicate selects the copy; star it; apply it to the mask + click(&mut h, "button:effectDuplicate"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["12 Sky Darken copy".to_string()]); + click(&mut h, "effectStar:12 Sky Darken copy"); + h.step(); + assert!(h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken copy" && e.favorite)); + click(&mut h, "button:effectApply"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, -60.0); + // search narrows the list; a group heading selects the group + req(&mut h, "ui.clickWidget", json!({"id": "field:effectSearch"})); + req(&mut h, "ui.text", json!({"text": "retouch"})); + h.step(); + click(&mut h, "effectGroup:Retouch"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["21 Skin".to_string()]); + // delete asks first, inside the window + click(&mut h, "button:effectDelete"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 4); + click(&mut h, "button:effectDeleteConfirm"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. })), "stays open"); +} + +#[test] +fn imported_effects_toast_reports_groups_and_what_was_kept() { + let v = json!({"effects": [ + {"name": "a", "group": "Detail", "unmapped": ["Grain"]}, + {"name": "b", "group": "Retouch", "unmapped": ["Point Color", "Grain"]}, + {"name": "c", "group": "", "unmapped": []}, + ]}); + let msg = crate::panels::mask_effects::import_toast(&v); + assert!(msg.contains("3 mask effects") && msg.contains("2 groups") && msg.contains("Grain, Point Color"), "{msg}"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index eb2b18a22..179d0e4a5 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -137,9 +137,6 @@ fn new_brush_starts_a_separate_mask_in_paint_mode() { fn mask_adjustments_hide_overlay_without_disabling_the_mask() { use lightcraft_pipeline::Overlay; let mut h = detail("panel.masking"); - // tall enough for the Exposure slider below the Effect menu to be on screen - let r = h.request("ui.resize", json!({"width": 1200, "height": 1000}), T); - assert_eq!(r["ok"], true, "{r}"); exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); assert!(h.app.ui.mask_overlay); @@ -1426,16 +1423,13 @@ fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { exec(&mut h, "mask.add", json!({"kind": "radial"})); exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}})); h.step(); - // save the mask's sliders from the menu (name prompt) + // save the mask's sliders from the menu (the New Effect Preset dialog) click(&mut h, "dropdown:maskEffect"); click(&mut h, "maskEffectMenu:save"); - let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens"); + let mut dlg = h.app.ui.dialog.take().expect("the save dialog opens"); match &mut dlg { - crate::state::Dialog::TextPrompt { command, value, .. } => { - assert_eq!(command, "mask.saveEffect"); - *value = "Lift".into(); - } - _ => panic!("a name prompt opens"), + crate::state::Dialog::SaveEffect { name, .. } => *name = "Lift".into(), + d => panic!("the save dialog opens: {d:?}"), } let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); assert!(r.is_ok(), "{r:?}"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..1c392e9b1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; LR-MASK-EFFECTS: local adjustment presets with Lightroom Classic import) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| **Total** | 409 | 51 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.7%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 172. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | @@ -302,7 +302,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | +| LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | @@ -787,6 +788,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-MASK-EFFECTS (new, ✅): local adjustment (effect / brush) presets: Effect menu, New Effect Preset dialog, Manage window, groups and favorites; imports Lightroom Classic local `.lrtemplate`, Camera Raw local `.xmp`, `.lceffect` and `.zip`. LR-IMP-PROFILES and LR-MASK-SLIDERS notes updated | | 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 | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 3669d1199..066afe2ea 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -192,13 +192,45 @@ Applying a preset adds its masks to the photo's own (masks the photo already has slider scales them. A sidecar's masks replace the photo's, because a sidecar holds the whole edit. Components we can't carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`. +## Local adjustment presets (mask effects) + +`mask.importEffects {path | paths, group?, dryRun?}` (and `preset.import`, File ▸ Import Profiles & Presets…, drag & drop) +reads these into the Masking panel's Effect menu (`crates/engine/src/cmd/mask_effect_files.rs`): + +| File | How it is read | +|---|---| +| Lightroom Classic `.lrtemplate` (`%APPDATA%\Adobe\Lightroom\Local Adjustment Presets`, `~/Library/Application Support/Adobe/Lightroom/Local Adjustment Presets`) | a Lua table with `type = "LocalizedAdjustmentPreset"`; `title` (else `internalName`, else the file name) → name; `value` holds the `Local*` field names in lower camel case: `exposure2012` → `LocalExposure2012`, mapped as in the table above (`exposure`, `contrast`, `clarity` are the older process version's; the 2012 fields win); `luminanceNoise` → Noise, `toningHue` / `toningSaturation` → Color | +| Camera Raw local-correction `.xmp` (`CameraRaw/LocalCorrections`) | top-level `crs:Local*` properties (or, in a file with `crs:PresetType="Local"` without them, its first correction's); `crs:Name` → name, `crs:Group` → group. Develop presets (no `crs:Local*`) stay develop presets | +| `.lceffect` | ours: `{"format": "lightcraft.maskEffects", "version": 2, "effects": [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}]}` (a bare array or one effect also reads); `mask.exportEffects {path, names?, group?}` writes it | +| `.zip` | the presets above inside it; its folders give groups | + +Groups: a file's folder (relative to the imported folder; names that only say "local adjustment presets" don't count) +names its group, unless the file names one. Lightroom users mark sections of the flat preset menu with **divider +presets**: presets that set nothing, named like `10 ----- DETAIL ENHANCERS ----- 10` (a run of three or more `-`, `=`, +`_`, `*`, `#`, `~`, `—`, `.`). These become groups instead of effects: a preset whose name starts with a number goes to +the divider with the largest number not above it (`12 Sky` under `10 ----- DETAIL ENHANCERS ----- 10` → *Detail +Enhancers*); presets without a number keep their folder's group. An earlier import's divider effects are removed. +Same-named effects are replaced (a star is kept). + +Settings with no local slider here are listed per preset in `unmapped` (and the toast) and **kept** with the effect in +`extra` (an `.lceffect` export carries them), but not applied: `grain` (Grain; LightCraft has no local grain), +`pointColors` (Point Color: `SrcHue/Sat/Lum`, `HueShift`, `SatScale`, `LumScale`, `RangeAmount`, `Hue/Sat/LumRange` +`{LowerNone, LowerFull, UpperFull, UpperNone}`; there is global Point Color only), `toningLuminance`. Zero values aren't +listed (`refineSaturation = 0` in most files). Lightroom keeps brush size, feather, flow and density per brush, not in +presets, so there is nothing to import for them; an effect saved here can carry them (`brush`). + +Applying (`mask.applyEffect {name, id? | ids? | all?, photos?: "selected"}`) sets the sliders the effect carries — +all of them for an imported preset (unnamed ones to 0, as Lightroom does), or only the ones chosen when it was saved +(`mask.saveEffect {fields: "changed" | [..]}`) — and Amount only if it carries it. The effect is remembered for the +mask's kind: the next brush / linear / radial / … mask starts with it (`mask.toolEffect`). + ## Preset files | | | |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). From bbd2e8cbd4b225f63804ead9843eb3435eb5806d Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 05:27:26 +1100 Subject: [PATCH 022/315] LR-IMP-CAMERA-COVERAGE: verify 97 Canon CR2, Pentax and Ricoh models on CC0 files One CC0 raw.pixls.us file per Canon CR2, Pentax and Ricoh body that decodes (100 files, 97 Adobe models) is added to RAW_SAMPLES and pinned in crates/raw/tests/canon_pentax_corpus.rs: own sensor hash plus a black-box check that the decoded green follows the camera's embedded JPEG (r 0.69-1.00; four files pinned only, inspected by eye). Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint. Docs: evidence.tsv + coverage re-measure (332 verified, 50% on verified bodies), gap 10, checklist, raw-parity, ROADMAP. Headline unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 8 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/tests/canon_pentax_corpus.rs | 192 +++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 28 +- docs/raw/coverage.tsv | 194 ++++----- docs/raw/evidence.tsv | 97 +++++ xtask/src/main.rs | 500 ++++++++++++++++++++++++ 9 files changed, 915 insertions(+), 118 deletions(-) create mode 100644 crates/raw/tests/canon_pentax_corpus.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..d37d6c86f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (97 Canon CR2, Pentax and Ricoh models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 23% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -114,6 +114,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and + checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 332 models verified, ~50% of + photographers on a verified body; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -161,6 +164,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 97 Canon CR2, Pentax and Ricoh models verified (332 models, 23%); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/tests/canon_pentax_corpus.rs b/crates/raw/tests/canon_pentax_corpus.rs new file mode 100644 index 000000000..db7da12ed --- /dev/null +++ b/crates/raw/tests/canon_pentax_corpus.rs @@ -0,0 +1,192 @@ +//! One CC0 file per Canon CR2, Pentax and Ricoh body from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.69–1.00 on these files, a misread layout or stream gives r near 0). Four files are +//! pinned only: two have no embedded JPEG (GX200, and the PowerShot S2 IS, a DNG written by CHDK), one is monochrome (K-3 Mark III +//! Monochrome), and the GXR's preview is not oriented like its sensor; their renders were inspected by eye. +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn canon_pentax_and_ricoh_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples, compare with the camera JPEG) + let cases: &[(&str, (usize, usize), u64, bool)] = &[ + ("cr2-canon-eos-1000d.cr2", (3948, 2622), 0x7de9_a900_6ae5_ccb6, true), + ("cr2-canon-eos-1d-mark-ii-n.cr2", (3596, 2360), 0xbfbf_beba_d32c_efdf, true), + ("cr2-canon-eos-1d-mark-iii.cr2", (3984, 2622), 0x5fbc_a832_828e_c45b, true), + ("cr2-canon-eos-1d-mark-iv.cr2", (5120, 3318), 0xda97_8f13_a35b_978f, true), + ("cr2-canon-eos-1d-x-mark-ii.cr2", (5568, 3708), 0xf3cb_7865_8c90_f41f, true), + ("cr2-canon-eos-2000d.cr2", (6096, 4051), 0xe687_d928_d4e7_821e, true), + ("cr2-canon-eos-20d.cr2", (3596, 2360), 0x20bf_cfd0_3ec3_7915, true), + ("cr2-canon-eos-30d.cr2", (3596, 2360), 0x5f49_abf5_3c51_2fc9, true), + ("cr2-canon-eos-4000d.cr2", (5344, 3516), 0x4f62_9ac5_37ce_9b8e, true), + ("cr2-canon-eos-400d.cr2", (3948, 2622), 0x3b42_3e9a_e59f_3558, true), + ("cr2-canon-eos-450d.cr2", (4312, 2876), 0x47ad_c3fe_76a6_bd06, true), + ("cr2-canon-eos-500d.cr2", (4832, 3204), 0xbb88_0e01_7f8b_8f36, true), + ("cr2-canon-eos-50d.cr2", (4832, 3228), 0xd4c1_61ba_c2f1_a492, true), + ("cr2-canon-eos-5d-mark-iv.cr2", (6880, 4544), 0xf62e_e9b9_6976_b2cf, true), + ("cr2-canon-eos-5d.cr2", (4476, 2954), 0xf0b6_2f3e_3971_7785, true), + ("cr2-canon-eos-5ds.cr2", (8896, 5920), 0x3e2e_5ff4_4f53_8920, true), + ("cr2-canon-eos-600d.cr2", (5344, 3516), 0xe962_86a6_f187_091e, true), + ("cr2-canon-eos-60d.cr2", (5344, 3516), 0x6b47_1bd4_8f4e_3aca, true), + ("cr2-canon-eos-6d-mark-ii.cr2", (6384, 4224), 0xc9f6_08ad_6cf8_6969, true), + ("cr2-canon-eos-70d.cr2", (5568, 3708), 0x1dba_52ca_716c_8594, true), + ("cr2-canon-eos-750d.cr2", (6096, 4056), 0xa8d2_87ba_7428_bfd6, true), + ("cr2-canon-eos-760d.cr2", (6096, 4056), 0xc9d1_a2a0_4555_77c8, true), + ("cr2-canon-eos-77d.cr2", (6288, 4056), 0x3bf3_dffd_d202_66e8, true), + ("cr2-canon-eos-7d-mark-ii.cr2", (5568, 3708), 0x4544_6618_a169_4540, true), + ("cr2-canon-eos-kiss-digital-n.cr2", (3516, 2328), 0x1cc0_14a6_4e5d_f2a6, true), + ("cr2-canon-eos-kiss-x80.cr2", (5344, 3516), 0x7d12_8887_a0bc_8544, true), + ("cr2-canon-eos-kiss-x9.cr2", (6288, 4056), 0x3a83_f960_1d45_854d, true), + ("cr2-canon-eos-m.cr2", (5280, 3528), 0x845d_bcda_107b_1161, true), + ("cr2-canon-eos-m10.cr2", (5280, 3528), 0x312a_4709_2c94_f887, true), + ("cr2-canon-eos-m100.cr2", (6288, 4056), 0x4bde_865a_2037_f121, true), + ("cr2-canon-eos-m2.cr2", (5280, 3528), 0x2ed7_e8c4_39d7_7f15, true), + ("cr2-canon-eos-m3.cr2", (6096, 4056), 0x958d_834a_3e39_fc1c, true), + ("cr2-canon-eos-m5.cr2", (6288, 4056), 0x39af_0b9f_4570_e9f5, true), + ("cr2-canon-eos-m6.cr2", (6288, 4056), 0xf4c8_36b3_2057_69f3, true), + ("cr2-canon-eos-rebel-sl1.cr2", (5280, 3528), 0xbcea_0c6b_0df2_4b7d, true), + ("cr2-canon-eos-rebel-t3.cr2", (4352, 2874), 0xb51e_0a5b_73ce_9ac3, true), + ("cr2-canon-eos-rebel-t4i.cr2", (5280, 3528), 0xf3e2_4be6_7049_9fac, true), + ("cr2-canon-eos-rebel-t5.cr2", (5344, 3516), 0xbd11_9ab9_f322_6e55, true), + ("cr2-canon-eos-rebel-t5i.cr2", (5280, 3528), 0xfb93_e24f_d77a_6db6, true), + ("cr2-canon-eos-rebel-t7i.cr2", (6288, 4056), 0x7657_7d0a_911d_9c04, true), + ("cr2-canon-powershot-g1-x-mark-ii.cr2", (4480, 3366), 0xd4b6_ced9_a68d_d520, true), + ("cr2-canon-powershot-g1-x-mark-iii.cr2", (6288, 4056), 0x7a65_598c_7df5_ef2a, true), + ("cr2-canon-powershot-g1-x.cr2", (4496, 3366), 0x0d02_3f27_892d_67ae, true), + ("cr2-canon-powershot-g11.cr2", (3744, 2784), 0x785b_5b63_ad8e_b78a, true), + ("cr2-canon-powershot-g12.cr2", (3744, 2784), 0xd5e2_266b_d066_5db0, true), + ("cr2-canon-powershot-g15.cr2", (4160, 3124), 0xf234_7cc5_3ac7_4a42, true), + ("cr2-canon-powershot-g16.cr2", (4192, 3062), 0xba01_a612_ba7c_bffe, true), + ("cr2-canon-powershot-g3-x.cr2", (5632, 3710), 0x8a1e_46e0_3d78_1879, true), + ("cr2-canon-powershot-g5-x.cr2", (5632, 3710), 0xc023_524c_2998_f93d, true), + ("cr2-canon-powershot-g7-x-mark-ii.cr2", (5632, 3710), 0x9d71_9788_df4e_38d8, true), + ("cr2-canon-powershot-g7-x.cr2", (5632, 3710), 0xe47b_2d8a_2566_9a60, true), + ("cr2-canon-powershot-g9-x-mark-ii.cr2", (5632, 3710), 0xfe91_af24_c438_f82a, true), + ("cr2-canon-powershot-g9-x.cr2", (5632, 3710), 0xc91d_c1ca_ee96_3e2a, true), + ("cr2-canon-powershot-g9.cr2", (4104, 3048), 0xee20_d244_64ec_8952, true), + ("cr2-canon-powershot-s100.cr2", (4160, 3124), 0x0f16_9e95_92e6_4275, true), + ("cr2-canon-powershot-s100v.cr2", (4160, 3124), 0x3973_5bf8_19c5_7ffd, true), + ("cr2-canon-powershot-s110.cr2", (4160, 3124), 0x8ef0_f13b_7b64_98fb, true), + ("cr2-canon-powershot-s120.cr2", (4192, 3062), 0x48c5_76b9_6afb_4a60, true), + ("dng-canon-powershot-s2-is.dng", (2672, 1968), 0x1ba0_5636_0928_545f, false), + ("cr2-canon-powershot-s95.cr2", (3744, 2784), 0x85a7_3f89_0720_7856, true), + ("cr2-canon-powershot-sx50-hs.cr2", (4176, 3062), 0x4466_4918_96d5_0162, true), + ("cr2-canon-powershot-sx60-hs.cr2", (4768, 3516), 0xae5f_336d_4abc_f0a1, true), + ("dng-pentax-645d.dng", (7424, 5552), 0xbf13_df20_b6fc_e20c, true), + ("dng-pentax-645z.dng", (8384, 6208), 0x2529_54b5_a64e_1536, true), + ("dng-pentax-k-01.dng", (4960, 3300), 0x57ee_22b2_2cb5_b2cf, true), + ("dng-pentax-k-1-mark-ii.dng", (7392, 4950), 0xf2ed_12fa_9824_7028, true), + ("dng-pentax-k-3-ii.dng", (6080, 4032), 0x921b_d488_3bc3_a6af, true), + ("dng-pentax-k-3-mark-iii-monochrome.dng", (6304, 4224), 0x3fc2_04b3_4c78_8959, false), + ("dng-pentax-k-30.dng", (4960, 3300), 0xe665_6a25_81ec_4895, true), + ("dng-pentax-k-5-ii.dng", (4992, 3284), 0xc6b0_b56b_76ab_74d6, true), + ("dng-pentax-k-5.dng", (4992, 3284), 0xd020_63f6_a9a5_c566, true), + ("dng-pentax-k-50.dng", (4960, 3300), 0x7728_7535_4feb_08e3, true), + ("dng-pentax-k-500.dng", (4960, 3300), 0xbc6c_575a_e188_acbe, true), + ("dng-pentax-k-7.dng", (4736, 3136), 0xd7c8_ec3d_5bbc_0b22, true), + ("dng-pentax-k-s1.dng", (5504, 3664), 0xa987_c182_22c8_8fe3, true), + ("dng-pentax-k-s2.dng", (5504, 3664), 0x056e_63c6_87e4_e504, true), + ("dng-pentax-k-x.dng", (4352, 2868), 0xad8d_0b39_7a77_bfb7, true), + ("dng-pentax-k200d.dng", (3936, 2624), 0xecc6_d5f8_d5e5_6cb3, true), + ("dng-pentax-k20d.dng", (4864, 3136), 0x5bb1_9a98_babd_4bbf, true), + ("dng-pentax-kp.dng", (6112, 4060), 0xa1a6_c4b4_84c5_d21f, true), + ("dng-pentax-mx-1.dng", (4016, 3010), 0xb8e1_011c_4b49_6dc3, true), + ("dng-pentax-q.dng", (4064, 3000), 0xb4e3_dd90_ef7f_874f, true), + ("dng-pentax-q7.dng", (4064, 3000), 0x8b2b_cf5b_a63b_8811, true), + ("dng-ricoh-gr-ii.dng", (4960, 3280), 0x9724_b08b_6259_ba49, true), + ("dng-ricoh-gr-iiix.dng", (6112, 4064), 0x8be6_fa17_2379_4d1a, true), + ("dng-ricoh-gr.dng", (4960, 3280), 0x3e5e_9ce5_a4ac_0dc6, true), + ("dng-ricoh-gx200.dng", (4096, 3014), 0xb7b6_ff86_8f38_3ab2, false), + ("dng-ricoh-gxr.dng", (3672, 2748), 0x6255_34a2_7d07_76aa, false), + ("pef-pentax-ist-d.pef", (3040, 2024), 0x9528_2820_e0c3_6088, true), + ("pef-pentax-ist-dl.pef", (3040, 2024), 0xb1b8_7fec_f109_8f66, true), + ("pef-pentax-ist-ds.pef", (3040, 2024), 0x05ed_70b9_cb1d_a5f8, true), + ("pef-pentax-k-1.pef", (7392, 4950), 0xdf3a_d5b4_a0a4_a1e1, true), + ("pef-pentax-k-3-mark-iii.pef", (6304, 4224), 0x4ea0_a41f_fd71_a0b7, true), + ("pef-pentax-k-70.pef", (6080, 4064), 0xad07_0022_dbfb_4cc6, true), + ("pef-pentax-k-r.pef", (4352, 2868), 0x8638_b64c_a21e_a94d, true), + ("pef-pentax-k100d-super.pef", (3040, 2024), 0xc177_9a26_301a_52a4, true), + ("pef-pentax-k100d.pef", (3040, 2024), 0x2cf8_04e5_757d_1d1c, true), + ("pef-pentax-k110d.pef", (3040, 2024), 0xc273_870f_da44_d3f8, true), + ("pef-pentax-k2000.pef", (3936, 2624), 0xadeb_62eb_f697_35ef, true), + ("pef-pentax-kf.pef", (6080, 4064), 0x0006_b0a3_ad13_45fe, true), + ]; + let mut seen = 0; + for &(name, sensor, hash, jpeg_check) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + if !jpeg_check { + seen += 1; + continue; + } + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Canon / Pentax / Ricoh bodies checked on {seen} files"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..1c258f280 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 10: 97 Canon CR2, Pentax and Ricoh models verified; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 332 of 1,446 Adobe models verified (23%), ~50% of photographers on a verified body; Nikon 5 bodies verified, Canon 73 (61 CR2 bodies and Pentax / Ricoh 40 pinned on one CC0 file each in `crates/raw/tests/canon_pentax_corpus.rs`). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 10 shrinks: 97 Canon CR2, Pentax and Ricoh models verified on one CC0 file each (332 models, ~50% on a verified body) | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..1b5120cbc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-CAMERA-COVERAGE: 97 Canon CR2, Pentax and Ricoh models pinned on CC0 files; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases, 4 pinned EOS R5 Mark II files and one pinned CC0 file for each of 100 Canon CR2, Pentax and Ricoh bodies, checked against its camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`) (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: 97 Canon CR2, Pentax and Ricoh Adobe models verified on one CC0 file each; status unchanged (🟡) | | 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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..c196aaab9 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (97 Canon CR2, Pentax and Ricoh models verified on one CC0 file each) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,13 +14,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| Decoded from sensor data and **verified** on a real file | **332** (23%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **845** (58%) | measured from the format rules (632 of them DNG, mostly phones) | | **Preview-only** (embedded JPEG) | **233** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 90 / 632 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | +| …of which on a body we have verified | **~50%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | @@ -43,7 +43,8 @@ committed table is stale. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; - the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference + the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), `crates/raw/tests/canon_pentax_corpus.rs` (97 Canon CR2, Pentax and Ricoh models, one CC0 raw.pixls.us file each, + pinned and checked against the camera JPEG), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the @@ -64,7 +65,7 @@ committed table is stale. | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| | Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | -| Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | +| Canon | 134 | 73 | 38 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | | Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | @@ -74,31 +75,31 @@ committed table is stale. | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | | Leica | 61 | 13 | 48 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | -| Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | +| Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — | | Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **332 (23%)** | **845 (58%)** | **233 (16%)** | **36 (2%)** | | ## By use (estimated) | Bucket | Weight | Camera fully works | On a verified body | Why | |---|---:|---:|---:|---| -| Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | +| Canon | 33% | 74% | 41% | 18 of the 22 popular bodies write CR3; 4 of them verified; 61 CR2 bodies verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | | Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | | Phones | 3% | 93% | 1% | DNG | -| Pentax / Ricoh | 1.5% | 94% | 14% | | +| Pentax / Ricoh | 1.5% | 99% | 80% | | | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | | Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **85%** | **42%** | | +| **Weighted** | 100% | **85%** | **50%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually checked, and Canon, the largest bucket, is the weakest of the big four. @@ -244,7 +245,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). |---:|---|---:|---:|---:|---| | 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) | | 2 | CR3: remaining CRX variants, then verify the other 14 popular CR3 bodies | +6.2 | 25–45 | ~0.19 | samples per body (raw.pixls.us has most) | -| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples | +| 3 | Verify popular Nikon and Fujifilm bodies (one CC0 file each, fix what breaks; Canon CR2 and Pentax done) | +1.0 | 4–8 | ~0.17 | public samples | | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | | 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 332 verified, 845 unverified; 50% of photographers on a verified body; use-weighted 85% and decoding 77% unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..270bec906 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -116,73 +116,73 @@ Apple iPad mini (A17 Pro) Rear Camera DNG 2024 unverified DNG decoder (uncompres Canon EOS-1D TIF 2003 unsupported TIF is not imported Canon EOS-1D C CR2 2012 unverified CR2 lossless JPEG decoder Canon EOS-1D Mark II CR2 2003 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark II N CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark III CR2 2007 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark IV CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS-1D Mark II N CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark III CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark IV CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS-1Ds TIF 2003 unsupported TIF is not imported Canon EOS-1Ds Mark II CR2 2005 unverified CR2 lossless JPEG decoder Canon EOS-1Ds Mark III CR2 2007 unverified CR2 lossless JPEG decoder Canon EOS-1D X CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS-1D X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS-1D X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS-1D X Mark III CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS 5D CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 5D CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 5D Mark II CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS-5D Mark III CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS-5D Mark IV (** see note) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 5DS CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS-5D Mark IV (** see note) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 5DS R CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 6D CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 6D Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 6D Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 7D CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 7D Mark II CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS 7D Mark II CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 10D CRW 2003 preview CRW is opened from its embedded JPEG only -Canon EOS 20D CR2 2003 unverified CR2 lossless JPEG decoder +Canon EOS 20D CR2 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 20Da CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS 30D CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 30D CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 40D CR2 2007 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 50D CR2 2008 unverified CR2 lossless JPEG decoder -Canon EOS 60D CR2 2010 unverified CR2 lossless JPEG decoder +Canon EOS 50D CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 60D CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 60Da CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 70D CR2 2013 unverified CR2 lossless JPEG decoder -Canon EOS 77D (EOS 9000D) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 70D CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 77D (EOS 9000D) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 90D CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon EOS D30 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS D60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 preview CRW is opened from its embedded JPEG only -Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 unverified CR2 lossless JPEG decoder -Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 unverified CR2 lossless JPEG decoder -Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 unverified CR2 lossless JPEG decoder -Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 unverified CR2 lossless JPEG decoder -Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 unverified CR2 lossless JPEG decoder -Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 unverified CR2 lossless JPEG decoder -Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 unverified CR2 lossless JPEG decoder -Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS C50 CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS M2 CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS M CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M2 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS M200 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M3 CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS M5 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M3 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M5 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS M50 Mark II CR3 2020 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M6 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M6 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS M6 Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M10 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M10 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS M50 (EOS Kiss M) CR3 2017 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS M100 CR2 2017 unverified CR2 lossless JPEG decoder +Canon EOS M100 CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS R (** see note) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon EOS R1 CR3 2023 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon EOS R3 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview @@ -206,27 +206,27 @@ Canon PowerShot 600 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A5 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A50 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G1 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G1 X CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot G1 X Mark II CR2 2013 unverified CR2 lossless JPEG decoder -Canon PowerShot G1 X Mark III CR2 2017 unverified CR2 lossless JPEG decoder +Canon PowerShot G1 X CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark II CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark III CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G2 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G3 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G3 X CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G3 X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G5 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G5 X CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G5 X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G5 X Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon PowerShot G6 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G7 X CR2 2013 unverified CR2 lossless JPEG decoder -Canon PowerShot G7 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G7 X CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G7 X Mark III CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon PowerShot G9 CR2 2007 unverified CR2 lossless JPEG decoder -Canon PowerShot G9X CR2 2015 unverified CR2 lossless JPEG decoder -Canon PowerShot G9 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G9 CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G10 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot G11 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot G12 CR2 2010 unverified CR2 lossless JPEG decoder -Canon PowerShot G15 CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot G16 CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot G11 CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G12 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G15 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G16 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot Pro 1 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot Pro70 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot Pro90 IS CRW 2003 preview CRW is opened from its embedded JPEG only @@ -236,15 +236,15 @@ Canon PowerShot S45 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S50 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S90 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot S95 CR2 2010 unverified CR2 lossless JPEG decoder +Canon PowerShot S95 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot S70 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot S100 CR2 2010 unverified CR2 lossless JPEG decoder -Canon PowerShot S100V CR2 2010 unverified CR2 lossless JPEG decoder -Canon PowerShot S110 CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot S120 CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot S100 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100V CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S110 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S120 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot SX1 IS CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot SX50 CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot SX60 HS CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot SX50 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX60 HS CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot SX70 HS CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon PowerShot V1 CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Casio EXILIM EX-10 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1009,44 +1009,44 @@ Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decode Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format Parrot Anafi DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax 645D DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax 645Z DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax *ist D PEF 2003 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax *ist DL PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax 645D DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645Z DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist D PEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DL PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax *ist DL2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax *ist DS PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax *ist DS PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax *ist DS2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-01 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-1 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-01 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-1 DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K-1 II (* see note) DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax K-3 DNG 2013 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-3 II († see note) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-5 II DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-3 II († see note) DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 II DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K-5 IIs DNG 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-7 PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-3 Mark III DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-3 Mark III Monochrome DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-30 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-50 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-70 PEF 2015 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-500 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax KP DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-r PEF 2010 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-S1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-S2 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-x PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K-7 PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III DNG 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III Monochrome DNG 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Pentax K-30 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-50 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-70 PEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-500 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KP DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-r PEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S1 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S2 DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-x PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K10D DNG 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K20D DNG 2007 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K100D PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K100D Super PEF 2007 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K110D PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K200D DNG 2007 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K2000 K-m PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax KF PEF 2022 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax MX-1 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax Q DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax Q7 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K20D DNG 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D Super PEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K110D PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K200D DNG 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K2000 K-m PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KF PEF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax MX-1 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q7 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax Q10 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax QS-1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pixii PIXII Camera (A1571) DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1078,10 +1078,10 @@ Phase One P 40+ IIQ 2010 preview IIQ is opened from its embedded JPEG only Phase One P 45 TIF 2007 unsupported TIF is not imported Phase One P 45+ TIF 2007 unsupported TIF is not imported Phase One P 65+ IIQ 2010 preview IIQ is opened from its embedded JPEG only -Ricoh GR DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Ricoh GR II DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR II DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Ricoh GR III DNG 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Ricoh GR IIIx DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR IIIx DNG 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Ricoh GR III HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR IIIx HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR IV DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1091,7 +1091,7 @@ Ricoh GR Digital II DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Ricoh GR Digital III DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR Digital IV DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GX100 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Ricoh GX200 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GX200 DNG 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) Ricoh GXR with GR LENS A12 28mm F2.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GXR with GR LENS A12 50mm F2.5 MACRO DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GXR with GR LENS A16 24-85 F3.5-5.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..c363caf77 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -14,6 +14,67 @@ Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor has Canon EOS R5 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 70D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S110 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G16 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G3 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100V verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G11 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G15 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark IV verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G12 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 6D Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 77D (EOS 9000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-5D Mark IV (** see note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 60D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark II N verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G5 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M6 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 7D Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S120 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S95 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 50D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 20D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX60 HS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 30D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -149,6 +210,42 @@ Pentax K-3 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K-1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DL verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GX200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Pentax KP verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III Monochrome verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Pentax K-70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR IIIx verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K20D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-01 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K110D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K200D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-x verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K2000 K-m verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645Z verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax MX-1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D Super verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-r verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KF verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 II († see note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..f277ad6c2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -885,6 +885,506 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "cr2-canon-eos-70d.cr2", + "https://raw.pixls.us/getfile.php/784/nice/Canon%20-%20EOS%2070D%20-%20RAW.CR2", + "00e42517013bd043b7145001399bbc320a8ff235a6dcd16033580d8a130bee1e", + ), + ( + "cr2-canon-eos-rebel-t7i.cr2", + "https://raw.pixls.us/getfile.php/3437/nice/Canon%20-%20EOS%20Rebel%20T7i%20-%20RAW%20%283:2%29.cr2", + "03fb55af71f384ada6b6aa0ca93279d48cf5fbd13651a323408d2fd97e1d1f7c", + ), + ( + "cr2-canon-powershot-g9-x.cr2", + "https://raw.pixls.us/getfile.php/2154/nice/Canon%20-%20PowerShot%20G9%20X%20-%20RAW%20%283:2%29.CR2", + "05c041127f4fdecfd49a45da4b12192c8dbe695c3e50427043166fef49b39e83", + ), + ( + "pef-pentax-k-1.pef", + "https://raw.pixls.us/getfile.php/2469/nice/Pentax%20-%20K-1%20-%2014bit%20%283:2%29.PEF", + "068c902e7b38ac31c7c6eca5c55f69ca395ec6aa7170489281b692a893d0fee9", + ), + ( + "cr2-canon-eos-4000d.cr2", + "https://raw.pixls.us/getfile.php/3241/nice/Canon%20-%20EOS%204000D%20-%20RAW%20%283:2%29.CR2", + "06daaf663f42d3f6ceb1a837d3ca4db2cfcb5899888ed13510bfa01e3261a896", + ), + ( + "cr2-canon-eos-1000d.cr2", + "https://raw.pixls.us/getfile.php/1616/nice/Canon%20-%20EOS%201000D%20-%20RAW%20%283:2%29.CR2", + "08b0843a7968315b68bd3c71a96c952ebf275d5b5eba4b6094ae8b103522584c", + ), + ( + "cr2-canon-powershot-g1-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/2186/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "08d861ca7fc2890f0217034970d4c900b09373b421106e4ba080f6904a48b37c", + ), + ( + "cr2-canon-eos-500d.cr2", + "https://raw.pixls.us/getfile.php/911/nice/Canon%20-%20EOS%20500D%20-%20RAW%20%283:2%29.CR2", + "0b57eb8b0dece6f63d4ea61660279488121c9a0aa250113852fa84c051c6da3e", + ), + ( + "pef-pentax-ist-dl.pef", + "https://raw.pixls.us/getfile.php/1083/nice/Pentax%20-%20%2Aist%20DL%20-%2012bit%20%283:2%29.PEF", + "0e6cd3c999a6dbf121591b2c58aaa8216e625f549db46af627f7346ccf1cf662", + ), + ( + "cr2-canon-eos-400d.cr2", + "https://raw.pixls.us/getfile.php/1764/nice/Canon%20-%20EOS%20400D%20-%20RAW%20%283:2%29.CR2", + "0e752ddaf81795885efc6443c78e5fee4d5f8f00c8d979ef20e235424f98d595", + ), + ( + "dng-ricoh-gx200.dng", + "https://raw.pixls.us/getfile.php/2589/nice/Ricoh%20-%20GX200%20-%2012bit%20%284:3%29.DNG", + "0f328905ee6f5b4823040a666103ced651178a13b8ccd0c4c60662f7265dd87f", + ), + ( + "cr2-canon-powershot-s110.cr2", + "https://raw.pixls.us/getfile.php/909/nice/Canon%20-%20PowerShot%20S110%20-%20RAW%20%284:3%29.CR2", + "14e440316c1d2b005a7f5d5c042e063b3e15d34242e38c3a0983db8ef85e4288", + ), + ( + "cr2-canon-eos-kiss-x80.cr2", + "https://raw.pixls.us/getfile.php/3099/nice/Canon%20-%20EOS%20Kiss%20X80%20-%20RAW%20%283:2%29.CR2", + "155878290528ff91f67b4f9ffed5d765e80ede5fb0c36fca40107c0b7f5c1d9d", + ), + ( + "dng-pentax-kp.dng", + "https://raw.pixls.us/getfile.php/1824/nice/Pentax%20-%20KP%20-%2014bit%20%283:2%29.DNG", + "16b09cb3032003989877659ae03cbb6985e3d7e84f1402863ccae8af10033c1c", + ), + ( + "dng-pentax-k-1-mark-ii.dng", + "https://raw.pixls.us/getfile.php/3345/nice/Pentax%20-%20K-1%20Mark%20II%20-%2014bit%20%283:2%29.DNG", + "190d5878675f4de07d246271ba411b152f3317b2d2a0e27b9c827fe7343b28ea", + ), + ( + "dng-pentax-k-3-mark-iii-monochrome.dng", + "https://raw.pixls.us/getfile.php/7081/nice/Pentax%20-%20K-3%20Mark%20III%20Monochrome%20-%2014bit%20%283:2%29.DNG", + "1afc1071451a3a3737c29ae319e1411551b6893d0e6a34db29903318266b0066", + ), + ( + "cr2-canon-eos-600d.cr2", + "https://raw.pixls.us/getfile.php/1586/nice/Canon%20-%20EOS%20600D%20-%20RAW%20%283:2%29.CR2", + "1b0892df5ba5fa418e945f0519d490aeecaaf7e74860a08b5dc47e3e9976a288", + ), + ( + "pef-pentax-k-70.pef", + "https://raw.pixls.us/getfile.php/1141/nice/Pentax%20-%20K-70%20-%2014bit%20%283:2%29.PEF", + "1d64c6aa71680642ba8f00b6f6105df3e66a4534e631f422fbf3316273c4408c", + ), + ( + "dng-ricoh-gr-iiix.dng", + "https://raw.pixls.us/getfile.php/5818/nice/Ricoh%20-%20GR%20IIIx%20-%2014bit%20%283:2%29.DNG", + "1e318af31be4baa9949f0ed4af5cfe8ffb2c180e2c12926cd75ac1d029bb0fae", + ), + ( + "cr2-canon-powershot-g1-x.cr2", + "https://raw.pixls.us/getfile.php/1588/nice/Canon%20-%20PowerShot%20G1%20X%20-%20RAW%20%284:3%29.CR2", + "1e486bbbdfb2c94accf0c5ef4558d1aa484e0ffd36fa03440e3ae34ba2aa338b", + ), + ( + "dng-ricoh-gxr.dng", + "https://raw.pixls.us/getfile.php/2756/nice/Ricoh%20-%20GXR%20-%2012bit%20%284:3%29.DNG", + "219a062f3d1eb8d66c9dd6088eecf495be082cf5c81f88622eb0dd28cc893cec", + ), + ( + "cr2-canon-powershot-g16.cr2", + "https://raw.pixls.us/getfile.php/1114/nice/Canon%20-%20PowerShot%20G16%20-%20RAW%20%284:3%29.CR2", + "2307e3cf643ab2f94d03f899467d8797ce1ef41bbffd6ad50af81bdd0ba34098", + ), + ( + "dng-pentax-k20d.dng", + "https://raw.pixls.us/getfile.php/1143/nice/Pentax%20-%20K20D%20-%2012bit%20%283:2%29.DNG", + "264465ef1d97f1599899778589f07f8aa485b88c0dfc9b1d4c006bf79d44b420", + ), + ( + "cr2-canon-powershot-g3-x.cr2", + "https://raw.pixls.us/getfile.php/1509/nice/Canon%20-%20PowerShot%20G3%20X%20-%20RAW%20%283:2%29.CR2", + "267641913613fd52532279ff16f03c135511fbcab5bbe8b276fc9cf91aebd7ca", + ), + ( + "pef-pentax-ist-ds.pef", + "https://raw.pixls.us/getfile.php/4392/nice/Pentax%20-%20%2Aist%20DS%20-%2012bit%2012bit%20uncompressed%20%283:2%29.pef", + "27809a7d252d515685ed4e6091b70ab25348502baeb75db03ce7b4f1d9549b76", + ), + ( + "cr2-canon-eos-m.cr2", + "https://raw.pixls.us/getfile.php/1224/nice/Canon%20-%20EOS%20M%20-%20RAW%20%283:2%29.CR2", + "27d151efe86f04bea8fcf6ec9f3a722ae3ee24a3f56a6ebbd27ee89b215cf569", + ), + ( + "cr2-canon-eos-1d-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1058/nice/Canon%20-%20EOS-1D%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "2e372710d546c9b8903a343696af4e7fd245794571f64258ab607a89dfa6aa97", + ), + ( + "pef-pentax-k-3-mark-iii.pef", + "https://raw.pixls.us/getfile.php/4677/nice/Pentax%20-%20K-3%20Mark%20III%20-%2014bit%20%283:2%29.PEF", + "351be88038b2dd28af39e11dbece6de74efd71119a9ef667d35cdf02dcff919f", + ), + ( + "cr2-canon-eos-750d.cr2", + "https://raw.pixls.us/getfile.php/1982/nice/Canon%20-%20EOS%20750D%20-%20RAW%20%283:2%29.CR2", + "38cdb173fe1ea083f4d475285d3080db49d7f3c7cdcb49bf1968fceeed66c85c", + ), + ( + "cr2-canon-eos-5ds.cr2", + "https://raw.pixls.us/getfile.php/2083/nice/Canon%20-%20EOS%205DS%20-%20RAW%20%283:2%29.CR2", + "3fdb8b7ada3b4a69ed5756fa814bee9bd51c1c22b40e70252a46890e2855df07", + ), + ( + "dng-canon-powershot-s2-is.dng", + "https://raw.pixls.us/getfile.php/941/nice/Canon%20-%20PowerShot%20S2%20IS%20-%2010bit%2010bit%20DNG%2C%20CHDK%20ver.%201.0.0-1504%20%284:3%29.CR2", + "445764716df165a1facd37c01d67ede44ca49671ed1a6112dd8ff7426324c757", + ), + ( + "cr2-canon-powershot-s100v.cr2", + "https://raw.pixls.us/getfile.php/2492/nice/Canon%20-%20PowerShot%20S100V%20-%20RAW%20%284:3%29.CR2", + "4775f347686f785104ee7f566df158247cdecef4de76fdbfa150e18b6b104c0a", + ), + ( + "cr2-canon-eos-kiss-digital-n.cr2", + "https://raw.pixls.us/getfile.php/1185/nice/Canon%20-%20EOS%20Kiss%20Digital%20N%20-%20RAW%20%283:2%29.CR2", + "49c78e5c36255cdd3f9fd219e86e3325bfa67be4d8d1ab5e21c21c3fd53c294e", + ), + ( + "dng-pentax-k-01.dng", + "https://raw.pixls.us/getfile.php/1008/nice/Pentax%20-%20K-01%20-%2012bit%20%283:2%29.DNG", + "4c9aac2ce2720154616ad7678385d1ec1f626d6464a1d5c6a5a4d821a78a608d", + ), + ( + "cr2-canon-eos-rebel-t5.cr2", + "https://raw.pixls.us/getfile.php/1411/nice/Canon%20-%20EOS%20Rebel%20T5%20-%20RAW%20%283:2%29.CR2", + "4f71c1416037d2212cc4847e978bffe874afc0f653260386e96dff704ca8bf51", + ), + ( + "pef-pentax-k110d.pef", + "https://raw.pixls.us/getfile.php/1309/nice/Pentax%20-%20K110D%20-%2012bit%20%283:2%29.pef", + "4f7c5574b0dbc3c1270b269126c2a3be030a86799f8b544610c74f31ef782f72", + ), + ( + "cr2-canon-powershot-g11.cr2", + "https://raw.pixls.us/getfile.php/2755/nice/Canon%20-%20PowerShot%20G11%20-%20RAW%20%284:3%29.CR2", + "50ef4d6712c7add1c65ef4c48c74ac5017f24ff1789f1d8a8d5d9accd3aab0fa", + ), + ( + "cr2-canon-eos-rebel-sl1.cr2", + "https://raw.pixls.us/getfile.php/1201/nice/Canon%20-%20EOS%20Rebel%20SL1%20-%20RAW%20%283:2%29.CR2", + "56faa7db3947c32c5e6e31bc0db027ebf95163533e043bd8c4f18166a129692c", + ), + ( + "cr2-canon-powershot-g15.cr2", + "https://raw.pixls.us/getfile.php/1986/nice/Canon%20-%20PowerShot%20G15%20-%20RAW%20%284:3%29.CR2", + "5756d1a6522134ee4833a9cbef04eb54b26a1f7935e58063733a264478385a26", + ), + ( + "dng-pentax-q7.dng", + "https://raw.pixls.us/getfile.php/1760/nice/Pentax%20-%20Q7%20-%2012bit%20%284:3%29.DNG", + "577eac8d03419bb82714169dd532d58efc82a0abf928d28e4641f1ed5d56c350", + ), + ( + "dng-pentax-k-s1.dng", + "https://raw.pixls.us/getfile.php/1365/nice/Pentax%20-%20K-S1%20-%2012bit%20%283:2%29.DNG", + "58abc43a8e025dcab0053bcee173d20197aa2b7983e5e62097147a1e8545f111", + ), + ( + "cr2-canon-eos-1d-mark-iv.cr2", + "https://raw.pixls.us/getfile.php/4258/nice/Canon%20-%20EOS-1D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", + "597bb01f2400b04bfe7ee024016992e8e897b7dffaa5e368b8375daf3b32b3ed", + ), + ( + "cr2-canon-eos-m5.cr2", + "https://raw.pixls.us/getfile.php/2935/nice/Canon%20-%20EOS%20M5%20-%20RAW%20%283:2%29.CR2", + "5af02dbad6cd90eeac2b0b1c0be0aeb9a5d2841c6dd984ee415ede9f2566667d", + ), + ( + "cr2-canon-powershot-s100.cr2", + "https://raw.pixls.us/getfile.php/1357/nice/Canon%20-%20PowerShot%20S100%20-%20RAW%20%284:3%29.CR2", + "5d1bb0597da59bc920442c55dbd7f70d090a9aa01b5642607bcdde523ae892c6", + ), + ( + "pef-pentax-ist-d.pef", + "https://raw.pixls.us/getfile.php/1843/nice/Pentax%20-%20%2Aist%20D%20-%2012bit%20%283:2%29.PEF", + "61904b594d16983070f3e56d663ae8f7b246b8bb554c4cf7bd3eb4b9f9a9f3aa", + ), + ( + "dng-pentax-k-30.dng", + "https://raw.pixls.us/getfile.php/3094/nice/Pentax%20-%20K-30%20-%2012bit%20%283:2%29.DNG", + "63eaa1ff93ad7c89947017332b10f78f17cd3853a5b495adf25410edaefbe618", + ), + ( + "cr2-canon-eos-rebel-t4i.cr2", + "https://raw.pixls.us/getfile.php/2203/nice/Canon%20-%20EOS%20Rebel%20T4i%20-%20RAW%20%283:2%29.CR2", + "65257f38bc68a11b9185ac710b3a442a19aa1c082afb01b668bf33e6836b6b2d", + ), + ( + "cr2-canon-powershot-sx50-hs.cr2", + "https://raw.pixls.us/getfile.php/3152/nice/Canon%20-%20PowerShot%20SX50%20HS%20-%20RAW%20%284:3%29.CR2", + "6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c", + ), + ( + "dng-pentax-k200d.dng", + "https://raw.pixls.us/getfile.php/5246/nice/Pentax%20-%20K200D%20-%2012bit%2012bit%20uncompressed%20%283:2%29.DNG", + "6d13579dd0a8ec784bfa3d8b53af5a3f936171bfa62d74eb96a17236a6fb9472", + ), + ( + "cr2-canon-eos-m2.cr2", + "https://raw.pixls.us/getfile.php/5067/nice/Canon%20-%20EOS%20M2%20-%20RAW%20%283:2%29.cr2", + "6d26eeb1e8a5978c7cb9d8beb6e6e34f3c27847055b76bc7f186503ca44508d0", + ), + ( + "cr2-canon-eos-m3.cr2", + "https://raw.pixls.us/getfile.php/1002/nice/Canon%20-%20EOS%20M3%20-%20RAW%20%283:2%29.CR2", + "6f12a02a21f64b284328ee6d3b2d1bab4439720cad2e881adf3da72592128b11", + ), + ( + "cr2-canon-powershot-g12.cr2", + "https://raw.pixls.us/getfile.php/1767/nice/Canon%20-%20PowerShot%20G12%20-%20RAW%20%284:3%29.cr2", + "6f2750c7cb144eee80a91431e2ce8df66dd229c3e1f1453bf80409ca669a8614", + ), + ( + "dng-pentax-k-5.dng", + "https://raw.pixls.us/getfile.php/1479/nice/Pentax%20-%20K-5%20-%2014bit%20%283:2%29.DNG", + "6f6419209340d07d597ccca66dc367d64cff3308ef2694c8f90c6259f3a5c431", + ), + ( + "cr2-canon-eos-6d-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1625/nice/Canon%20-%20EOS%206D%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "712815b910fa473f7aaeb97c0804292d996b16dd6b974a3ab9a7195a2e41d90c", + ), + ( + "cr2-canon-eos-m10.cr2", + "https://raw.pixls.us/getfile.php/1758/nice/Canon%20-%20EOS%20M10%20-%20RAW%20%283:2%29.CR2", + "71e27b9ebe7efba632e666db77b82c5129e0d1a402ff359a2b509774ebeb1b0f", + ), + ( + "dng-pentax-k-x.dng", + "https://raw.pixls.us/getfile.php/830/nice/Pentax%20-%20K-x%20-%2012bit%20%283:2%29.DNG", + "7a594b66b54f99bb5aba4627c78abfc41213d6911ea54e4f5d2dd6c8505f6968", + ), + ( + "cr2-canon-eos-rebel-t5i.cr2", + "https://raw.pixls.us/getfile.php/2651/nice/Canon%20-%20EOS%20Rebel%20T5i%20-%20RAW%20%283:2%29.CR2", + "7c3c9c3b8ec487d56040c21620aafe51e13e2c2fc1e1351929970720e10dda69", + ), + ( + "dng-ricoh-gr.dng", + "https://raw.pixls.us/getfile.php/996/nice/Ricoh%20-%20GR%20-%2012bit%20%284:3%29.DNG", + "7ceec1ea31c56b6e0414202504581b915f17818ffe018b4c8bba1393332c405e", + ), + ( + "dng-pentax-645d.dng", + "https://raw.pixls.us/getfile.php/2401/nice/Pentax%20-%20645D%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", + "7cfa4a6b3fc459157b0be94effaf98136027b1182dac7da0f91e29987ab6ae6c", + ), + ( + "cr2-canon-eos-rebel-t3.cr2", + "https://raw.pixls.us/getfile.php/3920/nice/Canon%20-%20EOS%20Rebel%20T3%20-%20RAW%20%283:2%29.CR2", + "8106f288e22ca0510b2ebe36e58833afbf847db92e9e1ec6f651605bac0beb24", + ), + ( + "cr2-canon-eos-77d.cr2", + "https://raw.pixls.us/getfile.php/1332/nice/Canon%20-%20EOS%2077D%20-%20RAW%20%283:2%29.CR2", + "811df582d97918987312fa0fceae3ad19a20532a64ebda9782764ac7e629c265", + ), + ( + "cr2-canon-eos-2000d.cr2", + "https://raw.pixls.us/getfile.php/2612/nice/Canon%20-%20EOS%202000D%20-%20RAW%20%283:2%29.CR2", + "84015493df96c877c0387071a627d6940187d75695b0598694901ac0ff83876a", + ), + ( + "cr2-canon-eos-5d-mark-iv.cr2", + "https://raw.pixls.us/getfile.php/980/nice/Canon%20-%20EOS%205D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", + "85bfec149261ffbae90797e45caae5e204e1e61ac62b92783925b5ee0f267d76", + ), + ( + "cr2-canon-eos-1d-mark-iii.cr2", + "https://raw.pixls.us/getfile.php/2037/nice/Canon%20-%20EOS-1D%20Mark%20III%20-%20RAW%20%283:2%29.CR2", + "860b5c9791548db8081823e3852c436a439e648ea863b0def05e99aac6e9e726", + ), + ( + "cr2-canon-eos-60d.cr2", + "https://raw.pixls.us/getfile.php/993/nice/Canon%20-%20EOS%2060D%20-%20RAW%20%283:2%29.CR2", + "865236ece3bf1f2f19a15001a8f0f99badf71e34b9eed1fead04d15daf87a154", + ), + ( + "pef-pentax-k100d.pef", + "https://raw.pixls.us/getfile.php/2262/nice/Pentax%20-%20K100D%20-%2012bit%20%283:2%29.PEF", + "86ec2383dbfda3eda38ca913b02874a70248efba6107aa304781c8d3779de8c9", + ), + ( + "cr2-canon-eos-1d-mark-ii-n.cr2", + "https://raw.pixls.us/getfile.php/1781/nice/Canon%20-%20EOS-1D%20Mark%20II%20N%20-%20RAW%20%283:2%29.CR2", + "8bf6591f64b0657bc6133cbd0ee1418ef8e6dd3dd595827f8e9910c0f235843c", + ), + ( + "dng-pentax-k-500.dng", + "https://raw.pixls.us/getfile.php/1458/nice/Pentax%20-%20K-500%20-%2012bit%20%283:2%29.DNG", + "91ccd6f29b8eaad88ae26d1c860ef1113e6dfc1bd2c8f5245b49f3a788295fb1", + ), + ( + "cr2-canon-eos-m100.cr2", + "https://raw.pixls.us/getfile.php/3531/nice/Canon%20-%20EOS%20M100%20-%20RAW%20%283:2%29.CR2", + "941d9e696b1d92b4b25a762474e3c5c127e7405dea099c266fa85e39a2454fcc", + ), + ( + "pef-pentax-k2000.pef", + "https://raw.pixls.us/getfile.php/5239/nice/Pentax%20-%20K2000%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF", + "985003748c094b8d02f2517b4c07d66d48ea5380260235ccb3409f41ceb3ea84", + ), + ( + "dng-pentax-k-50.dng", + "https://raw.pixls.us/getfile.php/2235/nice/Pentax%20-%20K-50%20-%2012bit%20%283:2%29.DNG", + "9ea1dc1f92df19699aa8b620670c64a84d4ead0172a49cbbd47dea9257d14e67", + ), + ( + "cr2-canon-powershot-g5-x.cr2", + "https://raw.pixls.us/getfile.php/4257/nice/Canon%20-%20PowerShot%20G5%20X%20-%20RAW%20%283:2%29.CR2", + "a7d0ac7f3c7ddfa607a6d0a7433ef9ba664d260aa91616cd75cfbdf912b0da00", + ), + ( + "dng-pentax-645z.dng", + "https://raw.pixls.us/getfile.php/2403/nice/Pentax%20-%20645Z%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", + "a8203686c61962af591b374331b6eb9dace4f2b29fce9f166303fc66fd210bb0", + ), + ( + "dng-pentax-k-5-ii.dng", + "https://raw.pixls.us/getfile.php/873/nice/Pentax%20-%20K-5%20II%20-%2014bit%20%283:2%29.DNG", + "aa41f0d9ab8d3e21e468b4c61d4d1ddae6a4fbe37f629b78159246cbb94dd56b", + ), + ( + "cr2-canon-powershot-g9.cr2", + "https://raw.pixls.us/getfile.php/760/nice/Canon%20-%20PowerShot%20G9%20-%20RAW%20%284:3%29.CR2", + "ad27a4d751984cd709380e3be274a4a5243d36ca89c5fc711736ba8a9f5c932f", + ), + ( + "dng-pentax-q.dng", + "https://raw.pixls.us/getfile.php/1610/nice/Pentax%20-%20Q%20-%2012bit%20%284:3%29.DNG", + "ad79b4ce4b88e5ca8b03d0d5aeaba09f55559695234941a5a6ada0cd4825860d", + ), + ( + "dng-pentax-mx-1.dng", + "https://raw.pixls.us/getfile.php/1731/nice/Pentax%20-%20MX-1%20-%2012bit%20%284:3%29.dng", + "b00f3fd42479bbaba7a00b2b6fa3e5f5d213c51d591adff4f6eb4a768ecd1ce9", + ), + ( + "cr2-canon-eos-5d.cr2", + "https://raw.pixls.us/getfile.php/1346/nice/Canon%20-%20EOS%205D%20-%20RAW%20%283:2%29.CR2", + "b30089172973388d03e192a14a63f750e22914dfc01db5713b6b3bce3c9baeee", + ), + ( + "pef-pentax-k100d-super.pef", + "https://raw.pixls.us/getfile.php/5804/nice/Pentax%20-%20K100D%20Super%20-%2012bit%2012bit%20uncompressed%20%283:2%29.PEF", + "b391c84ff1cc8769ec5214f837d1e4b3ae80374673608601b12ee4484e003ddb", + ), + ( + "cr2-canon-eos-m6.cr2", + "https://raw.pixls.us/getfile.php/1382/nice/Canon%20-%20EOS%20M6%20-%20RAW%20%283:2%29.CR2", + "b3a7a0037600b61d4c26ad03d5cac1801e35fb43a55699db5b77708c96f7338a", + ), + ( + "cr2-canon-powershot-g7-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1682/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "b81acebf5ac49b0389c354942d128d9423fdd59ff92217554f232f94e3e87ef5", + ), + ( + "cr2-canon-eos-7d-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1064/nice/Canon%20-%20EOS%207D%20Mark%20II%20-%20RAW%20%283:2%29.cr2", + "b9f71c56adb6e2861e6653efb312fb5cc88ba4aa17fa809c6a18b8db044360c3", + ), + ( + "dng-ricoh-gr-ii.dng", + "https://raw.pixls.us/getfile.php/1955/nice/Ricoh%20-%20GR%20II%20-%2012bit%20%283:2%29.DNG", + "bc8e00ccfd8e4753e8ee0735255bff4dcca5fb8d1a1352adfa59b86806d03f44", + ), + ( + "cr2-canon-powershot-s120.cr2", + "https://raw.pixls.us/getfile.php/1694/nice/Canon%20-%20PowerShot%20S120%20-%20RAW%20%284:3%29.CR2", + "bcbfadad8ecdbcc783864a2b769aef37588e2a6339b9682dcd9642602bd2011f", + ), + ( + "cr2-canon-powershot-s95.cr2", + "https://raw.pixls.us/getfile.php/1742/nice/Canon%20-%20PowerShot%20S95%20-%20RAW%20%284:3%29.cr2", + "c4a55c61434b8403f948518deca22e8ab755099f124d5cea5bc1cd0ea86711ad", + ), + ( + "cr2-canon-powershot-g1-x-mark-iii.cr2", + "https://raw.pixls.us/getfile.php/2695/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20III%20-%20RAW%20%283:2%29.CR2", + "ca052ed025432fdacfba7347cb4374fa3b727155912a91c42ed9a275d13bedf5", + ), + ( + "cr2-canon-eos-50d.cr2", + "https://raw.pixls.us/getfile.php/1792/nice/Canon%20-%20EOS%2050D%20-%20RAW%20%283:2%29.CR2", + "ce460cf2b6762c8c22ebeb00e9e1edccd728edcb7c4e82c78f3bce61d21fa88d", + ), + ( + "dng-pentax-k-s2.dng", + "https://raw.pixls.us/getfile.php/1626/nice/Pentax%20-%20K-S2%20-%2012bit%20%283:2%29.DNG", + "d23916a31f09c961e6050e4120bbcd92f7847080f9e40fad8dc7047ead1dec2d", + ), + ( + "cr2-canon-eos-20d.cr2", + "https://raw.pixls.us/getfile.php/1018/nice/Canon%20-%20EOS%2020D%20-%20RAW%20%283:2%29.CR2", + "db78660d48589130d99e0bc621424851eb0f84df052634fb5bc89c7a02fe7033", + ), + ( + "pef-pentax-k-r.pef", + "https://raw.pixls.us/getfile.php/8171/nice/Pentax%20-%20K-r%20-%2012bit%20compressed%20%283:2%29.PEF", + "dd0d7b76d89039995d253adddcfb44d131414fad2179610d9ea4d8d4bb876b7b", + ), + ( + "pef-pentax-kf.pef", + "https://raw.pixls.us/getfile.php/6664/nice/Pentax%20-%20KF%20-%2014bit%20%283:2%29.PEF", + "ddc9cdf399a7210b203dee1765ab9f4165a2e695da59bce6bcabf75b77174a4b", + ), + ( + "cr2-canon-powershot-g7-x.cr2", + "https://raw.pixls.us/getfile.php/1277/nice/Canon%20-%20PowerShot%20G7%20X%20-%20RAW%20%283:2%29.CR2", + "e4b0a64df6f43252ab96b4789f698ca334e86586ab151d1e9f5616b80172abcd", + ), + ( + "cr2-canon-eos-450d.cr2", + "https://raw.pixls.us/getfile.php/1353/nice/Canon%20-%20EOS%20450D%20-%20RAW%20%283:2%29.CR2", + "e975f71429b939701dd618a7bac4709c1409f4bb6aa1c3607ceeaa86031c1c4a", + ), + ( + "cr2-canon-powershot-sx60-hs.cr2", + "https://raw.pixls.us/getfile.php/4942/nice/Canon%20-%20PowerShot%20SX60%20HS%20-%20RAW%20%284:3%29.CR2", + "f005e82fe8579876d7e9d39291c57f4410412904dfff0de5bbad9f32b3189612", + ), + ( + "dng-pentax-k-3-ii.dng", + "https://raw.pixls.us/getfile.php/1363/nice/Pentax%20-%20K-3%20II%20-%2014bit%20%283:2%29.DNG", + "f43b9dc35e4d1b43918d2d4bd0973bc202af48ed24c924e4aff61250c971b171", + ), + ( + "dng-pentax-k-7.dng", + "https://raw.pixls.us/getfile.php/827/nice/Pentax%20-%20K-7%20-%2012bit%20%283:2%29.DNG", + "f441c36678dfca4f38e25a580f4f8485d78d8067a054153bc36be71e56c92386", + ), + ( + "cr2-canon-eos-kiss-x9.cr2", + "https://raw.pixls.us/getfile.php/2062/nice/Canon%20-%20EOS%20Kiss%20X9%20-%20RAW%20%283:2%29.CR2", + "f50cea4641c97c1154a214a686226ba900002f00f4f844af858ef80b9ec8dea6", + ), + ( + "cr2-canon-powershot-g9-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1575/nice/Canon%20-%20PowerShot%20G9%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "f91f1a3b6fa66b2e15fd26c1bd78e1eb19dfa7e6a3886aff36e7ab96fd1c319c", + ), + ( + "cr2-canon-eos-760d.cr2", + "https://raw.pixls.us/getfile.php/2094/nice/Canon%20-%20EOS%20760D%20-%20RAW%20%283:2%29.cr2", + "f98108c5efd57eef483009cdb490ada2071d1c1b58a82df3de997b4d86d849c2", + ), + ( + "cr2-canon-eos-30d.cr2", + "https://raw.pixls.us/getfile.php/1791/nice/Canon%20-%20EOS%2030D%20-%20RAW%20%283:2%29.CR2", + "fb216f8df8d7abca4eb9d9f8dc594c1d2b69d0a6369fb8b4107743343c37144f", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From 08e692f7c94e03076df2edaa118352c17c1009b4 Mon Sep 17 00:00:00 2001 From: neronek22 <aantoszek@gmail.com> Date: Sun, 11 Oct 2026 20:38:21 +0200 Subject: [PATCH 023/315] Add complete Polish (pl) localization --- crates/ui-egui/locales/pl-formats.json | 232 ++ crates/ui-egui/locales/pl.json | 2043 +++++++++++++++++ crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/menus.rs | 2 + .../crates/ui-egui/locales/pl-formats.json | 232 ++ packaging/crates/ui-egui/locales/pl.json | 2043 +++++++++++++++++ packaging/crates/ui-egui/src/i18n.rs | 1354 +++++++++++ packaging/crates/ui-egui/src/menus.rs | 1715 ++++++++++++++ 8 files changed, 7623 insertions(+) create mode 100644 crates/ui-egui/locales/pl-formats.json create mode 100644 crates/ui-egui/locales/pl.json create mode 100644 packaging/crates/ui-egui/locales/pl-formats.json create mode 100644 packaging/crates/ui-egui/locales/pl.json create mode 100644 packaging/crates/ui-egui/src/i18n.rs create mode 100644 packaging/crates/ui-egui/src/menus.rs diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json new file mode 100644 index 000000000..8bdf25420 --- /dev/null +++ b/crates/ui-egui/locales/pl-formats.json @@ -0,0 +1,232 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} wśród kilku podobnych plików: użyj polecenia Zlokalizuj)", + " ({} in Recently Deleted)": " ({} w Ostatnio usuniętych)", + " · {:.0}% clipped": " · {:.0}% prześwietlonych obszarów", + " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", + " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", + " · {} duplicate{} skipped": " · pominięto {} duplikatów{}", + " · {} failed: {}": " · {} nieudanych: {}", + " · {} moved": " · {} przeniesionych", + " · {} not readable": " · {} nie można odczytać", + " · {} original{} left at the source": " · {} oryginalnych{} pozostawiono w źródle", + " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", + " — not carried over: {}{}": " — nie przeniesiono: {}{}", + "+{} more": "+{} więcej", + ", {failed} file{} not readable": ", nie można odczytać {failed} plików{}", + "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", + "; {outside} outside its time range": "; {outside} poza zakresem dat", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", + "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", + "Add {} Photo{}": "Dodaj {} zdjęć{}", + "Add {} Photo{}, restore {}": "Dodaj {} zdjęć{}, przywróć {}", + "Add “{}” to Favorites": "Dodaj „{}” do ulubionych", + "Added {n} photo{} to My Photos": "Dodano {n} zdjęć{} do Moich zdjęć", + "Added {n} photo{} to “{}”": "Dodano {n} zdjęć{} do „{}”", + "Added {what} to {name}": "Dodano {what} do {name}", + "Adding photos… {} of {}": "Dodawanie zdjęć… {} z {}", + "All {n} selected photos move by the same amount.": "Wszystkie {n} zaznaczone zdjęcia zostaną przesunięte o tę samą wartość.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Już w bibliotece, w Ostatnio usuniętych: zaznaczono {} zdjęć{} · kliknij prawym przyciskiem ▸ Przywróć albo Usuń trwale, aby zaimportować ponownie", + "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece: zaznaczono {} zdjęć{} we Wszystkich zdjęciach", + "Auto ({})": "Automatycznie ({})", + "Auto Import: added {} photo{}": "Automatyczny import: dodano {} zdjęć{}", + "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", + "Automatic ({} MB)": "Automatycznie ({} MB)", + "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", + "Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})", + "Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})", + "Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…", + "Click {}:1": "Kliknij {}:1", + "Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}", + "Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych", + "Creates {} stacks from {} of {scope}": "Tworzy {} stosów z {} w zakresie: {scope}", + "Decoder: {reason}": "Dekoder: {reason}", + "Delete \"{label}\"": "Usuń „{label}”", + "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", + "Delete {n} Photos": "Usuń {n} zdjęć", + "Delete “{current}”": "Usuń „{current}”", + "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", + "Done ({what}): {done} photo{plural}": "Gotowe ({what}): {done} zdjęć{plural}", + "Duplicate \"{label}\"": "Duplikuj „{label}”", + "Example: {example}": "Przykład: {example}", + "Exported {n} photo{}": "Wyeksportowano {n} zdjęć{}", + "Exported {ok} of {total} photo{}": "Wyeksportowano {ok} z {total} zdjęć{}", + "Exported {} point curve preset{}": "Wyeksportowano {} ustawień krzywej punktowej{}", + "Exported {} preset{}": "Wyeksportowano {} ustawień wstępnych{}", + "Exporting {} of {total}": "Eksportowanie {} z {total}", + "Filter bar — {active} active filter{}": "Pasek filtrów — {active} aktywnych filtrów{}", + "Focus {:.0}": "Ostrość {:.0}", + "Folder “{}” (and its subfolders)": "Folder „{}” (wraz z podfolderami)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Znaleziono {n} brakujących zdjęć{}; nadal brakuje {left}{unsure}", + "Healed {n} dust spot{}": "Usunięto {n} plamek kurzu{}", + "Import cancelled · {} photo{} added": "Import anulowany · dodano {} zdjęć{}", + "Import failed: {e}": "Import nieudany: {e}", + "Imported {n} point curve preset{}": "Zaimportowano {n} ustawień krzywej punktowej{}", + "Imported {n} preset{}": "Zaimportowano {n} ustawień wstępnych{}", + "Imported {n} preset{}, {f} file{} not readable": "Zaimportowano {n} ustawień wstępnych{}, nie można odczytać {f} plików{}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Zaimportowano {profiles} profili{} (Przeglądarka profili ▸ odpowiednie grupy)", + "Imported {} photo{}": "Zaimportowano {} zdjęć{}", + "Insert at the cursor (also written {})": "Wstaw w miejscu kursora (zostanie też zapisane {})", + "Label Set: {name}": "Zestaw etykiet: {name}", + "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących", + "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}", + "Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.", + "Matches: {}": "Dopasowania: {}", + "Merging… {stage} {:.0}%": "Scalanie… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Przenieść {what} do Ostatnio usuniętych?", + "Named ({named_n})": "Nazwane ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Przypisywanie „{k}”: klikaj zdjęcia, aby dodać lub usunąć słowo kluczowe · Esc kończy", + "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do {n} zdjęć{} — wklejone zostaną tylko skopiowane ustawienia", + "Preset: {name}": "Ustawienie wstępne: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Tylko podgląd — edycja pliku JPEG osadzonego przez aparat ({})", + "Preview only — {}": "Tylko podgląd — {}", + "Previews ready for {done} photo{plural}": "Podglądy gotowe dla {done} zdjęć{plural}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Kopia próbna ({})", + "Proof Preview · {}": "Podgląd próbny · {}", + "Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć", + "Quick Develop: {name}": "Szybka obróbka: {name}", + "Rated {}": "Ocena: {}", + "Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}", + "Reading photos… {done} of {total}": "Odczytywanie zdjęć… {done} z {total}", + "Reading photos… {} of {}": "Odczytywanie zdjęć… {} z {}", + "Redo {}": "Ponów {}", + "Remove “{name}” and its {count} photo{} from the library?": "Usunąć „{name}” i jego {count} zdjęć{} z biblioteki?", + "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", + "Removed from {}": "Usunięto z {}", + "Removed {what} from {name}": "Usunięto {what} z {name}", + "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", + "Rename “{name}”": "Zmień nazwę „{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na {n} zdjęciach{} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", + "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", + "Replace {} with:": "Zamień {} na:", + "Reset {name}": "Wyzeruj {name}", + "Reset {} Channel": "Wyzeruj kanał {}", + "Restore {}": "Przywróć {}", + "Restore {} Photo{}": "Przywróć {} zdjęć{}", + "Saved export preset “{}”": "Zapisano ustawienie eksportu „{}”", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Ocenia ostrość i ekspozycję zdjęć z zakresu {scope} oraz znajduje podobne ujęcia wykonane w odstępie kilku sekund (serie).", + "Show {hidden} hidden location{}": "Pokaż {hidden} ukrytych lokalizacji{}", + "Showing {n} of {}": "Wyświetlono {n} z {}", + "Smart album: {}": "Album inteligentny: {}", + "Tagged {n} photo{} from the tracklog": "Dodano geotagi do {n} zdjęć{} na podstawie śladu GPS", + "The SAM 3 download failed: {e}": "Nie udało się pobrać SAM 3: {e}", + "Undo {}": "Cofnij {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Nieznane znaczniki: {s} {} pozostaną{} w wpisanej postaci (zobacz Znaczniki)", + "Updated “{name}”": "Zaktualizowano „{name}”", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", + "Version: {}": "Wersja: {}", + "Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}", + "Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…", + "none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}", + "the {n} selected photos": "{n} zaznaczonych zdjęć", + "the {} photos in view": "{} zdjęć w bieżącym widoku", + "these {count} photos": "te {count} zdjęcia", + "{count} smart preview{} · {:.1} MB": "{count} podglądów inteligentnych{} · {:.1} MB", + "{f} folders (and their subfolders)": "{f} folderów (wraz z podfolderami)", + "{label} Grading": "Gradacja: {label}", + "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", + "{label} {index} of {n}": "{label} {index} z {n}", + "{label}, {n} photos": "{label}, {n} zdjęć", + "{matching} of {t} photos": "{matching} z {t} zdjęć", + "{matching} photos": "{matching} pasujących zdjęć", + "{mode} spot {} of {}": "{mode}: plamka {} z {}", + "{month}": "{month}", + "{month} {year}": "{month} {year}", + "{msg} ({failed} failed)": "{msg} (nieudanych: {failed})", + "{name} Label": "Etykieta {name}", + "{name} Off": "{name}: wyłączone", + "{name} On": "{name}: włączone", + "{name} opened for editing; it is stacked with the original": "{name} otwarto do edycji; kopia znajduje się w stosie z oryginałem", + "{name} — double-click to reset it": "{name} — kliknij dwukrotnie, aby wyzerować", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nie można zapisać {n} zmian{} na dysku: {e}\nJeśli teraz zamkniesz program, utracisz {}.", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu oznacza utratę {}.", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e} — LightCraft ponowi próbę", + "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", + "{n} photos": "{n} zdjęć", + "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", + "{n} photo{}": "{n} zdjęć{}", + "{n} photo{} match · updates automatically as photos change": "{n} pasujących zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", + "{n} photo{} now · updates automatically as photos change": "Obecnie {n} zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", + "{pid} (missing)": "{pid} (brak)", + "{sel_n} selected · {counted}": "Zaznaczono {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} zdjęć{}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", + "{what} merge added": "Dodano po scaleniu: {what}", + "{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}", + "{} and higher": "{} i nowsze", + "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", + "{} failed": "{} nieudanych", + "{} files": "{} plików", + "{} files and {f} folder{}": "{} plików i {f} folderów{}", + "{} item{}": "{} elementów{}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", + "{} photos": "{} zdjęć", + "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Nie można odczytać {} zdjęć{}", + "{} sample{}": "{} próbek{}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} plamek na tym zdjęciu", + "{} — show their photos": "{} — pokaż ich zdjęcia", + "· {dups} already in the library": "· {dups} już w bibliotece", + "· {n} photo{}": "· {n} zdjęć{}", + "… and {more} more": "… i jeszcze {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", + "Filtered: {label}": "Filtrowanie: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} podobnych zdjęć{} · Widok ▸ Wyczyść filtry, aby zobaczyć wszystkie", + "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", + "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", + ", {} star{}": ", {} gwiazdek{}", + ", {} label": ", {} etykieta", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Pobieranie z {host}. Po pobraniu i weryfikacji LightCraft zainstaluje plik, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Działa na procesorze: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Przechowywane w folderze „denoise” biblioteki; po osiągnięciu limitu najpierw usuwane są najstarsze pliki nieużywane przez żadne zdjęcie.", + "Denoising {name}: {done} of {total} tiles{}": "Odszumianie {name}: {done} z {total} kafelków{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} z {} z serwera {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Wytrenowano na: {provenance}", + " · {queued} waiting": " · {queued} oczekuje", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.", + "In line for AI Denoise ({ahead} ahead)…": "W kolejce do odszumiania AI ({ahead} przed tym zadaniem)…", + "Running on {}.": "Działa na {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Umieść wewnątrz „{parent}”", + "Delete the keyword “{name}”?": "Usunąć słowo kluczowe „{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Zostanie usunięte z {count} zdjęć{}, razem z podrzędnymi słowami kluczowymi. Możesz to cofnąć.", + "Create Keyword Tag Inside “{name}”…": "Utwórz podrzędne słowo kluczowe w „{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}” zawiera już „{name}”. Scalić oba?", + "There is a “{name}” at the top level already. Merge the two?": "Słowo kluczowe „{name}” istnieje już na najwyższym poziomie. Scalić oba?", + "Added “{keyword}” to {n} photo{}": "Dodano „{keyword}” do {n} zdjęć{}", + "On {have} of {of} selected photos": "Na {have} z {of} zaznaczonych zdjęć", + "Remove “{name}”": "Usuń „{name}”", + "There is a keyword set “{name}” already": "Istnieje już zestaw słów kluczowych „{name}”", + "Added {n} keyword{}": "Dodano {n} słów kluczowych{}", + "; {n} gained synonyms": "; {n} otrzymało synonimy", + "Exported {n} keyword{}": "Wyeksportowano {n} słów kluczowych{}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One nie zaimportuje listy zawierającej ; , < lub > w słowie kluczowym: {names}", + " — left out, as a keyword list can't hold them: {names}": " — pominięto, ponieważ lista słów kluczowych ich nie obsługuje: {names}", + "{done} of {total}": "{done} z {total}", + "{done} of {total} MB": "{done} z {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} więcej", + "1 task is still running: {names}. Quitting stops it.": "Wciąż trwa 1 zadanie: {names}. Zamknięcie programu je zatrzyma.", + "{n} tasks are still running: {names}. Quitting stops them.": "Wciąż trwają {n} zadania: {names}. Zamknięcie programu je zatrzyma.", + "{what} merge cancelled": "Scalanie anulowane: {what}" +} diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json new file mode 100644 index 000000000..f69e22643 --- /dev/null +++ b/crates/ui-egui/locales/pl.json @@ -0,0 +1,2043 @@ +{ + " (library default)": " (domyślne ustawienie biblioteki)", + " (inverted)": " (odwrócona)", + " · best of its burst": " · najlepsze zdjęcie z serii", + " · in a burst": " · w serii zdjęć", + "% of all commits": "% wszystkich zatwierdzonych zmian", + "+ Group": "+ Grupa", + "+ Rule": "+ Reguła", + "0: nothing is rejected.": "0: żadne zdjęcie nie jest odrzucane.", + "1 day": "1 dzień", + "= exactly": "= dokładnie", + "A blank template keeps the original names.": "Pusty szablon zachowuje oryginalne nazwy.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Filtr ukrywa zdjęcia w tym widoku: zmień go lub wyczyść (Widok → Wyczyść filtry)", + "A one-time download of about": "Jednorazowe pobranie około", + "AI Mask Model Status": "Stan modelu masek AI", + "About": "O programie", + "About LightCraft": "O LightCraft", + "Active": "Aktywny", + "Activity": "Aktywność", + "Actual size": "Rzeczywisty rozmiar", + "Add": "Dodaj", + "Add Catchlight": "Dodaj błysk w oku", + "Add Folder to Local": "Dodaj folder do lokalnych", + "Add Mask": "Dodaj maskę", + "Add Object Click": "Dodaj zaznaczenie obiektu kliknięciem", + "Add Point Color Sample": "Dodaj próbkę koloru punktowego", + "Add Red Eye Correction": "Dodaj korektę czerwonych oczu", + "Add Remove Spot": "Dodaj usuwanie plamki", + "Add Selected Photos": "Dodaj zaznaczone zdjęcia", + "Add a rule (⌥-click: a group)": "Dodaj regułę (⌥ + kliknięcie: grupa)", + "Add in place": "Dodaj bez kopiowania plików", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.", + "Add keyword": "Dodaj słowo kluczowe", + "Add number": "Dodaj numer", + "Add to": "Dodaj do", + "Add to Album": "Dodaj do albumu", + "Add to Favorites": "Dodaj do ulubionych", + "Add to Local": "Dodaj do lokalnych", + "Add to My Photos": "Dodaj do Moich zdjęć", + "Add to Selected Photos": "Dodaj do zaznaczonych zdjęć", + "Add to Target Album": "Dodaj do albumu docelowego", + "Add to this mask": "Dodaj do tej maski", + "Add/Subtract/Intersect Mask": "Dodaj/Odejmij/Przetnij maskę", + "Added to photos you import that don't already have it.": "Dodawane do importowanych zdjęć, które jeszcze tego nie mają.", + "Adding": "Dodawanie", + "Adding Lightroom photos…": "Dodawanie zdjęć z Lightrooma…", + "Advanced search…": "Wyszukiwanie zaawansowane…", + "After": "Po", + "After 30 days": "Po 30 dniach", + "After 7 days": "Po 7 dniach", + "After 90 days": "Po 90 dniach", + "After a year": "Po roku", + "Album": "Album", + "Albums": "Albumy", + "All": "Wszystkie", + "All Metadata": "Wszystkie metadane", + "All Metadata…": "Wszystkie metadane…", + "All Photos": "Wszystkie zdjęcia", + "All rights reserved": "Wszelkie prawa zastrzeżone", + "All the photos imported from this disk are included.": "Uwzględniono wszystkie zdjęcia zaimportowane z tego dysku.", + "All types": "Wszystkie typy", + "Alt Text": "Tekst alternatywny", + "Always": "Zawsze", + "Amount": "Ilość", + "Analyzing the photo for AI masks…": "Analizowanie zdjęcia pod kątem masek AI…", + "Any Searchable Text": "Dowolny tekst do wyszukania", + "Aperture": "Przysłona", + "Application": "Aplikacja", + "Apply": "Zastosuj", + "Apply Color Label Set": "Zastosuj zestaw etykiet kolorów", + "Apply Filter Preset": "Zastosuj ustawienia wstępne filtra", + "Apply Metadata Preset": "Zastosuj ustawienia wstępne metadanych", + "Apply Point Curve Preset": "Zastosuj ustawienia wstępne krzywej punktowej", + "Apply Preset": "Zastosuj ustawienie wstępne", + "Apply Settings JSON": "Zastosuj ustawienia JSON", + "Approved": "Zatwierdzone", + "Aqua": "Turkusowy", + "Aqua Hue": "Odcień turkusowego", + "Aqua Luminance": "Jasność turkusowego", + "Aqua Saturation": "Nasycenie turkusowego", + "Arrange Panel": "Rozmieść panel", + "ArtCraft Website": "Witryna ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — więcej aplikacji kreatywnych", + "As Shot": "Jak na zdjęciu", + "As shot": "Jak zrobiono zdjęcie", + "Ascending": "Rosnąco", + "Aspect": "Proporcje", + "Aspect Ratio": "Proporcje obrazu", + "Assisted Culling": "Wspomagana selekcja", + "Assisted Culling…": "Wspomagana selekcja…", + "Auto": "Automatycznie", + "Auto Advance": "Automatyczne przechodzenie dalej", + "Auto Advance Off": "Automatyczne przechodzenie dalej wyłączone", + "Auto Advance On": "Automatyczne przechodzenie dalej włączone", + "Auto Advance after rating or flagging": "Przechodź dalej po ocenie lub oznaczeniu", + "Auto Advance: move to the next photo after rating or flagging": "Automatyczne przejście: przejdź do następnego zdjęcia po nadaniu oceny lub oznaczeniu flagą", + "Auto Align": "Automatyczne wyrównanie", + "Auto B&W Mix": "Automatyczne mieszanie czerni i bieli", + "Auto Crop": "Automatyczne kadrowanie", + "Auto Import": "Automatyczny import", + "Auto Import Now": "Importuj automatycznie teraz", + "Auto Import Settings": "Automatyczne ustawienia importu", + "Auto Mask": "Automatyczna maska", + "Auto Select": "Automatyczny wybór", + "Auto Settings": "Ustawienia automatyczne", + "Auto Straighten": "Automatyczne prostowanie", + "Auto Sync": "Automatyczna synchronizacja", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatyczna synchronizacja: zmiany obejmują wszystkie zaznaczone zdjęcia. Automatyczne mieszanie czerni i bieli. Automatyczne wersje.", + "Auto settings applied": "Zastosowano ustawienia automatyczne", + "Auto-Stack by Capture Time": "Automatycznie grupuj według czasu wykonania", + "Auto-Stack by Capture Time…": "Automatycznie grupuj według czasu wykonania…", + "Auto-Tag Photos from Tracklog": "Automatycznie oznacz zdjęcia na podstawie śladu GPS", + "Auto-Tag from Tracklog": "Automatycznie oznacz na podstawie śladu GPS", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatyczne tagowanie ze śladu: przypisuje zdjęciom pozycje GPS ze śladu GPX na podstawie czasu wykonania.", + "Auto-Tag from Tracklog…": "Automatycznie oznacz na podstawie śladu GPS…", + "Automatic": "Automatycznie", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Tryb automatyczny renderuje widok szczegółów w wyświetlanym rozmiarze, nie przekraczając rozdzielczości zdjęcia. Wybierz mniejszy limit, aby przyspieszyć działanie.", + "Automatically Write Changes into XMP": "Automatycznie zapisuj zmiany w XMP", + "Automatically write changes into XMP sidecars": "Automatycznie zapisuj zmiany w plikach pomocniczych XMP", + "Autumn Gold": "Jesienne złoto", + "B&W": "Czarno-białe", + "B&W Mix": "Mieszanie czerni i bieli", + "B&W Mixer": "Mikser czerni i bieli", + "Back": "Wstecz", + "Back Up Library…": "Utwórz kopię zapasową biblioteki…", + "Back to Edit": "Wróć do edycji", + "Back to Edit (E)": "Wróć do edycji (E)", + "Back to Grid": "Wróć do siatki", + "Background": "Tło", + "Balance": "Balans", + "Basic": "Podstawowe", + "Before": "Przed", + "Before/After (Y)": "Przed/Po (Y)", + "Before/After Settings": "Ustawienia porównania przed/po", + "Before/After Split": "Podział przed/po", + "Before/After Split Top/Bottom": "Podział przed/po góra/dół", + "Before/After Top/Bottom": "Przed/Po góra/dół", + "Begin Interaction": "Rozpocznij interakcję", + "Best of Similar Shots": "Najlepsze spośród podobnych ujęć", + "Binary assets added": "Dodano zasoby binarne", + "Binary assets removed": "Usunięto zasoby binarne", + "Bit depth": "Głębia bitowa", + "Black & White": "Czarno-białe", + "Blacks": "Czernie", + "Bleach Bypass": "Pominięcie wybielania", + "Bleached": "Wyblakły", + "Blending": "Mieszanie", + "Blue": "Niebieski", + "Blue Hour Boost": "Wzmocnienie błękitnej godziny", + "Blue Hue": "Odcień niebieskiego", + "Blue Luminance": "Luminancja niebieskiego", + "Blue Primary": "Podstawowy niebieski", + "Blue Saturation": "Nasycenie niebieskiego", + "Blue channel": "Kanał niebieski", + "Blue label": "Niebieska etykieta", + "Blue/Yellow Fringe": "Niebiesko-żółte obwódki", + "Blurry photos below the score are flagged as rejects.": "Nieostre zdjęcia z oceną poniżej progu są oznaczane jako odrzucone.", + "Boundary Warp": "Dopasowanie granic", + "Bright & Airy": "Jasne i lekkie", + "Bring All to Front": "Przenieś wszystko na wierzch", + "Browse Folder": "Przeglądaj folder", + "Browse Folder…": "Przeglądaj folder…", + "Browse Profiles": "Przeglądaj profile", + "Browse…": "Przeglądaj…", + "Brush": "Pędzel", + "Brush Settings": "Ustawienia pędzla", + "Brush Stroke": "Pociągnięcie pędzla", + "Build Previews": "Utwórz podglądy", + "Build Smart Previews": "Utwórz podglądy inteligentne", + "By Date": "Według daty", + "By day (YYYY/YYYY-MM-DD)": "Według dnia (RRRR/RRRR-MM-DD)", + "By month (YYYY/YYYY-MM)": "Według miesiąca (RRRR/RRRR-MM)", + "CPU threads": "Wątki procesora", + "Calibration": "Kalibracja", + "Camera": "Aparat", + "Camera make and model": "Marka i model aparatu", + "Cameras and Cards": "Aparaty i karty pamięci", + "Cancel": "Anuluj", + "Cancel AI Mask Model Download": "Anuluj pobieranie modelu masek AI", + "Cancel Download": "Anuluj pobieranie", + "Cancel Interaction": "Anuluj interakcję", + "Cancel Preview Build": "Anuluj tworzenie podglądu", + "Candidate": "Kandydat", + "Caption": "Podpis", + "Capture Date": "Data wykonania zdjęcia", + "Capture date and time in your own format (directives below)": "Data i godzina wykonania zdjęcia we własnym formacie (dyrektywy poniżej)", + "Capture date as YYYYMMDD": "Data wykonania jako RRRRMMDD", + "Captured": "Wykonano", + "Catalog Statistics": "Statystyki katalogu", + "Catchlight": "Błysk w oku", + "Change": "Zmień", + "Change Component Mode": "Zmień tryb składnika", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Znaki niedozwolone w nazwach plików (/ \\ : * ? \" < > |) są zastępowane myślnikiem; jeśli nazwa już istnieje, dodawane są przyrostki -1, -2….", + "Check All": "Zaznacz wszystko", + "Check Output Path": "Sprawdź ścieżkę wyjściową", + "Check the files of this type": "Sprawdź pliki tego typu", + "Checking names…": "Sprawdzanie nazw…", + "Choose Another Library…": "Wybierz inną bibliotekę…", + "Choose Edit Settings to Copy": "Wybierz ustawienia edycji do skopiowania", + "Choose Edit Settings to Copy…": "Wybierz ustawienia edycji do skopiowania…", + "Choose Folder…": "Wybierz folder…", + "Choose a photo in the grid or filmstrip": "Wybierz zdjęcie w siatce lub na pasku zdjęć", + "Choose…": "Wybierz…", + "Cinematic": "Filmowy", + "City": "Miasto", + "Clarity": "Przejrzystość", + "Clean Studio": "Czyste studio", + "Clear": "Wyczyść", + "Clear Cache": "Wyczyść pamięć podręczną", + "Clear Filters": "Wyczyść filtry", + "Clear Guides": "Wyczyść prowadnice", + "Clear History": "Wyczyść historię", + "Clear Preview Cache": "Wyczyść pamięć podręczną podglądów", + "Clear Quick Collection": "Wyczyść szybką kolekcję", + "Clear Reference": "Wyczyść zdjęcie referencyjne", + "Clear all": "Wyczyść wszystko", + "Clear form": "Wyczyść formularz", + "Clear rating": "Usuń ocenę", + "Clear rating and advance": "Usuń ocenę i przejdź dalej", + "Cleared History": "Wyczyszczono historię", + "Click Zoom Ratio": "Współczynnik powiększenia po kliknięciu", + "Click a shortcut, then press the new keys (Esc cancels).": "Kliknij skrót, a następnie naciśnij nowe klawisze (Esc anuluje).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Kliknij znacznik, aby go wstawić. Przykłady dotyczą pliku IMG_0042.CR3 z 14 stycznia 2026 r., godz. 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klikaj zdjęcia w siatce, aby przełączać słowo kluczowe; Esc kończy", + "Click the object to select it · ⌥-click leaves a part out": "Kliknij obiekt, aby go zaznaczyć · ⌥-kliknięcie wyklucza fragment", + "Click the photo to pick a colour · ⇧-click adds more": "Kliknij zdjęcie, aby pobrać kolor · ⇧-kliknięcie dodaje kolejne", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknij zdjęcie, aby pobrać kolor; ⇧-kliknięcie dodaje kolejne (maks. 5)", + "Click to clear these filters": "Kliknij, aby wyczyścić te filtry", + "Click to restore · double-click to rename": "Kliknij, aby przywrócić · kliknij dwukrotnie, aby zmienić nazwę", + "Clone": "Klonuj", + "Close": "Zamknij", + "Close Panel": "Zamknij panel", + "Close Window": "Zamknij okno", + "Close all": "Zamknij wszystko", + "Close file": "Zamknij plik", + "Close panel": "Zamknij panel", + "Cloudy": "Pochmurno", + "Collapse": "Zwiń", + "Collapse All Stacks": "Zwiń wszystkie stosy", + "Collapse Stack": "Zwiń stos", + "Color": "Kolor", + "Color Grading": "Gradacja kolorów", + "Color Label": "Etykieta kolorystyczna", + "Color Label Names": "Nazwy etykiet kolorów", + "Color Label Sets": "Zestawy etykiet kolorów", + "Color Mixer": "Mikser kolorów", + "Color Noise Reduction": "Redukcja szumu koloru", + "Color Priority": "Priorytet kolorów", + "Color Range": "Zakres kolorów", + "Color label cleared": "Usunięto etykietę koloru", + "Color space": "Przestrzeń barw", + "Color space: sRGB (AVIF)": "Przestrzeń kolorów: sRGB (AVIF)", + "Colors": "Kolory", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtrowanie według kilku etykiet kolorystycznych jednocześnie; rozwijane drzewo folderów w widoku lokalnym.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Maski zakresu barw: kliknij zdjęcie, aby pobrać próbkę. Zakresy luminancji: pasek zakresu, płynność przejścia, mapa luminancji.", + "Combine files…": "Połącz pliki…", + "Command Journal": "Dziennik poleceń", + "Comments panel": "Panel komentarzy", + "Commits": "Zatwierdzone zmiany", + "Company": "Firma", + "Compare": "Porównaj", + "Compare (Shift+C)": "Porównaj (Shift+C)", + "Compare Before and After": "Porównaj przed i po", + "Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć", + "Component options": "Opcje składnika", + "Compression": "Kompresja", + "Concrete Cool": "Chłodny beton", + "Confirm before deleting": "Potwierdzaj przed usunięciem", + "Confirm before moving photos to Recently Deleted": "Potwierdzaj przeniesienie zdjęć do ostatnio usuniętych", + "Constrain Crop": "Ogranicz kadrowanie", + "Continue": "Kontynuuj", + "Continue Without Saving": "Kontynuuj bez zapisywania", + "Contrast": "Kontrast", + "Contributors": "Współtwórcy", + "Convert to DNG": "Konwertuj do DNG", + "Cool Fade": "Chłodne wyblaknięcie", + "Cool Morning": "Chłodny poranek", + "Copy": "Kopiuj", + "Copy After's Settings to Before": "Skopiuj ustawienia „Po” do „Przed”", + "Copy Before to After": "Skopiuj „Przed” do „Po”", + "Copy Before's Settings to After": "Skopiuj ustawienia „Przed” do „Po”", + "Copy Edit Settings": "Kopiuj ustawienia edycji", + "Copy History Step Settings to Before": "Skopiuj ustawienia kroku historii do „Przed”", + "Copy Metadata": "Kopiuj metadane", + "Copy as DNG": "Kopiuj jako DNG", + "Copy into the library (else use the files where they are)": "Kopiuj do biblioteki (w przeciwnym razie używaj plików w ich lokalizacji)", + "Copy pages": "Kopiuj strony", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopiuj pliki (do folderu Originals/ biblioteki lub wybranego folderu)", + "Copy to": "Kopiuj do", + "Copy to Clipboard": "Kopiuj do schowka", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopiuj: pliki zostaną skopiowane do poniższego folderu, a biblioteka będzie używać kopii; oryginały pozostaną bez zmian.", + "Copyright": "Prawa autorskie", + "Copyright Info URL": "Adres URL informacji o prawach autorskich", + "Copyright Status": "Status praw autorskich", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Stan praw autorskich, warunki wykorzystania i adres URL informacji o prawach autorskich w informacjach, ustawieniach wstępnych metadanych i eksporcie.", + "Country": "Kraj", + "Create Album": "Utwórz album", + "Create Album…": "Utwórz album…", + "Create Folder": "Utwórz folder", + "Create Folder…": "Utwórz folder…", + "Create New Mask": "Utwórz nową maskę", + "Create PDF from clipboard": "Utwórz PDF ze schowka", + "Create PDF from file…": "Utwórz PDF z pliku…", + "Create PDF from images…": "Utwórz PDF z obrazów…", + "Create Preset": "Utwórz ustawienie wstępne", + "Create Preset…": "Utwórz ustawienie wstępne…", + "Create Proof Copy": "Utwórz kopię próbną", + "Create Smart Album": "Utwórz inteligentny album", + "Create Smart Album from Filter…": "Utwórz album inteligentny z filtra…", + "Create Smart Album…": "Utwórz album inteligentny…", + "Create Stack": "Utwórz stos", + "Create Version": "Utwórz wersję", + "Create Version from Step": "Utwórz wersję z kroku", + "Create Virtual Copy": "Utwórz kopię wirtualną", + "Creator": "Twórca", + "Creator metadata": "Metadane twórcy", + "Crisp Landscape": "Wyrazisty krajobraz", + "Crisp Vista": "Wyrazisty widok", + "Crop": "Kadruj", + "Crop & Rotate": "Kadrowanie i obrót", + "Crop & Rotate (C)": "Kadruj i obracaj (C)", + "Crop Aspect": "Proporcje kadrowania", + "Crop, Rotate, Geometry": "Kadrowanie, obrót, geometria", + "Cross Process": "Obróbka krzyżowa", + "Culling": "Selekcja zdjęć", + "Curve": "Krzywa", + "Custom": "Niestandardowe", + "Custom template…": "Szablon niestandardowy…", + "Custom…": "Niestandardowe…", + "Cut": "Wytnij", + "Cut pages": "Wytnij strony", + "Cycle Crop Overlay": "Przełącz nakładkę kadrowania", + "Cycle Crop Overlay Orientation": "Przełącz orientację nakładki kadrowania", + "Cycle Info Overlay": "Przełącz nakładkę informacji", + "Cycle Mask Overlay Color": "Przełącz kolor nakładki maski", + "Cycle Mask Overlay Mode": "Przełącz tryb nakładki maski", + "Cylindrical": "Cylindryczny", + "Dark & Moody": "Ciemny i nastrojowy", + "Darken": "Przyciemnij", + "Darks": "Ciemne tony", + "Date": "Data", + "Date Groups": "Grupy dat", + "Day": "Dzień", + "Daylight": "Światło dzienne", + "Decrease Brush Feather": "Zmniejsz miękkość pędzla", + "Decrease Brush Size": "Zmniejsz rozmiar pędzla", + "Deep Sky": "Głębokie niebo", + "Defringe": "Usuń obwódki barwne", + "Deghost Amount": "Intensywność usuwania duchów", + "Dehaze": "Usuń zamglenie", + "Delete": "Usuń", + "Delete (⌫)": "Usuń (⌫)", + "Delete Album": "Usuń album", + "Delete All Masks": "Usuń wszystkie maski", + "Delete Color Label Set": "Usuń zestaw etykiet kolorów", + "Delete Component": "Usuń składnik", + "Delete Export Preset": "Usuń ustawienie wstępne eksportu", + "Delete Filter Preset": "Usuń ustawienie wstępne filtra", + "Delete Imported Profile": "Usuń zaimportowany profil", + "Delete Keyword": "Usuń słowo kluczowe", + "Delete Keyword Set": "Usuń zestaw słów kluczowych", + "Delete Mask": "Usuń maskę", + "Delete Metadata Preset": "Usuń ustawienie wstępne metadanych", + "Delete Permanently": "Usuń trwale", + "Delete Photo": "Usuń zdjęcie", + "Delete Photos": "Usuń zdjęcia", + "Delete Point Color": "Usuń kolor punktowy", + "Delete Point Color Sample": "Usuń próbkę koloru punktowego", + "Delete Point Curve Preset": "Usuń ustawienie wstępne krzywej punktowej", + "Delete Preset": "Usuń ustawienie wstępne", + "Delete Red Eye": "Usuń czerwone oczy", + "Delete Red Eye Correction": "Usuń korektę czerwonych oczu", + "Delete Spot": "Usuń plamkę", + "Delete Version": "Usuń wersję", + "Delete all": "Usuń wszystko", + "Delete all spots": "Usuń wszystkie plamki", + "Delete mask": "Usuń maskę", + "Delete them": "Usuń je", + "Density": "Gęstość", + "Depth Range": "Zakres głębi", + "Descending": "Malejąco", + "Describe": "Opisz", + "Describe what to keep": "Opisz, co zachować", + "Describe what to leave out": "Opisz, co wykluczyć", + "Describe what to select": "Opisz, co zaznaczyć", + "Describe…": "Opisz…", + "Deselect": "Odznacz", + "Deselect All": "Odznacz wszystko", + "Desert Heat": "Pustynny żar", + "Desert Warmth": "Pustynne ciepło", + "Desktop": "Pulpit", + "Detail": "Szczegóły", + "Detail (D)": "Szczegóły (D)", + "Details": "Szczegóły", + "Diagonal": "Przekątna", + "Discard Preview Cache": "Odrzuć pamięć podręczną podglądów", + "Display P3": "Display P3", + "Display name": "Nazwa wyświetlana", + "Display theme": "Motyw wyświetlania", + "Distortion": "Zniekształcenie", + "Document properties…": "Właściwości dokumentu…", + "Don't enlarge": "Nie powiększaj", + "Done": "Gotowe", + "Download": "Pobierz", + "Download AI Mask Model": "Pobierz model masek AI", + "Download the SAM 3 Model?": "Pobrać model SAM 3?", + "Downloads": "Pobrane", + "Drag along the horizon; double-click for Auto": "Przeciągnij wzdłuż horyzontu; kliknij dwukrotnie, aby użyć trybu automatycznego", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Przeciągnij po oku na zdjęciu; źrenica zostanie wykryta automatycznie.", + "Draw Guides": "Rysuj prowadnice", + "Duplicate": "Duplikuj", + "Duplicate Component": "Duplikuj składnik", + "Duplicate Mask": "Duplikuj maskę", + "Duplicate and Invert Mask": "Duplikuj i odwróć maskę", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Każdy znak / rozpoczyna kolejny poziom folderu; znaczniki są uzupełniane osobno dla każdego zdjęcia (brakujące metadane oznaczają „nieznane”).", + "Edge": "Krawędź", + "Edit": "Edytuj", + "Edit (E)": "Edytuj (E)", + "Edit Capture Time": "Edytuj czas wykonania", + "Edit Capture Time…": "Edytuj czas wykonania…", + "Edit Color Label Names": "Edytuj nazwy etykiet kolorów", + "Edit Color Label Names…": "Edytuj nazwy etykiet kolorów…", + "Edit Copy for External Editor": "Edytuj kopię w edytorze zewnętrznym", + "Edit Date": "Edytuj datę", + "Edit Info": "Edytuj informacje", + "Edit Label Names": "Edytuj nazwy etykiet", + "Edit Label Names…": "Edytuj nazwy etykiet…", + "Edit Mask": "Edytuj maskę", + "Edit Mask Component": "Edytuj składnik maski", + "Edit Sections": "Edytuj sekcje", + "Edit Smart Album": "Edytuj album inteligentny", + "Edit Smart Album…": "Edytuj album inteligentny…", + "Edit Spot": "Edytuj plamkę", + "Edit in External Editor": "Edytuj w edytorze zewnętrznym", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Edytuj w zewnętrznym edytorze (⇧⌘E): powstaje 16-bitowa kopia TIFF, dodana do stosu i odświeżana po powrocie.", + "Edit settings copied": "Skopiowano ustawienia edycji", + "Edited": "Edytowane", + "Edited or not": "Edytowane lub nie", + "Editing": "Edycja", + "Effect": "Efekt", + "Effects": "Efekty", + "Empty Recently Deleted": "Opróżnij ostatnio usunięte", + "Enable Lens Corrections": "Włącz korekcję obiektywu", + "Enable Profile Corrections": "Włącz korekcje profilu", + "End Interaction": "Zakończ interakcję", + "English": "Angielski", + "Enter Full Screen": "Przejdź na pełny ekran", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Wprowadź datę jako RRRR-MM-DD GG:MM:SS", + "Erase": "Wymaż", + "Every EXIF, GPS and XMP field in the file": "Każde pole EXIF, GPS i XMP w pliku", + "Existing previews": "Istniejące podglądy", + "Exit": "Zakończ", + "Expand": "Rozwiń", + "Expand All Stacks": "Rozwiń wszystkie stosy", + "Expand Stack": "Rozwiń stos", + "Expand/Collapse Stack": "Rozwiń/Zwiń stos", + "Export": "Eksportuj", + "Export Album with Preset": "Eksportuj album z ustawieniem wstępnym", + "Export Album…": "Eksportuj album…", + "Export As…": "Eksportuj jako…", + "Export Group…": "Eksportuj grupę…", + "Export Now": "Eksportuj teraz", + "Export Point Curve Presets": "Eksportuj ustawienia wstępne krzywej punktowej", + "Export Point Curve Presets…": "Eksportuj ustawienia wstępne krzywej punktowej…", + "Export Presets": "Eksportuj ustawienia wstępne", + "Export Presets…": "Eksportuj ustawienia wstępne…", + "Export User Presets…": "Eksportuj ustawienia wstępne użytkownika…", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Nazwy plików przy eksporcie używają znaczników Zmień nazwy zdjęć ({title}, {seq:2}, {date:%Y-%m-%d}…) oraz nowych: {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Export stopped unexpectedly": "Eksport został nieoczekiwanie przerwany", + "Export with Preset": "Eksportuj z ustawieniem wstępnym", + "Export with Previous": "Eksportuj z poprzednimi ustawieniami", + "Exporting": "Eksportowanie", + "Export…": "Eksportuj…", + "Exposure": "Ekspozycja", + "Exposure (shutter · aperture · ISO)": "Ekspozycja (migawka · przysłona · ISO)", + "Extended Description": "Opis rozszerzony", + "External Editor": "Edytor zewnętrzny", + "Face Boxes": "Ramki twarzy", + "Face boxes": "Ramki twarzy", + "Face names written to XMP by Lightroom and other apps show up here.": "Imiona osób zapisane w XMP przez Lightrooma i inne aplikacje pojawiają się tutaj.", + "Faded Matte": "Wyblakły mat", + "Faded Slide": "Wyblakły slajd", + "Faded Urban": "Wyblakłe miasto", + "Favorite Preset": "Ulubione ustawienie wstępne", + "Favorite Profile": "Ulubiony profil", + "Favorites": "Ulubione", + "Feather": "Miękkość krawędzi", + "File": "Plik", + "File & date": "Plik i data", + "File Format": "Format pliku", + "File Name": "Nazwa pliku", + "File Path": "Ścieżka pliku", + "File Size": "Rozmiar pliku", + "File Type": "Typ pliku", + "File name": "Nazwa pliku", + "File types:": "Typy plików:", + "Filename": "Nazwa pliku", + "Fill": "Wypełnij", + "Fill Edges": "Wypełnij krawędzie", + "Film": "Film", + "Filmstrip": "Pasek zdjęć", + "Filmstrip (/)": "Pasek zdjęć (/)", + "Filter": "Filtr", + "Filter Bar": "Pasek filtrów", + "Filter Presets": "Ustawienia wstępne filtrów", + "Filter bar": "Pasek filtrów", + "Filter fields": "Pola filtrów", + "Find Dust Spots": "Znajdź drobiny kurzu", + "Find Missing Photos": "Znajdź brakujące zdjęcia", + "Find Missing Photos…": "Znajdź brakujące zdjęcia…", + "Find Similar Photos": "Znajdź podobne zdjęcia", + "Find tools and commands…": "Znajdź narzędzia i polecenia…", + "Find…": "Znajdź…", + "First": "Pierwszy", + "First commit": "Pierwszy commit", + "Fit": "Dopasuj", + "Fit (F)": "Dopasuj (F)", + "Fit on Screen": "Dopasuj do ekranu", + "Fit to width": "Dopasuj do szerokości", + "Fit visible": "Dopasuj widoczne", + "Five stars": "Pięć gwiazdek", + "Fixed keys": "Stałe klawisze", + "Flag": "Oznacz", + "Flag as Pick": "Oznacz jako wybrane", + "Flag as Reject": "Oznacz jako odrzucone", + "Flagged as Pick": "Oznaczone jako wybrane", + "Flagged as Reject": "Oznaczone jako odrzucone", + "Flash": "Lampa błyskowa", + "Flip H": "Odbij w poziomie", + "Flip Horizontal": "Odbij poziomo", + "Flip Overlay (⇧O)": "Odwróć nakładkę (⇧O)", + "Flip V": "Odbij w pionie", + "Flip Vertical": "Odbij pionowo", + "Float panel": "Odłącz panel", + "Flow": "Przepływ", + "Fluorescent": "Światło fluorescencyjne", + "Focal Length": "Ogniskowa", + "Focal length": "Ogniskowa", + "Focus (assisted culling)": "Ostrość (wspomagana selekcja)", + "Folder": "Folder", + "Folder name (renamed on disk; its photos follow)": "Nazwa folderu (zmieniana na dysku wraz ze zdjęciami)", + "Folders": "Foldery", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Foldery i {date} używają daty wykonania; gdy zdjęcie jej nie ma, używana jest dzisiejsza data.", + "Food": "Jedzenie", + "Forget Unchanged Local Photos": "Zapomnij o niezmienionych zdjęciach lokalnych", + "Forget unchanged photos": "Zapomnij o niezmienionych zdjęciach", + "Form fields panel": "Panel pól formularza", + "Format": "Format", + "Forward": "Dalej", + "Frame time": "Czas klatki", + "Frame update": "Aktualizacja klatki", + "Free": "Wolne", + "Fresh & Bright": "Świeżo i jasno", + "Friday": "Piątek", + "Full": "Pełny", + "Full Screen Preview": "Podgląd pełnoekranowy", + "Full screen mode": "Tryb pełnoekranowy", + "Full size": "Pełny rozmiar", + "GPS Track Log": "Ślad GPS", + "GPU Rendering": "Renderowanie GPU", + "GPU rendering": "Renderowanie na GPU", + "General": "Ogólne", + "Generated demo photo": "Wygenerowane zdjęcie demonstracyjne", + "Geometry": "Geometria", + "Get Develop Settings": "Pobierz ustawienia wywoływania", + "Global Hue": "Globalny odcień", + "Global Luminance": "Globalna luminancja", + "Global Saturation": "Globalne nasycenie", + "Glossy": "Błyszczący", + "Go to History Step": "Przejdź do kroku historii", + "Golden": "Złocisty", + "Golden Glow": "Złota poświata", + "Golden Hour": "Złota godzina", + "Grab bag": "Zbiór różnych elementów", + "Grain": "Ziarno", + "Graphic": "Graficzny", + "Green": "Zielony", + "Green Amount": "Ilość zieleni", + "Green Hue": "Odcień zieleni", + "Green Hue High": "Górny zakres odcienia zieleni", + "Green Hue Low": "Dolny zakres odcienia zieleni", + "Green Luminance": "Luminancja zieleni", + "Green Primary": "Zieleń podstawowa", + "Green Saturation": "Nasycenie zieleni", + "Green channel": "Kanał zielony", + "Green label": "Zielona etykieta", + "Grid": "Siatka", + "Grid Info": "Informacje w siatce", + "Gritty Street": "Surowa ulica", + "Group": "Grupa", + "Group by Date: Automatic": "Grupowanie według daty: automatycznie", + "Group by Day": "Grupuj według dnia", + "Group by Month": "Grupuj według miesiąca", + "Group by Year": "Grupuj według roku", + "Group by date": "Grupuj według daty", + "Group into Stack": "Zgrupuj w stos", + "Group name": "Nazwa grupy", + "Guided": "Z prowadnicami", + "Guided Upright": "Korekcja perspektywy z prowadnicami", + "Guided Upright Guides": "Prowadnice korekcji perspektywy", + "HDR Merge": "Scalanie HDR", + "HDR Merge Preview": "Podgląd scalania HDR", + "HDR Panorama Merge": "Scal panoramę HDR", + "HDR Panorama Merge Preview": "Podgląd scalania panoramy HDR", + "HDR Panorama with Last Settings": "Panorama HDR z ostatnimi ustawieniami", + "HDR Panoramas": "Panoramy HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panoramy HDR", + "HDR with Last Settings": "HDR z ostatnimi ustawieniami", + "HDR…": "HDR…", + "Has Edits": "Ma edycje", + "Has GPS": "Ma dane GPS", + "Heal": "Korekta", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Usuń retuszem niewielkie, miękkie ciemne plamki pozostawione przez kurz na matrycy", + "Height": "Wysokość", + "Height (px)": "Wysokość (px)", + "Help": "Pomoc", + "Hide LightCraft": "Ukryj LightCraft", + "Hide Mask": "Ukryj maskę", + "Hide Others": "Ukryj pozostałe", + "Hides this shortcut only; the folder and its photos stay as they are": "Ukrywa tylko ten skrót; folder i zdjęcia pozostają bez zmian", + "High": "Wysoki", + "High Contrast B&W": "Czarno-biały o wysokim kontraście", + "Highlight": "Światła", + "Highlights": "Światła", + "Highlights Hue": "Odcień świateł", + "Highlights Luminance": "Luminancja świateł", + "Highlights Saturation": "Nasycenie świateł", + "Highlights split": "Podział świateł", + "Histogram": "Histogram", + "History": "Historia", + "History & Activity (Y)": "Historia i aktywność (Y)", + "Home": "Strona główna", + "Horizontal": "Poziomo", + "Horizontal text": "Tekst poziomy", + "Hours": "Godziny", + "Hue": "Odcień", + "Hue Range": "Zakres odcieni", + "ISO Speed": "Czułość ISO", + "ISO speed": "Czułość ISO", + "If file exists": "Jeśli plik istnieje", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Jeśli nie można otworzyć biblioteki przy uruchamianiu (jest używana w innym programie, nieczytelna lub znajduje się na odłączonym dysku,", + "Image": "Obraz", + "Import": "Importuj", + "Import Date": "Data importu", + "Import Lightroom Catalog": "Importuj katalog Lightrooma", + "Import Lightroom Catalog…": "Importuj katalog Lightrooma…", + "Import Photos": "Importuj zdjęcia", + "Import Photos…": "Importuj zdjęcia…", + "Import Point Curve Presets": "Importuj ustawienia krzywej punktowej", + "Import Point Curve Presets…": "Importuj ustawienia krzywej punktowej…", + "Import Presets": "Importuj ustawienia wstępne", + "Import Presets…": "Importuj ustawienia wstępne…", + "Import Profiles": "Importuj profile", + "Import Profiles & Presets…": "Importuj profile i ustawienia wstępne…", + "Import as new": "Importuj jako nowe", + "Import from Device": "Importuj z urządzenia", + "Import from Folder…": "Importuj z folderu…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importuj zdjęcia przez Plik → Importuj zdjęcia… (Cmd+Shift+I) lub przeciągnij je tutaj", + "Imported": "Zaimportowane", + "Imported Photos": "Zaimportowane zdjęcia", + "Importing": "Importowanie", + "Importing Lightroom Catalog": "Importowanie katalogu Lightrooma", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Importowanie wyszukuje zdjęcia w źródle; nie dodaje folderu do sekcji Lokalne (użyj Lokalne → Przeglądaj folder…, aby pracować w folderze bez importowania).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "W siatce zdjęć i siatce kwadratowej: oznacz jako wybrane i przejdź dalej", + "In Photo Grid and Square Grid: expand or collapse the stack": "W siatce zdjęć i siatce kwadratowej: rozwiń lub zwiń stos", + "In Recently Deleted": "W ostatnio usuniętych", + "In library": "W bibliotece", + "In memory — nothing is saved": "W pamięci — nic nie zapisano", + "In use": "W użyciu", + "In-memory session: these settings last until LightCraft quits.": "Sesja w pamięci: te ustawienia obowiązują do zamknięcia LightCraft.", + "Include subfolders": "Uwzględnij podfoldery", + "Increase Brush Feather": "Zwiększ miękkość pędzla", + "Increase Brush Size": "Zwiększ rozmiar pędzla", + "Info": "Informacje", + "Info (I)": "Informacje (I)", + "Info Overlay Off": "Wyłącz nakładkę informacyjną", + "Info Overlay: Exposure": "Nakładka informacyjna: ekspozycja", + "Info Overlay: File": "Nakładka informacyjna: plik", + "Info overlay": "Nakładka informacyjna", + "Insert at the cursor": "Wstaw w miejscu kursora", + "Inspect Lightroom Catalog": "Sprawdź katalog Lightrooma", + "Inspect Photo": "Sprawdź zdjęcie", + "Inspecting Lightroom Catalog": "Sprawdzanie katalogu Lightrooma", + "Instant ’70s": "Lata 70. od ręki", + "Interface": "Interfejs", + "Interface language": "Język interfejsu", + "Intersect": "Część wspólna", + "Intersect with": "Przetnij z", + "Into one folder": "Do jednego folderu", + "Invert": "Odwróć", + "Invert Component": "Odwróć składnik", + "Invert Mask": "Odwróć maskę", + "JPEG (Large)": "JPEG (duży)", + "JPEG (Small)": "JPEG (mały)", + "Join the ArtCraft Discord": "Dołącz do Discorda ArtCraft", + "Join the ArtCraft Discord…": "Dołącz do Discorda ArtCraft…", + "Join the ArtCraft community on Discord": "Dołącz do społeczności ArtCraft na Discordzie", + "Keep in Local": "Zachowaj w lokalnych", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Przechowuj ją na dysku z wolnym miejscem: podglądy inteligentne zajmują około 1 MB na zdjęcie. Pamięć podręczna miniatur pozostaje w bibliotece.", + "Keyboard Shortcuts": "Skróty klawiaturowe", + "Keyboard shortcuts": "Skróty klawiaturowe", + "Keyword": "Słowo kluczowe", + "Keyword Painter": "Pędzel słów kluczowych", + "Keyword Set": "Zestaw słów kluczowych", + "Keyword Sets": "Zestawy słów kluczowych", + "Keyword Suggestions": "Sugestie słów kluczowych", + "Keyword to paint": "Słowo kluczowe do nanoszenia", + "Keywords": "Słowa kluczowe", + "Keywords (K)": "Słowa kluczowe (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Dodane słowa kluczowe pojawiają się tutaj; skróty ⌥1–⌥9 przypisują je do zdjęć.", + "Label": "Etykieta", + "Landscape": "Krajobraz", + "Language": "Język", + "Last": "Ostatni", + "Last GPU fallback": "Ostatnie przełączenie awaryjne GPU", + "Last commit": "Ostatni zapis zmian", + "Last loupe render": "Ostatnie renderowanie lupy", + "Last view": "Ostatni widok", + "Lateral Chromatic Aberration": "Poprzeczna aberracja chromatyczna", + "Layer": "Warstwa", + "Layers": "Warstwy", + "Leave them": "Pozostaw je", + "Lens": "Obiektyw", + "Lens Blur": "Rozmycie obiektywu", + "Lens Corrections": "Korekcje obiektywu", + "Level": "Poziom", + "Library": "Biblioteka", + "Library Info": "Informacje o bibliotece", + "Library Originals": "Oryginały biblioteki", + "Library Preferences": "Preferencje biblioteki", + "Library State": "Stan biblioteki", + "Library saved": "Biblioteka zapisana", + "Licence:": "Licencja:", + "Light": "Światło", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 i starsze wersje odrzucają ten plik („nieobsługiwany format … v2”).", + "LightCraft Default": "Domyślne ustawienia LightCraft", + "LightCraft Help": "Pomoc LightCraft", + "LightCraft Preset": "Ustawienie wstępne LightCraft", + "LightCraft Presets": "Ustawienia wstępne LightCraft", + "LightCraft Website": "Strona LightCraft", + "LightCraft could not start": "Nie można uruchomić LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft nie znalazł miejsca na przechowywanie biblioteki.", + "LightCraft couldn't open the library at": "LightCraft nie mógł otworzyć biblioteki w lokalizacji", + "LightCraft on GitHub": "LightCraft w serwisie GitHub", + "LightCraft website": "Strona internetowa LightCraft", + "LightCraft — Second Window": "LightCraft — drugie okno", + "Lightroom Classic Catalog": "Katalog Lightroom Classic", + "Lightroom import cancelled": "Anulowano import z Lightrooma", + "Lightroom inspection cancelled": "Anulowano sprawdzanie Lightrooma", + "Lights": "Światła", + "Limit file size (KB, 0 = off)": "Limit rozmiaru pliku (KB, 0 = wyłączone)", + "Line delta": "Zmiana liczby wierszy", + "Linear": "Liniowy", + "Linear Gradient": "Gradient liniowy", + "Lines +/−": "Wiersze +/−", + "Lines added": "Dodane wiersze", + "Lines deleted": "Usunięte wiersze", + "List Albums": "Wyświetl albumy", + "List Develop Controls": "Wyświetl ustawienia wywoływania", + "List History": "Wyświetl historię", + "List Presets": "Wyświetl ustawienia predefiniowane", + "List Profiles": "Wyświetl profile", + "List this folder in Local from now on": "Od teraz pokazuj ten folder w widoku lokalnym", + "Local": "Lokalne", + "Local folders": "Foldery lokalne", + "Local library — no cloud account needed": "Biblioteka lokalna — konto w chmurze nie jest potrzebne", + "Locate Missing File…": "Znajdź brakujący plik…", + "Locate Photo": "Znajdź zdjęcie", + "Location": "Lokalizacja", + "Lock": "Zablokuj", + "Lock Crop Aspect": "Zablokuj proporcje kadrowania", + "Locked": "Zablokowane", + "Long Edge": "Dłuższy bok", + "Looking for photos…": "Wyszukiwanie zdjęć…", + "Lossless": "Bezstratny", + "Lossy DNG files and Smart Previews open as raw photos.": "Stratne pliki DNG i podglądy inteligentne otwierają się jako zdjęcia RAW.", + "Low": "Niski", + "Luminance": "Luminancja", + "Luminance Range": "Zakres luminancji", + "Lush Greens": "Soczysta zieleń", + "Magenta": "Purpurowy", + "Magenta Hue": "Odcień purpury", + "Magenta Luminance": "Luminancja purpury", + "Magenta Saturation": "Nasycenie purpury", + "Make Candidate the Select": "Ustaw zdjęcie kandydujące jako wybrane", + "Manual": "Ręcznie", + "Marquee zoom": "Powiększanie zaznaczonego obszaru", + "Mask Adjustment": "Korekta maski", + "Masking": "Maskowanie", + "Masking (M)": "Maskowanie (M)", + "Masks": "Maski", + "Match": "Dopasuj", + "Match Total Exposures": "Wyrównaj całkowitą ekspozycję", + "Matte": "Matowy", + "Matte Soft": "Delikatny mat", + "Med": "Śr.", + "Medium Contrast": "Średni kontrast", + "Megapixels": "Megapiksele", + "Memory Budget": "Limit pamięci", + "Memory Usage": "Wykorzystanie pamięci", + "Memory budget": "Limit pamięci", + "Memory for caches": "Pamięć na pamięć podręczną", + "Menu": "Menu", + "Merge": "Scal", + "Merge Keywords": "Połącz słowa kluczowe", + "Merge into…": "Scal z…", + "Merged": "Scalone", + "Merged PRs": "Scalone zgłoszenia PR", + "Merging…": "Scalanie…", + "Metadata": "Metadane", + "Metadata Preset": "Zestaw metadanych", + "Metadata Presets": "Zestawy metadanych", + "Metadata preset": "Zestaw metadanych", + "Midpoint": "Punkt środkowy", + "Midtones": "Tony średnie", + "Midtones Hue": "Odcień tonów średnich", + "Midtones Luminance": "Luminancja tonów średnich", + "Midtones Saturation": "Nasycenie tonów średnich", + "Midtones split": "Podział tonów średnich", + "Minimize": "Minimalizuj", + "Missing Photos": "Brakujące zdjęcia", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Brakujące metadane ({camera}, {title}…) pozostawiają puste miejsce; jeśli wynikowa nazwa jest pusta, zachowywana jest nazwa oryginalna.", + "Misty Morning": "Mglisty poranek", + "Mode": "Tryb", + "Model": "Model", + "Models": "Modele", + "Modified Date": "Data modyfikacji", + "Monday": "Poniedziałek", + "Mono Red Filter": "Monochromatyczny filtr czerwony", + "Mono Rich": "Bogata monochromia", + "Mono Soft": "Delikatna monochromia", + "Monochrome": "Monochromatyczny", + "Month": "Miesiąc", + "Moody": "Nastrojowy", + "Moody Portrait": "Nastrojowy portret", + "More": "Więcej", + "More preset options": "Więcej opcji ustawienia predefiniowanego", + "Move": "Przenieś", + "Move Album": "Przenieś album", + "Move Down": "Przenieś w dół", + "Move Down in Stack": "Przenieś niżej w stosie", + "Move Folder": "Przenieś folder", + "Move Folder To…": "Przenieś folder do…", + "Move Mask": "Przenieś maskę", + "Move Preset to Group": "Przenieś ustawienie predefiniowane do grupy", + "Move Preset to New Group": "Przenieś ustawienie predefiniowane do nowej grupy", + "Move Up": "Przenieś w górę", + "Move Up in Stack": "Przenieś wyżej w stosie", + "Move in Stack": "Przenieś w stosie", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Przenieś pliki (do folderu Originals/ w bibliotece lub do wybranego folderu), usuwając je z lokalizacji źródłowej", + "Move them": "Przenieś je", + "Move to": "Przenieś do", + "Move to Group": "Przenieś do grupy", + "Move to group": "Przenieś do grupy", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Przenosi wszystkie zdjęcia zaimportowane z tego dysku do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Przenosi zdjęcia zaimportowane z tego folderu do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", + "Muted": "Stonowany", + "Muted Film": "Stonowany film", + "My Photos": "Moje zdjęcia", + "My Photos Panel": "Panel Moje zdjęcia", + "Name": "Nazwa", + "Name (A–Z)": "Nazwa (A–Z)", + "Name of the folder the original is in": "Nazwa folderu z oryginałem", + "Named": "Nazwane", + "Named People": "Osoby z przypisanymi imionami", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Nazwy pojawiają się w menu etykiet, pasku filtrów i panelu informacji oraz są zapisywane w XMP. Puste pole oznacza nazwę koloru.", + "Navigator": "Nawigator", + "Needs Work": "Wymaga dopracowania", + "Neon Dusk": "Neonowy zmierzch", + "Neon Night": "Neonowa noc", + "Nested group": "Grupa zagnieżdżona", + "Neutral": "Neutralny", + "Never": "Nigdy", + "New": "Nowy", + "New Album": "Nowy album", + "New Album…": "Nowy album…", + "New Folder…": "Nowy folder…", + "New Group…": "Nowa grupa…", + "New Smart Album": "Nowy album inteligentny", + "New Smart Album from Filter…": "Nowy album inteligentny z filtra…", + "New Smart Album…": "Nowy album inteligentny…", + "New album": "Nowy album", + "New album…": "Nowy album…", + "New blank PDF": "Nowy pusty PDF", + "New name": "Nowa nazwa", + "New time": "Nowy czas", + "New…": "Nowy…", + "Next Photo": "Następne zdjęcie", + "Night Blue": "Nocny błękit", + "No": "Nie", + "No Camera or Card Found": "Nie znaleziono aparatu ani karty pamięci", + "No Date Groups": "Brak grup według dat", + "No Stars": "Brak gwiazdek", + "No Tool": "Brak narzędzia", + "No automatic versions.": "Brak automatycznych wersji.", + "No camera": "Brak aparatu", + "No contributor data was built into this copy.": "W tej wersji nie zapisano danych współtwórców.", + "No date": "Brak daty", + "No dust spots found": "Nie znaleziono plamek kurzu", + "No edits yet.": "Nie wprowadzono jeszcze zmian.", + "No exposure information": "Brak informacji o ekspozycji", + "No filter presets yet": "Brak zapisanych zestawów filtrów", + "No lens data embedded in this file (DNG lens opcodes only).": "Ten plik nie zawiera danych obiektywu (dostępne są jedynie instrukcje obiektywu DNG).", + "No masks yet. Choose a mask type above.": "Nie ma jeszcze masek. Wybierz typ maski powyżej.", + "No matching commands": "Brak pasujących poleceń", + "No matching photos": "Brak pasujących zdjęć", + "No metadata found": "Nie znaleziono metadanych", + "No model credits were built into this copy.": "W tej wersji nie zapisano informacji o autorach modeli.", + "No named people in these photos": "Na tych zdjęciach nie ma osób z przypisanymi imionami", + "No named people yet": "Nie nazwano jeszcze żadnych osób", + "No new presets": "Brak nowych ustawień predefiniowanych", + "No photo selected": "Nie wybrano zdjęcia", + "No photos": "Brak zdjęć", + "No photos found": "Nie znaleziono zdjęć", + "No photos in this folder": "W tym folderze nie ma zdjęć", + "No photos match the active filters": "Żadne zdjęcia nie spełniają aktywnych filtrów", + "No photos match the filters (View → Clear Filters)": "Żadne zdjęcia nie spełniają filtrów (Widok → Wyczyść filtry)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Brak zdjęć RAW. Aparaty pojawią się tutaj po dodaniu ich zdjęć do biblioteki.", + "No reference photo": "Brak zdjęcia referencyjnego", + "No usable GPU found: rendering on the CPU": "Nie znaleziono obsługiwanej karty graficznej: renderowanie na procesorze", + "No versions yet. Create one to keep this look.": "Nie ma jeszcze wersji. Utwórz wersję, aby zachować ten wygląd.", + "Noise": "Szum", + "Noise Reduction": "Redukcja szumu", + "Non-raw photos": "Zdjęcia inne niż RAW", + "None": "Brak", + "Not Now": "Nie teraz", + "Nothing to compare": "Nie ma czego porównywać", + "Nothing to survey": "Nie ma czego przeglądać", + "Nudge Develop Value": "Zmień nieznacznie wartość wywoływania", + "OK": "OK", + "Object": "Obiekt", + "Object Mask": "Maska obiektu", + "October 2026": "Październik 2026", + "Off": "Wyłączone", + "Offset X": "Przesunięcie X", + "Offset Y": "Przesunięcie Y", + "Opacity": "Krycie", + "Opacity (%)": "Krycie (%)", + "Open Library": "Otwórz bibliotekę", + "Open Library…": "Otwórz bibliotekę…", + "Open in": "Otwórz w", + "Open in Detail": "Otwórz w widoku szczegółowym", + "Open the place in OpenStreetMap": "Otwórz lokalizację w OpenStreetMap", + "Open…": "Otwórz…", + "Optics": "Optyka", + "Optimize Library": "Optymalizuj bibliotekę", + "Optional name": "Nazwa (opcjonalnie)", + "Options": "Opcje", + "Orange": "Pomarańczowy", + "Orange Hue": "Odcień pomarańczowego", + "Orange Luminance": "Luminancja pomarańczowego", + "Orange Saturation": "Nasycenie pomarańczowego", + "Orientation": "Orientacja", + "Original": "Oryginał", + "Original + Settings": "Oryginał + ustawienia", + "Original extension without the dot": "Oryginalne rozszerzenie bez kropki", + "Original file name without its extension": "Oryginalna nazwa pliku bez rozszerzenia", + "Original files": "Pliki oryginalne", + "Original offline · editing the smart preview": "Oryginał niedostępny · edycja podglądu inteligentnego", + "Original offline · no smart preview": "Oryginał niedostępny · brak podglądu inteligentnego", + "Originals": "Oryginały", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Inne obrazy (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Nakładka", + "Overwrite": "Nadpisz", + "Pages": "Strony", + "Paint": "Malowanie", + "Paint only areas like the one under the brush": "Maluj tylko obszary podobne do tego pod pędzlem", + "Paint over a distraction on the photo to remove it.": "Zamaluj niepożądany element na zdjęciu, aby go usunąć.", + "Paint overlay": "Nakładka malowania", + "Panels": "Panele", + "Panorama": "Panorama", + "Panorama Merge": "Łączenie panoramy", + "Panorama Merge Preview": "Podgląd łączenia panoramy", + "Panorama with Last Settings": "Panorama z ostatnimi ustawieniami", + "Panoramas": "Panoramy", + "Panorama…": "Panorama…", + "Parametric curve": "Krzywa parametryczna", + "Paste": "Wklej", + "Paste Edit Settings": "Wklej ustawienia edycji", + "Paste Metadata": "Wklej metadane", + "Paste Selected Settings": "Wklej wybrane ustawienia", + "Paste Selected Settings…": "Wklej wybrane ustawienia…", + "Paste Settings": "Wklej ustawienia", + "Paste Settings from Previous": "Wklej ustawienia z poprzedniego", + "Paste from Previous": "Wklej z poprzedniego", + "Paste pages": "Wklej strony", + "Pastel Haze": "Pastelowa mgiełka", + "People": "Osoby", + "Percent": "Procent", + "Percentage": "Wartość procentowa", + "Performance": "Wydajność", + "Person": "Osoba", + "Perspective": "Perspektywa", + "Pet Eye": "Oczy zwierząt", + "Pet Eye Catchlight": "Blik w oczach zwierząt", + "Photo": "Zdjęcie", + "Photo Grid": "Siatka zdjęć", + "Photo Grid (G)": "Siatka zdjęć (G)", + "Photo Merge": "Łączenie zdjęć", + "Photos": "Zdjęcia", + "Photos per bracket (0 = auto)": "Zdjęć w serii ekspozycji (0 = automatycznie)", + "Pick": "Wybierz", + "Pick Flag": "Flaga wyboru", + "Pick a colour on the photo with the eyedropper.": "Wybierz kolor ze zdjęcia za pomocą pipety.", + "Pick the sharpest photo of each burst": "Wybierz najostrzejsze zdjęcie z każdej serii", + "Picked": "Wybrane", + "Picks": "Wybrane zdjęcia", + "Pictures": "Obrazy", + "Pixels": "Piksele", + "Point Color": "Kolor punktowy", + "Point Curve": "Krzywa punktowa", + "Point Curve Presets": "Ustawienia wstępne krzywej punktowej", + "Point curve": "Krzywa punktowa", + "Portrait": "Portret", + "Position": "Położenie", + "Preferences": "Preferencje", + "Preferences…": "Preferencje…", + "Prepare AI Masks": "Przygotuj maski AI", + "Preset": "Ustawienie wstępne", + "Preset name": "Nazwa ustawienia wstępnego", + "Presets": "Ustawienia wstępne", + "Presets & Profiles": "Ustawienia wstępne i profile", + "Presets (Shift+P)": "Ustawienia wstępne (Shift+P)", + "Presets and profiles": "Ustawienia wstępne i profile", + "Presets ▾": "Ustawienia wstępne ▾", + "Press keys…": "Naciśnij klawisze…", + "Preview": "Podgląd", + "Preview Build Progress": "Postęp tworzenia podglądów", + "Preview only": "Tylko podgląd", + "Preview size": "Rozmiar podglądu", + "Previews": "Podglądy", + "Previous Photo": "Poprzednie zdjęcie", + "Print": "Drukuj", + "Print…": "Drukuj…", + "Profile": "Profil", + "Profile Browser": "Przeglądarka profili", + "Profile Distortion": "Dystorsja profilu", + "Profile Menu": "Menu profili", + "Profile Vignetting": "Winietowanie profilu", + "Profile:": "Profil:", + "Profiles": "Profile", + "Projection": "Odwzorowanie", + "Proof copy created": "Utworzono kopię próbną", + "Properties": "Właściwości", + "Public domain": "Domena publiczna", + "Pupil Size": "Rozmiar źrenicy", + "Purple": "Purpurowy", + "Purple Amount": "Ilość purpury", + "Purple Hue": "Odcień purpury", + "Purple Hue High": "Górna granica odcienia purpury", + "Purple Hue Low": "Dolna granica odcienia purpury", + "Purple Luminance": "Luminancja purpury", + "Purple Saturation": "Nasycenie purpury", + "Put the locations you removed from Local back (no files change)": "Przywróć lokalizacje usunięte z widoku Lokalne (bez zmiany plików)", + "Quality": "Jakość", + "Query Photos": "Wyszukaj zdjęcia", + "Quick Collection": "Szybka kolekcja", + "Quick Develop": "Szybka obróbka", + "Quiet Green": "Spokojna zieleń", + "Quit": "Zakończ", + "Quit Anyway": "Zamknij mimo to", + "Quit LightCraft": "Zamknij LightCraft", + "Quit with unsaved changes?": "Zamknąć z niezapisanymi zmianami?", + "RAW decoding": "Dekodowanie RAW", + "Radial": "Promieniowy", + "Radial Gradient": "Gradient promieniowy", + "Radius": "Promień", + "Random": "Losowo", + "Range": "Zakres", + "Rate ★": "Oceń ★", + "Rate ★ and advance": "Oceń ★ i przejdź dalej", + "Rate ★★": "Oceń ★★", + "Rate ★★ and advance": "Oceń ★★ i przejdź dalej", + "Rate ★★★": "Oceń ★★★", + "Rate ★★★ and advance": "Oceń ★★★ i przejdź dalej", + "Rate ★★★★": "Oceń ★★★★", + "Rate ★★★★ and advance": "Oceń ★★★★ i przejdź dalej", + "Rate ★★★★★": "Oceń ★★★★★", + "Rate ★★★★★ and advance": "Oceń ★★★★★ i przejdź dalej", + "Rating": "Ocena", + "Rating cleared": "Usunięto ocenę", + "Ratings & flags": "Oceny i flagi", + "Raw": "RAW", + "Raw default": "Domyślne ustawienia RAW", + "Raw defaults": "Domyślne ustawienia RAW", + "Raw files": "Pliki RAW", + "Raw photos": "Zdjęcia RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Zdjęcia RAW jako DNG z osadzonymi zmianami. Opcje rozmiaru, koloru i wyjścia nie mają zastosowania.", + "Read Metadata from File": "Wczytaj metadane z pliku", + "Read mode": "Tryb odczytu", + "Read the SAM License": "Przeczytaj licencję SAM", + "Reading": "Wczytywanie", + "Reading Lightroom catalog…": "Wczytywanie katalogu Lightroom…", + "Reading folder…": "Wczytywanie folderu…", + "Real name": "Imię i nazwisko", + "Recent": "Ostatnie", + "Recent Keywords": "Ostatnie słowa kluczowe", + "Recently Added": "Ostatnio dodane", + "Recently Deleted": "Ostatnio usunięte", + "Red": "Czerwony", + "Red Eye": "Czerwone oczy", + "Red Hue": "Odcień czerwieni", + "Red Luminance": "Luminancja czerwieni", + "Red Primary": "Czerwony podstawowy", + "Red Saturation": "Nasycenie czerwieni", + "Red channel": "Kanał czerwony", + "Red label": "Czerwona etykieta", + "Red/Cyan Fringe": "Czerwono-turkusowa obwódka", + "Redo": "Ponów", + "Reference": "Odniesienie", + "Reference View": "Widok referencyjny", + "Reference the files where they are": "Pozostaw pliki w bieżącym położeniu", + "Refine": "Dopracuj", + "Refine AI Mask Detail": "Dopracuj szczegóły maski AI", + "Refine Edges": "Dopracuj krawędzie", + "Refine Mask Edges": "Dopracuj krawędzie maski", + "Refine Saturation": "Dopracuj nasycenie", + "Refining the mask's detail…": "Dopracowywanie szczegółów maski…", + "Refresh Source": "Odśwież źródło", + "Refresh Source (/)": "Odśwież źródło (/)", + "Reject": "Odrzuć", + "Reject below focus": "Odrzuć poniżej progu ostrości", + "Rejected": "Odrzucone", + "Rejects": "Odrzucone zdjęcia", + "Reliability": "Niezawodność", + "Relink Photo": "Ponownie połącz zdjęcie", + "Reload Changed Files": "Wczytaj ponownie zmienione pliki", + "Reload from Disk": "Wczytaj ponownie z dysku", + "Remove": "Usuń", + "Remove (H)": "Usuń (H)", + "Remove CA": "Usuń aberrację chromatyczną", + "Remove Chromatic Aberration": "Usuń aberrację chromatyczną", + "Camera local tone mapping": "Lokalne mapowanie tonów aparatu", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Zastosuj lokalne mapowanie tonów zapisane w pliku RAW (Apple ProRAW), tak jak robi to aparat", + "Remove Disk from Library": "Usuń dysk z biblioteki", + "Remove Disk from Library…": "Usuń dysk z biblioteki…", + "Remove Face Box": "Usuń ramkę twarzy", + "Remove Folder from Library": "Usuń folder z biblioteki", + "Remove a filter above, or choose Clear all": "Usuń powyższy filtr lub wybierz Wyczyść wszystko", + "Remove from Album": "Usuń z albumu", + "Remove from Favorites": "Usuń z ulubionych", + "Remove from Library…": "Usuń z biblioteki…", + "Remove from Local": "Usuń z lokalnych", + "Remove from Photo": "Usuń ze zdjęcia", + "Remove from Selected Photos": "Usuń z zaznaczonych zdjęć", + "Remove from Stack": "Usuń ze stosu", + "Remove from Target Album": "Usuń z albumu docelowego", + "Remove from survey": "Usuń z widoku przeglądu", + "Remove location info": "Usuń informacje o lokalizacji", + "Remove shortcut": "Usuń skrót klawiszowy", + "Remove this face box (undo with Edit ▸ Undo)": "Usuń tę ramkę twarzy (cofnij przez Edycja ▸ Cofnij)", + "Remove this filter": "Usuń ten filtr", + "Remove this group": "Usuń tę grupę", + "Remove this rule": "Usuń tę regułę", + "Rename": "Zmień nazwę", + "Rename Album": "Zmień nazwę albumu", + "Rename Component": "Zmień nazwę elementu", + "Rename Folder": "Zmień nazwę folderu", + "Rename Folder…": "Zmień nazwę folderu…", + "Rename Keyword": "Zmień nazwę słowa kluczowego", + "Rename Keyword…": "Zmień nazwę słowa kluczowego…", + "Rename Mask": "Zmień nazwę maski", + "Rename Photos": "Zmień nazwy zdjęć", + "Rename Photos…": "Zmień nazwy zdjęć…", + "Rename Preset": "Zmień nazwę ustawienia wstępnego", + "Rename Preview": "Zmień nazwę podglądu", + "Rename Template Tags": "Zmień nazwy znaczników szablonu", + "Rename Version": "Zmień nazwę wersji", + "Rename…": "Zmień nazwę…", + "Rendering": "Renderowanie", + "Rendering…": "Renderowanie…", + "Reorder Masks": "Zmień kolejność masek", + "Reset": "Resetuj", + "Reset All": "Zresetuj wszystko", + "Reset All Curves": "Zresetuj wszystkie krzywe", + "Reset All Keyboard Shortcuts": "Przywróć wszystkie skróty klawiszowe", + "Reset Before to Import State": "Przywróć stan przed edycją do stanu po imporcie", + "Reset Control": "Zresetuj element sterujący", + "Reset Crop": "Zresetuj kadrowanie", + "Reset Edits": "Zresetuj edycje", + "Reset Panel Layout": "Przywróć układ paneli", + "Reset Parametric Curve": "Zresetuj krzywą parametryczną", + "Reset Section": "Zresetuj sekcję", + "Reset Tone Curve": "Zresetuj krzywą tonalną", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Zresetuj wszystkie krzywe: punktowe (wszystkie kanały) i parametryczną (kliknij dwukrotnie kanał, aby zresetować tylko jego krzywą)", + "Reset all edits (Cmd+Shift+R)": "Zresetuj wszystkie edycje (Cmd+Shift+R)", + "Reshuffle": "Wylosuj ponownie", + "Resize Face Box": "Zmień rozmiar ramki twarzy", + "Resize panel window": "Zmień rozmiar okna panelu", + "Resize panels": "Zmień rozmiary paneli", + "Resize to": "Zmień rozmiar do", + "Resolution (ppi)": "Rozdzielczość (ppi)", + "Restore": "Przywróć", + "Restore (no shortcut)": "Przywróć (bez skrótu)", + "Restore Library from Backup…": "Przywróć bibliotekę z kopii zapasowej…", + "Restore Version": "Przywróć wersję", + "Restore keeps their edits; Import as new deletes the old record first.": "Przywracanie zachowuje edycje; import jako nowe zdjęcie najpierw usuwa stary wpis.", + "Restore them": "Przywróć je", + "Retouch": "Retusz", + "Reverse the order": "Odwróć kolejność", + "Revert": "Cofnij zmiany", + "Review": "Przegląd", + "Review Import": "Sprawdź import", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem ▸ Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)", + "Right-click a preset to delete it": "Kliknij ustawienie wstępne prawym przyciskiem, aby je usunąć", + "Rights Usage Terms": "Warunki korzystania z praw", + "Rotate": "Obróć", + "Rotate Crop": "Obróć kadr", + "Rotate Crop Aspect": "Obróć proporcje kadru", + "Rotate Left": "Obróć w lewo", + "Rotate Right": "Obróć w prawo", + "Roughness": "Chropowatość", + "Roundness": "Zaokrąglenie", + "Rules": "Reguły", + "SAM 3 download stopped: it resumes where it left off next time.": "Pobieranie SAM 3 zatrzymano; zostanie wznowione przy następnej próbie.", + "Same as raw default": "Tak jak domyślne ustawienia RAW", + "Sample Mask Color": "Pobierz próbkę koloru maski", + "Sample a colour on the photo": "Wybierz próbkę koloru ze zdjęcia", + "Saturation": "Nasycenie", + "Saturation Range": "Zakres nasycenia", + "Saturday": "Sobota", + "Save": "Zapisz", + "Save As…": "Zapisz jako…", + "Save Color Label Set": "Zapisz zestaw etykiet kolorystycznych", + "Save Current Filter as Preset…": "Zapisz bieżący filtr jako ustawienie wstępne…", + "Save Current Keywords as Set…": "Zapisz bieżące słowa kluczowe jako zestaw…", + "Save Export Preset": "Zapisz ustawienie wstępne eksportu", + "Save Filter Preset": "Zapisz ustawienie wstępne filtra", + "Save Keyword Set": "Zapisz zestaw słów kluczowych", + "Save Metadata Preset": "Zapisz ustawienie wstępne metadanych", + "Save Metadata Preset…": "Zapisz ustawienie wstępne metadanych…", + "Save Metadata to File": "Zapisz metadane do pliku", + "Save Point Curve Preset": "Zapisz ustawienie wstępne krzywej punktowej", + "Save Point Curve…": "Zapisz krzywą punktową…", + "Save as Preset": "Zapisz jako ustawienie wstępne", + "Save as Set…": "Zapisz jako zestaw…", + "Save as Smart Album…": "Zapisz jako album inteligentny…", + "Save as set": "Zapisz jako zestaw", + "Save as…": "Zapisz jako…", + "Save preset": "Zapisz ustawienie wstępne", + "Saving Lightroom import index…": "Zapisywanie indeksu importu Lightroom…", + "Scale": "Skala", + "Scan failed": "Skanowanie nie powiodło się", + "Scanning": "Skanowanie", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Oceny pozostają przypisane do zdjęć: możesz filtrować według ostrości i najlepszych podobnych ujęć lub tworzyć albumy inteligentne.", + "Screen": "Ekran", + "Scroll menu down": "Przewiń menu w dół", + "Scroll menu up": "Przewiń menu w górę", + "Search": "Szukaj", + "Search Photos": "Szukaj zdjęć", + "Search commands or keys": "Szukaj poleceń lub klawiszy", + "Search…": "Szukaj…", + "Seasons": "Pory roku", + "Second Window": "Drugie okno", + "Select": "Zaznacz", + "Select All": "Zaznacz wszystko", + "Select Luminance Range": "Zaznacz zakres luminancji", + "Select Mask": "Wybierz maskę", + "Select Photos": "Wybierz zdjęcia", + "Select Spot": "Wybierz punkt", + "Select a photo to edit": "Wybierz zdjęcie do edycji", + "Select by": "Wybierz według", + "Select by Flag, Rating or Label": "Wybierz według flagi, oceny lub etykiety", + "Select photos, then choose View → Survey (N)": "Wybierz zdjęcia, a następnie Widok → Przegląd (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Wybierz dwa zdjęcia, a następnie Widok → Porównaj (Shift+C)", + "Selected": "Zaznaczone", + "Selecting…": "Wybieranie…", + "Selenium Tone": "Tonowanie selenowe", + "Send Feedback…": "Wyślij opinię…", + "Sepia Tone": "Tonowanie sepią", + "Sequence number zero-padded to N digits (1–9)": "Numer kolejny dopełniony zerami do N cyfr (1–9)", + "Sequence number, counting from the start number": "Numer kolejny liczony od numeru początkowego", + "Services": "Usługi", + "Set": "Zestaw", + "Set Album Cover": "Ustaw okładkę albumu", + "Set Before": "Ustaw stan „Przed”", + "Set Color Label": "Ustaw etykietę kolorystyczną", + "Set Crop": "Ustaw kadr", + "Set Develop Values": "Ustaw wartości wywoływania", + "Set Flag": "Ustaw flagę", + "Set Image Zoom and Pan": "Ustaw powiększenie i przesunięcie obrazu", + "Set Keyboard Shortcut": "Ustaw skrót klawiszowy", + "Set Mask Adjustments": "Ustaw korekty maski", + "Set Point Curve": "Ustaw krzywą punktową", + "Set Profile": "Ustaw profil", + "Set Rating": "Ustaw ocenę", + "Set Top of Stack": "Przenieś na wierzch stosu", + "Set as Album Cover": "Ustaw jako okładkę albumu", + "Set as Before": "Ustaw jako stan „Przed”", + "Set as Reference Photo": "Ustaw jako zdjęcie referencyjne", + "Set as Target Album": "Ustaw jako album docelowy", + "Set as Target Album (B adds to it)": "Ustaw jako album docelowy (B dodaje do niego zdjęcia)", + "Set as Top of Stack": "Przenieś na wierzch stosu", + "Set date & time": "Ustaw datę i godzinę", + "Set name": "Ustaw nazwę", + "Set the mix from the photo's colours": "Ustal proporcje na podstawie kolorów zdjęcia", + "Settings": "Ustawienia", + "Settings file problem": "Problem z plikiem ustawień", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Ustawienia początkowe nowych zdjęć RAW. Zmiana nie wpływa na zdjęcia już znajdujące się w bibliotece.", + "Settings pasted": "Wklejono ustawienia", + "Settings to include": "Ustawienia do uwzględnienia", + "Settings…": "Ustawienia…", + "Shade": "Cień", + "Shadow": "Cień", + "Shadows": "Cienie", + "Shadows Hue": "Odcień cieni", + "Shadows Luminance": "Luminancja cieni", + "Shadows Saturation": "Nasycenie cieni", + "Shadows Tint": "Zabarwienie cieni", + "Shadows split": "Podział cieni", + "Sharpen": "Wyostrz", + "Sharpening": "Wyostrzanie", + "Sharpness": "Wyostrzenie", + "Shift": "Przesunięcie", + "Shift by": "Przesuń o", + "Short Edge": "Krótsza krawędź", + "Show": "Pokaż", + "Show All": "Pokaż wszystko", + "Show Clipping": "Pokaż prześwietlenia i niedoświetlenia", + "Show Deghost Overlay": "Pokaż nakładkę usuwania duchów", + "Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne", + "Show Luminance Map": "Pokaż mapę luminancji", + "Show Mask": "Pokaż maskę", + "Show Mask Overlay": "Pokaż nakładkę maski", + "Show Mask Pins": "Pokaż znaczniki masek", + "Show Original": "Pokaż oryginał", + "Show Photo Counts": "Pokaż liczbę zdjęć", + "Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym", + "Show Pins": "Pokaż znaczniki", + "Show Source": "Pokaż źródło", + "Show Thumbnails": "Pokaż miniatury", + "Show commands without a shortcut": "Pokaż polecenia bez skrótu", + "Show destination gamut warning": "Pokaż ostrzeżenie o gamucie docelowym", + "Show display gamut warning": "Pokaż ostrzeżenie o gamucie wyświetlacza", + "Show file names": "Pokaż nazwy plików", + "Show in Explorer": "Pokaż w Eksploratorze", + "Show in File Manager": "Pokaż w menedżerze plików", + "Show in Finder": "Pokaż w Finderze", + "Show on Map": "Pokaż na mapie", + "Show overlay (O)": "Pokaż nakładkę (O)", + "Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji", + "Show the Navigator while zoomed in": "Pokaż Nawigator podczas powiększenia", + "Show the template tags; click one to insert it": "Pokaż znaczniki szablonu; kliknij znacznik, aby go wstawić", + "Show/Hide Mask": "Pokaż/ukryj maskę", + "Show/hide My Photos panel": "Pokaż/ukryj panel Moje zdjęcia", + "Shutter speed": "Czas naświetlania", + "Side panels": "Panele boczne", + "Sidecar names": "Nazwy plików pomocniczych", + "Size": "Rozmiar", + "Size (% of short edge)": "Rozmiar (% krótszej krawędzi)", + "Size limit": "Limit rozmiaru", + "Skip": "Pomiń", + "Sky": "Niebo", + "Slideshow": "Pokaz slajdów", + "Slideshow paused": "Pokaz slajdów wstrzymany", + "Slideshow resumed": "Pokaz slajdów wznowiony", + "Slideshow · Space pauses · Esc ends": "Pokaz slajdów · Spacja: pauza · Esc: koniec", + "Slideshow, second window, All Metadata, System Info.": "Pokaz slajdów, drugie okno, wszystkie metadane, informacje o systemie.", + "Smart Album": "Inteligentny album", + "Smart Album Rule Fields": "Pola reguł albumu inteligentnego", + "Smart Preview Status": "Stan podglądu inteligentnego", + "Smart Previews Location": "Lokalizacja podglądów inteligentnych", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Albumy inteligentne z edytorem reguł: dopasuj wszystkie / dowolne / żadne, grupy zagnieżdżone, 26 pól.", + "Smart preview available": "Dostępny podgląd inteligentny", + "Smart previews": "Podglądy inteligentne", + "Smart previews folder changed": "Zmieniono folder podglądów inteligentnych", + "Smoothness": "Wygładzenie", + "Soft B&W": "Delikatna czerń i biel", + "Soft Pastel": "Delikatne pastele", + "Soft Proofing": "Próba koloru", + "Soft Skin": "Wygładzanie skóry", + "Sort": "Sortuj", + "Sort by": "Sortuj według", + "Source": "Źródło", + "Source code on GitHub": "Kod źródłowy na GitHubie", + "Spherical": "Sferyczny", + "Spiral": "Spirala", + "Split Stack": "Rozdziel stos", + "Spring Fresh": "Wiosenna świeżość", + "Square Grid": "Siatka kwadratowa", + "Square Grid (G toggles)": "Siatka kwadratowa (G przełącza)", + "Square Grid: show file names and formats": "Siatka kwadratowa: pokaż nazwy i formaty plików", + "Stack": "Stos", + "Stack photos taken within this time of each other:": "Umieszczaj na jednym stosie zdjęcia wykonane w odstępie nie większym niż:", + "Stacks": "Stosy", + "Standard": "Standardowy", + "Star rating (0–5)": "Ocena gwiazdkowa (0–5)", + "Start at": "Zacznij od", + "Start number": "Numer początkowy", + "Starting": "Uruchamianie", + "Startup": "Uruchamianie programu", + "State / Province": "Stan / województwo", + "Stop": "Zatrzymaj", + "Stop Building Previews": "Zatrzymaj tworzenie podglądów", + "Stop Using as Target Album": "Przestań używać jako albumu docelowego", + "Stopping…": "Zatrzymywanie…", + "Straighten": "Wyprostuj", + "Straighten Tool": "Narzędzie prostowania", + "Strong Contrast": "Silny kontrast", + "Style": "Styl", + "Subfolder": "Podfolder", + "Subfolder name": "Nazwa podfolderu", + "Subject": "Obiekt", + "Subtract": "Odejmij", + "Subtract from this mask": "Odejmij od tej maski", + "Suggestions": "Sugestie", + "Summer Haze": "Letnia mgiełka", + "Sunday": "Niedziela", + "Survey": "Przegląd", + "Survey (N)": "Przegląd (N)", + "Swap": "Zamień", + "Swap Aspect (X)": "Zamień proporcje (X)", + "Swap Before and After": "Zamień „Przed” i „Po”", + "Swap Before and After Settings": "Zamień ustawienia „Przed” i „Po”", + "Swap Compare Photos": "Zamień porównywane zdjęcia", + "Switch light / dark theme": "Przełącz jasny/ciemny motyw", + "Sync Settings": "Synchronizuj ustawienia", + "System": "System", + "System Info": "Informacje o systemie", + "System Info…": "Informacje o systemie…", + "System default": "Domyślne ustawienie systemowe", + "Table": "Tabela", + "Tags": "Tagi", + "Take a snapshot": "Utwórz migawkę", + "Targeted Adjustment": "Korekta punktowa", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdujące się tam kolory", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdującą się tam krzywą", + "Teal & Amber": "Turkus i bursztyn", + "Teal & Orange": "Turkus i pomarańcz", + "Temp": "Temperatura", + "Temperature": "Temperatura", + "Template": "Szablon", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sesja tymczasowa — biblioteka nie jest otwarta, a żadne zmiany nie zostaną zapisane.", + "Text": "Tekst", + "Text direction": "Kierunek tekstu", + "Texture": "Tekstura", + "That includes the photos in the folders inside it.": "Dotyczy to również zdjęć w znajdujących się wewnątrz folderach.", + "That key can't be used as a shortcut": "Tego klawisza nie można użyć jako skrótu", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 jest zainstalowany: maski Obiekt i Opis są gotowe.", + "The download didn't work:": "Pobieranie nie powiodło się:", + "The number at the end of the original name (IMG_0042 → 0042)": "Liczba na końcu oryginalnej nazwy (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Oryginalne rozszerzenie jest zawsze dodawane; {ext} umieszcza je również wewnątrz nazwy.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Oryginalne, niezmienione pliki, każdy z plikiem pomocniczym XMP zawierającym zapisane edycje.", + "The photo has these settings": "Zdjęcie ma następujące ustawienia", + "Theme": "Motyw", + "They can be restored from Recently Deleted until it is emptied.": "Można je przywrócić z Ostatnio usuniętych, dopóki ten folder nie zostanie opróżniony.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Trafią do Ostatnio usuniętych i będzie można je przywrócić; żadne pliki na dysku nie zostaną zmienione.", + "Thirds": "Trójpodział", + "This Computer": "Ten komputer", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Ten folder jest niedostępny (odłączony dysk?). Podglądy inteligentne nie będą tworzone ani używane, dopóki folder nie wróci lub nie wybierzesz innego.", + "This photo can't be opened": "Nie można otworzyć tego zdjęcia", + "Threshold": "Próg", + "Thumbnail cache": "Pamięć podręczna miniatur", + "Thursday": "Czwartek", + "Time zone": "Strefa czasowa", + "Tint": "Zabarwienie", + "Title": "Tytuł", + "Title metadata": "Metadane tytułu", + "Toggle Keyword from Set": "Przełącz słowo kluczowe z zestawu", + "Toggle Mask": "Przełącz maskę", + "Toggle Section": "Przełącz sekcję", + "Toggle Zoom": "Przełącz powiększenie", + "Tone Curve": "Krzywa tonalna", + "Tools": "Narzędzia", + "Top Level": "Poziom główny", + "Transfer": "Przenieś", + "Treatment": "Sposób obróbki", + "Triangle": "Trójkąt", + "Try Again": "Spróbuj ponownie", + "Try Saving Again": "Spróbuj ponownie zapisać", + "Tuesday": "Wtorek", + "Tungsten": "Światło żarowe", + "Turn Off": "Wyłącz", + "Turn on Include subfolders, or pick another folder under Local": "Włącz „Uwzględnij podfoldery” lub wybierz inny folder w obszarze Lokalne", + "Type": "Typ", + "Uncheck All": "Odznacz wszystko", + "Uncheck the files of this type": "Odznacz pliki tego typu", + "Undo": "Cofnij", + "Unedited": "Bez edycji", + "Unedited raws": "Nieedytowane pliki RAW", + "Unflag": "Usuń flagę", + "Unflagged": "Bez flagi", + "Ungroup Stack": "Rozgrupuj stos", + "Unknown Date": "Nieznana data", + "Unknown tags stay as typed — check the preview for a {typo}.": "Nieznane znaczniki pozostają bez zmian — sprawdź podgląd pod kątem {typo}.", + "Unrated": "Bez oceny", + "Unreadable": "Nieczytelny", + "Update": "Aktualizuj", + "Update Mask Shape": "Zaktualizuj kształt maski", + "Update Preset with Current Settings": "Zaktualizuj ustawienie wstępne bieżącymi ustawieniami", + "Update Rules from Current Filter": "Zaktualizuj reguły według bieżącego filtra", + "Update Version": "Zaktualizuj wersję", + "Update Version with Current Settings": "Zaktualizuj wersję bieżącymi ustawieniami", + "Update with Current Settings": "Zaktualizuj bieżącymi ustawieniami", + "Updated the edits saved in the external editor": "Zaktualizowano edycje zapisane w edytorze zewnętrznym", + "Upright": "Korekcja perspektywy", + "Urban": "Miejski", + "Use Keyword Set": "Użyj zestawu słów kluczowych", + "Use Library Folder": "Użyj folderu biblioteki", + "Use camera-specific defaults": "Użyj domyślnych ustawień aparatu", + "Use the GPU for rendering": "Użyj procesora graficznego do renderowania", + "User Presets": "Ustawienia wstępne użytkownika", + "Username": "Nazwa użytkownika", + "Value": "Wartość", + "Variance": "Wariancja", + "Version": "Wersja", + "Version name": "Nazwa wersji", + "Versions": "Wersje", + "Versions (Shift+V)": "Wersje (Shift+V)", + "Vertical": "Pionowo", + "Vertical text": "Tekst pionowy", + "Vibrance": "Dynamika", + "Videos": "Wideo", + "View": "Widok", + "Viewing and sharing": "Wyświetlanie i udostępnianie", + "Vignette": "Winieta", + "Vignetting": "Winietowanie", + "Vintage": "Styl retro", + "Virtual Copy": "Kopia wirtualna", + "Visible": "Widoczne", + "Visualize Spots": "Wizualizuj plamki", + "Visualize Spots (A)": "Wizualizuj plamki (A)", + "Visualize range": "Pokaż zakres", + "Vivid": "Żywe kolory", + "Vivid Pop": "Wyraziste kolory", + "Warm Film": "Ciepły film", + "Warm Glow": "Ciepła poświata", + "Warm Print": "Ciepły wydruk", + "Warm Table": "Ciepła paleta", + "Watched folder": "Folder obserwowany", + "Watermark": "Znak wodny", + "Wednesday": "Środa", + "What's New": "Co nowego", + "When rated or hovered": "Po ocenieniu lub wskazaniu kursorem", + "White Balance": "Balans bieli", + "White Balance Selector": "Próbnik balansu bieli", + "White Balance Selector (W)": "Próbnik balansu bieli (W)", + "Whites": "Biele", + "Width": "Szerokość", + "Width (% of photo)": "Szerokość (% zdjęcia)", + "Width (px)": "Szerokość (px)", + "Width × Height": "Szerokość × wysokość", + "Will be replaced": "Zostanie zastąpiony", + "Will be restored": "Zostanie przywrócony", + "Window": "Okno", + "Winter Blue": "Zimowy błękit", + "XMP Sidecar Preferences": "Preferencje plików pomocniczych XMP", + "XMP sidecars": "Pliki pomocnicze XMP", + "Year": "Rok", + "Yellow": "Żółty", + "Yellow Hue": "Odcień żółci", + "Yellow Luminance": "Luminancja żółci", + "Yellow Saturation": "Nasycenie żółci", + "Yellow label": "Żółta etykieta", + "Yes": "Tak", + "You can close this: the download continues, and resumes if interrupted.": "Możesz zamknąć to okno: pobieranie będzie kontynuowane i wznowi się po przerwaniu.", + "Your Name": "Twoje imię i nazwisko", + "Your library couldn't be opened": "Nie można otworzyć biblioteki", + "Zoom": "Powiększenie", + "Zoom 100%": "Powiększenie 100%", + "Zoom In": "Powiększ", + "Zoom Out": "Pomniejsz", + "Zoom to Fit": "Dopasuj powiększenie", + "Zoom to page level": "Dopasuj powiększenie do strony", + "a literal %": "Dosłowny znak %", + "a removed photo": "Usunięte zdjęcie", + "all": "wszystkie", + "and": "i", + "any": "dowolne", + "are empty": "Są puste", + "aren't empty": "Nie są puste", + "blue": "Niebieski", + "comma, separated": "Rozdzielone przecinkami", + "contains": "Zawiera", + "copyrighted": "Chronione prawem autorskim", + "day 01–31": "Dzień 01–31", + "days": "dni", + "discarding smart previews": "Odrzucanie podglądów inteligentnych", + "doesn't contain": "Nie zawiera", + "e.g. sky · the red car · car, road": "np. niebo · czerwony samochód · samochód, droga", + "ends with": "Kończy się na", + "false": "fałsz", + "green": "zielony", + "hour 00–23": "Godzina 00–23", + "hours": "godziny", + "image": "Obraz", + "includes the folders inside it": "Uwzględnia foldery wewnątrz", + "is": "jest", + "is <": "Jest <", + "is >": "Jest >", + "is after": "Jest po", + "is before": "Jest przed", + "is between": "Jest pomiędzy", + "is empty": "jest puste", + "is in the last": "Jest w ciągu ostatnich", + "is unknown": "Jest nieznany", + "is ≤": "Jest ≤", + "is ≥": "Jest ≥", + "isn't": "Nie jest", + "isn't empty": "nie jest puste", + "isn't in the last": "Nie występuje w ciągu ostatnich", + "keep names — or e.g. {date}_{seq:3}": "Zachowaj nazwy — lub np. {date}_{seq:3}", + "latitude, longitude": "Szerokość i długość geograficzna", + "library locked: the lock is the operating system's and goes away with the program.": "Biblioteka zablokowana: blokadę nakłada system operacyjny i znika ona po zamknięciu programu.", + "memory only": "Tylko w pamięci", + "minute": "minuta", + "month 01–12": "Miesiąc 01–12", + "months": "miesiące", + "none": "brak", + "none (CPU rendering)": "Brak (renderowanie przez CPU)", + "none of": "Żaden z", + "not available here": "Niedostępne tutaj", + "of the following rules:": "Następujących reguł:", + "or": "lub", + "pick": "Wybierz", + "previews": "Podglądy", + "publicDomain": "Domena publiczna", + "purple": "Fioletowy", + "raw": "RAW", + "red": "czerwony", + "saved in": "Zapisano w", + "second": "Sekunda", + "smart previews": "Podglądy inteligentne", + "the photos in view": "Zdjęcia w widoku", + "the selected photos": "Zaznaczone zdjęcia", + "this photo": "To zdjęcie", + "true": "prawda", + "unknown": "nieznany", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "Zdjęcie", + "weeks": "tygodnie", + "year, 2 digits": "Rok, 2 cyfry", + "year, 4 digits": "Rok, 4 cyfry", + "years": "lata", + "yellow": "żółty", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} oznacza datę wykonania; gdy jej brak, używana jest data importu.", + "© 2026 Your Name": "© 2026 Twoje imię i nazwisko", + "© Your Name": "© Twoje imię i nazwisko", + "− harder border · + softer border": "− ostrzejsza krawędź · + bardziej miękka krawędź", + "≤ at most": "≤ co najwyżej", + "≥ at least": "≥ co najmniej", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘ i przeciągnij, aby wyprostować, ⇧G: korekcja perspektywy z liniami pomocniczymi; podczas przekształcania wyświetlana jest siatka.", + "Any camera": "Dowolny aparat", + "Any lens": "Dowolny obiektyw", + "Any keyword": "Dowolne słowo kluczowe", + "Label Set": "Zestaw etykiet", + ", picked": ", wybrano", + ", rejected": ", odrzucono", + "Preview only: editing the camera's embedded JPEG": "Tylko podgląd: edycja JPEG-a osadzonego przez aparat", + "Captured from": "Wykonane od", + "Captured until": "Wykonane do", + "Only": "Tylko", + "How to install the model": "Jak zainstalować model", + "Install the SAM 3 Model": "Zainstaluj model SAM 3", + "Open Log Folder": "Otwórz folder dzienników", + "Back to this person": "Wróć do tej osoby", + "AI Denoise": "Odszumianie AI", + "AI Denoise Model": "Model odszumiania AI", + "AI Denoise is off: press Use on a model.": "Odszumianie AI jest wyłączone: kliknij „Użyj” przy modelu.", + "AI Denoise needs a model. Install one in Settings.": "Odszumianie AI wymaga modelu. Zainstaluj go w Ustawieniach.", + "Accept & Download": "Akceptuj i pobierz", + "Already installed. Remove it before installing a replacement.": "Już zainstalowano. Usuń obecny model przed zainstalowaniem nowego.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Tryb automatyczny używa karty graficznej, jeśli jest szybsza od procesora. Przy pierwszym przetwarzaniu zdjęcia LightCraft mierzy wydajność obu urządzeń.", + "Cache": "Pamięć podręczna", + "Checking and installing the model…": "Sprawdzanie i instalowanie modelu…", + "Checking…": "Sprawdzanie…", + "Clear cache": "Wyczyść pamięć podręczną", + "Denoise Models": "Modele odszumiania", + "Denoise Settings": "Ustawienia odszumiania", + "Denoise all photos with an Amount": "Odszum wszystkie zdjęcia z ustawioną intensywnością", + "Downloaded and checked. Installing…": "Pobrano i sprawdzono. Instalowanie…", + "Failed its last test": "Nie przeszedł ostatniego testu", + "Graphics card": "Karta graficzna", + "I have read these terms and accept them for my own use": "Przeczytałem warunki i akceptuję je na własny użytek", + "Inspect Denoise Model": "Sprawdź model odszumiania", + "Install Denoise Model": "Zainstaluj model odszumiania", + "Install from file…": "Zainstaluj z pliku…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Zainstaluj własny model ONNX z plikiem denoise-model.json w tym samym katalogu. W tej kompilacji nie można pobierać modeli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Nie utworzono jeszcze odszumionego obrazu, a obrazy nie są przetwarzane automatycznie (Ustawienia, Odszumianie AI).", + "LightCraft does not know this model, so it assumed:": "LightCraft nie rozpoznaje tego modelu, dlatego przyjęto następujące założenia:", + "Limit": "Limit", + "Make it now": "Utwórz teraz", + "Make the denoised picture of the photos I am looking at": "Utwórz odszumione wersje oglądanych zdjęć", + "Next up for AI Denoise…": "Następne w kolejce do odszumiania AI…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nic nie zostanie pobrane bez Twojej zgody. Model jest przechowywany tylko na tym komputerze; LightCraft go nie przesyła ani nie udostępnia.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Przetwarzane są tylko zdjęcia z intensywnością odszumiania AI większą od zera. Podczas eksportu brakujące wersje są zawsze tworzone.", + "Open page": "Otwórz stronę", + "Open the model's page": "Otwórz stronę modelu", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Otwiera stronę modelu w przeglądarce, gdzie można przeczytać o nim lub samodzielnie pobrać plik.", + "Processor": "Procesor", + "Set up AI Denoise…": "Skonfiguruj odszumianie AI…", + "Speed": "Szybkość", + "Starting…": "Uruchamianie…", + "Tested and works on this computer": "Przetestowano — działa na tym komputerze", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model odszumiania jest zainstalowany i używany: suwak intensywności odszumiania AI w panelu Szczegóły jest teraz aktywny", + "The graphics card is set up when the next photo is made.": "Karta graficzna zostanie skonfigurowana podczas przetwarzania następnego zdjęcia.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model jest przechowywany wyłącznie na tym komputerze. LightCraft nigdy go nie przesyła ani nie udostępnia.", + "The processor does the work.": "Przetwarzanie wykonuje procesor.", + "There is no model file to install": "Brak pliku modelu do zainstalowania", + "This build cannot run denoise models.": "Ta kompilacja nie obsługuje modeli odszumiania.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Ta kompilacja nie ma miejsca na przechowywanie modeli odszumiania, więc odszumianie AI jest niedostępne (dostępne w aplikacji komputerowej).", + "This file cannot be used yet": "Nie można jeszcze użyć tego pliku", + "Tick the box to accept the model's terms first": "Najpierw zaznacz pole akceptacji warunków modelu", + "Try again": "Spróbuj ponownie", + "Try the graphics card again": "Spróbuj ponownie użyć karty graficznej", + "Turn off": "Wyłącz", + "Unknown licence": "Nieznana licencja", + "Use": "Użyj", + "Waiting to start…": "Oczekiwanie na uruchomienie…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.", + "cached picture": "obraz w pamięci podręcznej", + "cached pictures": "obrazy w pamięci podręcznej", + "commercial use allowed": "dozwolony użytek komercyjny", + "in use": "w użyciu", + "installed": "zainstalowano", + "makes AI Denoise available": "włącza odszumianie AI", + "non-commercial use only": "wyłącznie do użytku niekomercyjnego", + "photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.", + "photo waiting": "zdjęcie oczekuje", + "photos have their denoised picture.": "zdjęcia mają już odszumione wersje.", + "photos waiting": "zdjęć oczekuje", + "starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI", + "terms unclear": "niejasne warunki", + "the graphics card": "karta graficzna", + "the graphics card is not used": "karta graficzna nie jest używana", + "Cancel Denoise": "Anuluj odszumianie", + "Cancel Denoise Model Download": "Anuluj pobieranie modelu odszumiania", + "Choose Denoise Model": "Wybierz model odszumiania", + "Clear Denoise Cache": "Wyczyść pamięć podręczną odszumiania", + "Denoise Model Downloads": "Pobieranie modeli odszumiania", + "Denoise Photos": "Odszum zdjęcia", + "Denoise Pump": "Kolejka odszumiania", + "Denoise Status": "Stan odszumiania", + "Download Denoise Model": "Pobierz model odszumiania", + "Remove Denoise Model": "Usuń model odszumiania", + "Test Denoise Model": "Przetestuj model odszumiania", + "AI Denoise is enabled on the requesting photo": "Odszumianie AI włączono dla wybranego zdjęcia", + "After installation, AI Denoise will turn on for the requesting photo.": "Po instalacji odszumianie AI zostanie włączone dla wybranego zdjęcia.", + "Cancel pending action": "Anuluj oczekującą operację", + "Install a denoise model to enable AI Denoise on this photo": "Zainstaluj model odszumiania, aby włączyć odszumianie AI dla tego zdjęcia", + "Pending photo action cancelled": "Anulowano oczekującą operację", + "Pending photo action cancelled because its library or photo changed": "Anulowano oczekującą operację, ponieważ zmieniła się biblioteka lub zdjęcie", + "ONNX model": "Model ONNX", + "commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL", + "Decrease Rating": "Zmniejsz ocenę", + "Increase Rating": "Zwiększ ocenę", + "Click to type a value": "Kliknij, aby wpisać wartość", + "Angle": "Kąt", + "Video": "Wideo", + "Copyrighted": "Chronione prawem autorskim", + "Public Domain": "Domena publiczna", + "Unknown": "Nieznany", + "Landscape (wide)": "Poziomo (szeroko)", + "Portrait (tall)": "Pionowo (wysoko)", + "Square": "Kwadrat", + "In Color": "Kolorowe", + "No Label": "Bez etykiety", + "Choose an album…": "Wybierz album…", + "No albums yet": "Brak albumów", + "Fix the marked rules to save this album.": "Popraw oznaczone reguły, aby zapisać album.", + "Some rules need fixing (Edit Smart Album…):": "Niektóre reguły wymagają poprawienia (Edytuj album inteligentny…):", + "isn't a field rules know": "nie jest obsługiwanym polem reguły", + "can't be tested that way": "nie można sprawdzić w ten sposób", + "is yes or no": "wymaga odpowiedzi tak lub nie", + "needs a number": "wymaga liczby", + "needs a time like 1/250 or 2": "wymaga czasu, np. 1/250 lub 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "wymaga daty, np. 2026, 2026-08 lub 2026-08-14", + "has the first date after the second": "pierwsza data jest późniejsza od drugiej", + "needs two values": "wymaga dwóch wartości", + "needs a number more than 0": "wymaga liczby większej od zera", + "needs hours, days, weeks, months or years": "wymaga godzin, dni, tygodni, miesięcy lub lat", + "isn't one of the choices": "nie jest jedną z dostępnych opcji", + "needs something to look for (or use “is empty”)": "wymaga tekstu do wyszukania (lub opcji „jest puste”)", + "needs something to look for (or use “isn't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie jest puste”)", + "needs text": "wymaga tekstu", + "can't match any rating from 0 to 5": "nie może odpowiadać żadnej ocenie od 0 do 5", + "needs an album": "wymaga albumu", + "names an album that no longer exists": "wskazuje album, który już nie istnieje", + "can't test a folder; choose an album in it": "nie można sprawdzić folderu; wybierz album, który się w nim znajduje", + "This group is empty: add a rule or remove it.": "Ta grupa jest pusta: dodaj regułę albo usuń grupę.", + "needs something to look for (or use “are empty”)": "wymaga tekstu do wyszukania (lub opcji „są puste”)", + "needs something to look for (or use “aren't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie są puste”)", + "Mo": "Pn", + "Tu": "Wt", + "We": "Śr", + "Th": "Cz", + "Fr": "Pt", + "Sa": "So", + "Su": "Nd", + "Pick a date": "Wybierz datę", + "would make this album include itself through other smart albums": "spowodowałoby włączenie tego albumu do niego samego przez inne albumy inteligentne", + "Search albums": "Wyszukaj albumy", + "No albums match": "Brak pasujących albumów", + "This is the album you're editing": "To album, który edytujesz", + "It tests this album, so testing it back would loop": "Sprawdza ten album, więc odwrotne sprawdzenie utworzyłoby pętlę", + "Copy Name": "Kopiuj nazwę", + "In a Stack": "W stosie", + "File Extension": "Rozszerzenie pliku", + "Video Duration": "Czas trwania filmu", + "Keyword Count": "Liczba słów kluczowych", + "People Count": "Liczba osób", + "Shutter Speed": "Czas migawki", + "Cropped": "Przycięte", + "Keywords & People": "Słowa kluczowe i osoby", + "Description": "Opis", + "Camera Info": "Informacje o aparacie", + "Develop": "Wywoływanie", + "Keyword List": "Lista słów kluczowych", + "Filter Keywords": "Filtruj słowa kluczowe", + "No keywords yet": "Brak słów kluczowych", + "No keywords match": "Brak pasujących słów kluczowych", + "Keyword Name": "Nazwa słowa kluczowego", + "Synonyms": "Synonimy", + "Separated by commas": "Oddzielone przecinkami", + "Keyword Tag Options": "Opcje etykiety słowa kluczowego", + "Include on Export": "Uwzględnij przy eksporcie", + "Export Containing Keywords": "Eksportuj nadrzędne słowa kluczowe", + "Export Synonyms": "Eksportuj synonimy", + "Create Keyword Tag": "Utwórz słowo kluczowe", + "Edit Keyword Tag": "Edytuj słowo kluczowe", + "Create": "Utwórz", + "No photo has it; the keywords below it go too.": "Żadne zdjęcie go nie używa; podrzędne słowa kluczowe również zostaną usunięte.", + "Edit Keyword Tag…": "Edytuj słowo kluczowe…", + "Put New Keywords Inside This Keyword": "Umieszczaj nowe słowa kluczowe wewnątrz tego", + "Purge Unused Keywords": "Usuń nieużywane słowa kluczowe", + "Delete Keyword…": "Usuń słowo kluczowe…", + "Drop here for the top level": "Upuść tutaj, aby umieścić na najwyższym poziomie", + "Their photos and the keywords below them come together under one keyword.": "Zdjęcia i podrzędne słowa kluczowe zostaną zebrane pod jednym słowem kluczowym.", + "Select photos to give them keywords.": "Wybierz zdjęcia, którym chcesz przypisać słowa kluczowe.", + "Add to All Selected Photos": "Dodaj do wszystkich wybranych zdjęć", + "Will Export": "Zostanie wyeksportowane", + "No keywords are exported": "Nie zostaną wyeksportowane żadne słowa kluczowe", + "& Containing": "i nadrzędne", + "Edit Set…": "Edytuj zestaw…", + "Edit Keyword Set": "Edytuj zestaw słów kluczowych", + "Keywords (⌥1–⌥9)": "Słowa kluczowe (⌥1–⌥9)", + "A keyword set needs a name": "Zestaw słów kluczowych musi mieć nazwę", + "Save as a new set": "Zapisz jako nowy zestaw", + "Recent Keywords is built in: choose another name": "Ostatnie słowa kluczowe to zestaw wbudowany: wybierz inną nazwę", + "Import Keywords…": "Importuj słowa kluczowe…", + "Export Keywords…": "Eksportuj słowa kluczowe…", + "Import Keywords": "Importuj słowa kluczowe", + "Export Keywords": "Eksportuj słowa kluczowe", + "Keyword Lists": "Listy słów kluczowych", + "Synchronize Folder…": "Synchronizuj folder…", + "Synchronize Folder": "Synchronizuj folder", + "Synchronize": "Synchronizuj", + "Scanning…": "Skanowanie…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Znajdź zdjęcia dodane do folderu lub brakujące na dysku oraz pliki XMP zmienione przez inne aplikacje", + "Bring the library up to date with this folder and the folders inside it:": "Zaktualizuj bibliotekę na podstawie tego folderu i wszystkich jego podfolderów:", + "Import new photos": "Importuj nowe zdjęcia", + "Remove missing photos from the library": "Usuń brakujące zdjęcia z biblioteki", + "Read metadata updates from XMP sidecars": "Odczytaj aktualizacje metadanych z plików XMP", + "The library is up to date with this folder.": "Biblioteka jest aktualna względem tego folderu.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Brakujące zdjęcia trafiają do Ostatnio usuniętych i można je przywrócić; pliki na dysku pozostają bez zmian.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Odczyt pliku XMP zastępuje ocenę, etykiety, metadane i edycje zdjęcia jego zawartością.", + "Skipped: files already in the library elsewhere": "Pominięto: pliki znajdujące się już w bibliotece w innym miejscu", + "Skipped: files that can't be read": "Pominięto: pliki, których nie można odczytać", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Ten folder nie istnieje: przeniesiono go, zmieniono nazwę albo odłączono dysk.", + "Relink photos whose file was renamed or moved": "Połącz ponownie zdjęcia, których pliki przeniesiono lub przemianowano", + "A folder is already being synchronized": "Trwa już synchronizacja folderu", + "Synchronizing…": "Synchronizowanie…", + "Relinked": "Połączono ponownie", + "Removed": "Usunięto", + "Metadata read": "Odczytano metadane", + "Failed": "Niepowodzenie", + "Stopped": "Zatrzymano", + "Stopped by an error (see the log)": "Zatrzymano z powodu błędu (zobacz dziennik)", + "Not saved": "Nie zapisano", + "A folder is being synchronized": "Trwa synchronizacja folderu", + "Wait for the import to finish": "Poczekaj na zakończenie importu", + "Show less": "Pokaż mniej", + "Quit while tasks are running?": "Zakończyć, gdy trwają zadania?", + "Exporting contact sheet": "Eksportowanie arkusza stykowego", + "Reading folder": "Odczytywanie folderu", + "Scanning folder": "Skanowanie folderu", + "Building previews": "Tworzenie podglądów", + "Building smart previews": "Tworzenie podglądów inteligentnych", + "Discarding smart previews": "Odrzucanie podglądów inteligentnych", + "Importing Lightroom catalog": "Importowanie katalogu Lightroom", + "Reading Lightroom catalog": "Odczytywanie katalogu Lightroom", + "Merging": "Scalanie", + "Downloading model": "Pobieranie modelu", + "Finding faces": "Wyszukiwanie twarzy", + "Synchronizing folder": "Synchronizowanie folderu", + "Looking for changes": "Wyszukiwanie zmian", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„zapisano w pamięci, ale nie na dysku”, na górnym pasku pojawia się ostrzeżenie, a LightCraft ponawia próby, dopóki", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): plik DNG jest porównywany z danymi RAW, zapisywany do pliku tymczasowego, synchronizowany i odczytywany ponownie, zanim", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Pomoc ▸ Informacje o systemie i Ustawienia ▸ Wydajność wyjaśniają, dlaczego procesor graficzny nie jest", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(np. D750, D780, D850, D7500, Z 50) nie wyświetlają się już niemal czarne ani z utraconymi szczegółami w cieniach: ich poziom czerni był", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(zgłoszenie #94): żądanie HTTP ze strony internetowej nie może już przekazać polecenia w treści. Długość wierszy ograniczono do", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 na studio-mac”) zamiast pozwalać obu procesom zapisywać i po cichu gubić wzajemne zmiany. Awaria nigdy nie pozostawia", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "podgląd 1620 × 1080 i są importowane wraz z metadanymi: datą wykonania, aparatem, obiektywem, ekspozycją, GPS i XMP. Ich dane RAW", + "2026-04 or 2026-04-12": "2026-04 lub 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nowe wbudowane ustawienia predefiniowane: Portret, Krajobraz, Miasto, Jedzenie, Pory roku, Vintage oraz tonowanie czarno-białe.", + "4 MiB and connections at 16.": "4 MiB, a liczbę połączeń do 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630): w systemie Windows okno i renderowanie GPU korzystają wyłącznie z DirectX 12 i nigdy nie ładują sterownika Vulkan, chyba że", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Zmiana, której nie można zapisać na dysku (pełny lub odłączony nośnik), nie pozostaje już bez sygnału: polecenie zgłasza", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Jedna biblioteka może być otwarta tylko w jednym programie naraz (zgłoszenie #99): próba otwarcia biblioteki używanej przez LightCraft lub `lightcraft-cli`", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Zapis przerwany w połowie (pełny dysk, zerwane połączenie z udziałem sieciowym) nie sprawia już wrażenia uszkodzonego katalogu", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Uśpiony NAS, niedostępny udział sieciowy albo uruchamiający się dysk USB nie blokują już okna (zgłoszenie #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Maski AI wykorzystujące SAM 3 (Obiekt i Opis w panelu Maskowanie): kliknij obiekt, aby go zaznaczyć (kliknięcie z ⌥ pozostawia", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Wersja przeglądarkowa (eksperymentalna), ochrona biblioteki (zgłoszenie #107): Plik ▸ Utwórz kopię zapasową biblioteki… pobiera katalog", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Zdjęcia Canon CR2 z aparatów EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II i 1D Mark IV (oraz innych", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Pliki Canon CR3 pokazują pełnowymiarowy podgląd JPEG osadzony w pliku (np. 6960 × 4640 w EOS R6 Mark III), zamiast", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R zachowuje zdjęcie, ale usuwa jego edycje).", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Skompresowane pliki Nikon NEF (kompresja bezstratna i stratna, 12 i 14 bitów — np. D3200, D5100, D7000, D750, D850, Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Konwersja do DNG, Kopiuj jako DNG, Łączenie zdjęć i podglądy inteligentne nie zapisują już bezpośrednio do pliku docelowego (zgłoszenie", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ustawienie krawędzi i uzyskaj ostrzejszy podgląd po powiększeniu, obliczany w tle. Model działa wewnątrz LightCraft w czystym Ruście", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Wyeksportowane pliki zapisuje się do pliku tymczasowego, a następnie zmienia ich nazwę na docelową, dzięki czemu pełny lub odłączony dysk", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Eksport i renderowanie nigdy nie nadpisują oryginału zdjęcia (zgłoszenie #93): eksport do folderu ze zdjęciem przy", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Eksporty nie są już czarne z powodu GPU (zgłoszenie #78): renderowanie GPU, któremu zabraknie pamięci urządzenia", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Szybszy eksport i import z kart, przy zachowaniu ochrony danych nieodtwarzalnych (zgłoszenie #134): eksporty,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importuj ustawienia predefiniowane z innych edytorów: ustawienia XMP, klasyczne pliki `.lrtemplate`, „ustawienia DNG” z aplikacji mobilnych i archiwa `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import ▸ Kopiuj weryfikuje każdą kopię, podobnie jak Przenieś (zgłoszenie #96): każdy plik jest zapisywany jako nowy, synchronizowany z dyskiem i", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import ▸ Przenieś: zdjęcia trafiają do folderu docelowego (z takim samym układem folderów i zmianą nazw jak przy kopiowaniu, np.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: kopiowanie do dowolnego folderu, podział według dni lub miesięcy albo do jednego folderu, zmiana nazw przy imporcie, zestaw metadanych, kopiowanie jako DNG.", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "Plik JPEG o tej samej nazwie (`IMG_0001.CR3` i `IMG_0001.JPG`) nie współdzieli już jednego pliku XMP: RAW zachowuje `IMG_0001.xmp`,", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft nie zawiesza się już podczas uruchamiania na komputerach Windows z uszkodzonym sterownikiem Vulkan (zgłoszenie #136, np. niektóre Intel UHD", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Style Luminar: pliki `.lmp` i kolekcje `.mplumpack` są importowane jako ustawienia predefiniowane (grupowane według kolekcji); suwaki", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Warunki licencji firmy Meta, a nie LightCraft. Pobranie oznacza ich akceptację.", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Przenieś: pliki są przenoszone do folderu poniżej; oryginał (wraz z plikiem XMP) jest usuwany ze źródła dopiero po weryfikacji kopii. Duplikaty i pliki z błędami pozostają na miejscu.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Pliki Nikon NEF otrzymują wstępne kolory i tonalność dopasowane do JPEG z aparatu, podobnie jak Sony ARW, zamiast wyblakłego,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Pliki Nikon podzielone na dwie części o różnych rodzajach kompresji nadal korzystają na razie z podglądu).", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Maski Obiekt i Opis korzystają z modelu segmentacji SAM 3 firmy Meta. Nie jest on częścią LightCraft; wszystkie pozostałe funkcje działają bez niego.", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Pliki RAW Panasonic i Leica (RW2, RWL i starsze RAW, od DMC-LX1 do DC-S1R II) są wywoływane z danych RAW w", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Zdjęcie. Pasek zdjęć ma także menu kontekstowe. Ponowne dodanie pliku znajdującego się w Ostatnio usuniętych nie", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Zdjęcie ▸ Edytuj w edytorze zewnętrznym (⇧⌘E) tworzy 16-bitową kopię TIFF, grupuje ją z oryginałem i otwiera w wybranym programie (nazwa aplikacji w macOS, ścieżka programu w pozostałych systemach).", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Zdjęcia w Ostatnio usuniętych można przywrócić w aplikacji: kliknij prawym przyciskiem ▸ Przywróć (lub Usuń trwale), również w", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Zdjęcia widoczne w widoku Lokalnie, lecz nigdy niedodane ani niezmienione, są usuwane z katalogu, jeśli ich folder nie był przeglądany przez określony czas (sprawdzane przy otwieraniu biblioteki). Pliki pozostają na dysku; ponowne przejrzenie folderu znów je pokazuje.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Zdjęcia pojawiające się w tym folderze (z aparatu podłączonego do komputera, skanera lub aplikacji synchronizującej) są dodawane od razu po ukończeniu zapisu.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Zdjęcia); importowanie (także przeciąganiem), Znajdź brakujące zdjęcia, Zbuduj / Odrzuć podglądy inteligentne, podgląd zmiany nazw,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Kolekcja podręczna i album docelowy (B w siatce), zestawy słów kluczowych (⌥1–⌥9), zestawy etykiet kolorów.", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Zmień nazwę folderu… i Przenieś folder do… (Lokalnie) można cofnąć i ponowić (zgłoszenie #97): folder wraca na dysku wraz ze", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Zmień nazwy zdjęć nigdy nie nadpisuje innego zdjęcia przy zmianie wyłącznie wielkości liter (zgłoszenie #95): w systemach z rozróżnianiem", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Zmień nazwy zdjęć zgłasza pliki, których nie udało się przywrócić po błędzie (zgłoszenie #105), np. po zerwaniu połączenia z udziałem sieciowym", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Zapis metadanych do pliku XMP utworzonego przez inną aplikację nie zastępuje już jego zawartości (zgłoszenie #92): LightCraft scala swoje", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Pliki ustawień nie są już po cichu resetowane (zgłoszenie #103): uszkodzone `prefs.json`, `presets.json` lub `view.json` zachowuje się", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Mniejsze i szybsze katalogi: zdjęcia jedynie przeglądane w widoku Lokalnie (niedodane, nieocenione i nieedytowane) są zapominane po", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Pliki Sony ARW sprzed około 2017 r. (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II i pokrewne", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni RGB,", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Znaczniki: zobacz Znaczniki obok szablonu. Nazwy plików na dysku (wraz z plikami XMP) są zmieniane; kolizje otrzymują przyrostek -1, -2…", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatyczny import z obserwowanego folderu; Konwertuj do DNG; Duplikuj; Zbuduj podglądy standardowe / 1:1 / inteligentne.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Bez zapisywania i Zakończ (zgłoszenie #100). Program nie uruchamia już po cichu sesji demonstracyjnej wyglądającej jak zresetowana biblioteka, przez co", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) wraz z ich plikami XMP; każdy oryginał znika z karty dopiero po", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; na woluminach nierozróżniających wielkości liter zmiana wielkości liter również przechodzi poprawnie.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "pełny dysk) nie jest już uznawany za gotowy, a funkcja Zbuduj podglądy inteligentne zastępuje go.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "potem (zgłoszenie #101): niepełny zapis jest obcinany przed ponowieniem przez LightCraft, więc przy następnym uruchomieniu odtwarzane są wszystkie", + "again. Change the period (or turn it off) in Settings → Performance.": "ponownie. Zmień ten okres (lub wyłącz go) w Ustawienia → Wydajność.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "z oryginałami biblioteki nie skanuje już całej biblioteki, jeśli pod wskazaną ścieżką nie ma żadnych plików.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ma już folder `thumbs/` z innymi zdjęciami — nie tracą one danych przy przycinaniu lub czyszczeniu pamięci podręcznej.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "i sprawdza plik przed użyciem. Maski zachowują swoje zaznaczenie, więc renderowanie i eksport działają także bez modelu.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "i każde importowane zdjęcie w jednym archiwum ZIP, a Plik ▸ Przywróć bibliotekę z kopii zapasowej… przywraca je (bieżąca biblioteka", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "oraz nie blokuje okna. Funkcja jest opcjonalna i nie należy do LightCraft (licencja SAM firmy Meta, około 3,4 GB):", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "jako `<name>.corrupt-<time>` i informuje o tym; plik, którego nie można odczytać (np. zablokowany przez inny program), pozostaje bez zmian", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "gdy o to poprosisz. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (lub `WGPU_BACKEND` biblioteki wgpu, którą renderowanie GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "pakiety zawierające dowolne z nich — całe foldery naraz, grupowane według pakietu. Maski zawarte w ustawieniach również są przenoszone.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "zapis aparatu nie jest już na stałe stosowany do obrazu. Zdjęcia wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu. (Pliki, których", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "takiej zmiany. Katalogi zawierające już taki fragment wczytują się w całości. Zamykanie programu, gdy dziennika katalogu nie da się zapisać", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "zmian nie udało się zapisać, podejmuje jeszcze jedną próbę, a następnie pyta: Spróbuj zapisać ponownie, Zakończ mimo to lub Anuluj.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "przez kanał sterujący lub MCP, ścieżka docelowa łączenia albo `lightcraft-cli render IMG.jpg -o IMG.jpg` są odrzucane z czytelnym", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "weryfikowane względem zawartości odczytanej z karty. Nieudaną lub różniącą się kopię usuwa się i zgłasza jako nieudany", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "kolory i tonację dopasowane do JPEG z aparatu, podobnie jak w plikach Nikon i Sony RAW, z kadrem zgodnym z proporcjami ustawionymi w", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "kopia została zweryfikowana i dodana do biblioteki. Duplikaty i pliki z błędami pozostają na swoim miejscu.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dane nie są jeszcze dekodowane, więc pozostają dostępne tylko jako podgląd. Dla plików CR3 zaimportowanych wcześniej funkcja Zdjęcie ▸ Wczytaj ponownie z dysku (teraz w", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "usuwane są krawędzie, a „12-bitowe nieskompresowane” pliki nie są już przycinane tonalnie. Seria RX100 wyświetla się znacznie bliżej", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "każdy format zapisywany przez aparaty. Większość modeli wcześniej otwierała tylko podgląd: GH1–GH5, serie G, GX, GF, GM, FZ, LX i", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "eksport. Katalog, pliki XMP, ustawienia, DNG, połączone zdjęcia, podglądy inteligentne i kopie utworzone podczas importu nadal są", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "pola i zachowuje pozostałe — np. ustawienia wywoływania oraz historię edycji z tamtej aplikacji — bajt po bajcie. Plik", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "pliku (Sony przechowuje je w tych modelach jedynie w zaszyfrowanych danych producenta), kilka kolumn wyrównania przy prawej", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "przy pierwszym użyciu maski AI LightCraft prosi o zgodę na pobranie, pokazuje postęp, pozwala anulować i wznowić,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "na czas sesji, zamiast nadpisywać je wartościami domyślnymi. Ustawienia aplikacji (`ui.json`, które zapamiętują", + "gets -1, -2… instead of being replaced.": "otrzymuje przyrostek -1, -2… zamiast zostać zastąpiony.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "zielonkawego, neutralnego obrazu (zgłoszenie #150); balans bieli korygowany jest względem wyglądu zapisanego przez aparat. Pliki NEF 12-bitowe", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "jest otwarta — na tym komputerze lub innym współdzielącym folder — wskazuje kto ją otworzył („już otwarta w LightCraft (proces", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "jak je przywrócić albo trwale usunąć i ponownie zaimportować. (Aby zacząć od nowa z danym zdjęciem, użyj Resetuj edycje,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "wcześniej ignorowany) wybiera zaplecze graficzne; `off` renderuje na CPU. GPU uruchamia się teraz po otwarciu okna", + "immutable, and HOSTING.md describes the actual build.": "i niezmienne, a HOSTING.md opisuje rzeczywistą konfigurację kompilacji.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importu — dlatego „import ukończony” oznacza zweryfikowane kopie, zanim sformatujesz kartę — a zajęta nazwa", + "imported as \"preview only\" switch over on Reload.": "wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "w krótkich partiach, aby mechanizmy nadzoru wolnych zintegrowanych GPU nie resetowały urządzenia. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "niedokończone renderowanie jest powtarzane na CPU — plik wynikowy zawiera ten sam obraz w obu przypadkach. Praca jest wysyłana do GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "jest zachowywany). Nieudany zapis (pełny nośnik) wyświetla teraz ostrzeżenie o niezapisanych zmianach i jest ponawiany; zdjęcie, którego nie można zapisać", + "is refused and nothing is overwritten.": "jest odrzucana i nic nie zostaje nadpisane.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "działa, i tylko jeśli w Ustawienia ▸ Wydajność włączono używanie GPU do renderowania; jeśli LightCraft kiedykolwiek zakończy się awaryjnie podczas", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "nie zostaje dodana, druga karta pokazuje komunikat zamiast nadpisywać pierwszą, `?reset` najpierw pyta, a strona informuje", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "z dala od drugiego pliku (zgłoszenie #92). Znajdź brakujące zdjęcia wyszukuje również pliki o zmienionych nazwach na podstawie zawartości, preferuje zgodność zawartości", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ego zdjęcia i pliki XMP, a zdjęcia ponownie na niego wskazują. Jeśli stare miejsce jest już zajęte, cofnięcie", + "layout is read from each file instead of assumed.": "układ jest odczytywany z każdego pliku, zamiast przyjmować go z góry.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "nie pozostawia pliku DNG i zachowuje RAW. Podglądy inteligentne zapisuje się tak samo; uszkodzony podgląd (ucięty przez awarię lub", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteki) są zapisywane atomowo i natychmiast po otwarciu innej biblioteki, nie tylko przy wychodzeniu. Zamykanie programu, gdy", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "nie ogranicza się już do komunikatu „pominięto duplikat”: otwiera panel boczny Ostatnio usunięte, zaznacza zdjęcie i informuje,", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "wszystko zostało utracone przy wyjściu; sesja tymczasowa przez cały czas pokazuje baner i nigdy nie zapisuje w Twojej bibliotece.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "pamięci, przekroczy limity bufora GPU, napotka błąd lub reset sterownika albo zwróci", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "komunikatem, a oryginał pozostaje niezmieniony bajt po bajcie. Wcześniejsze zwykłe eksporty nadal są nadpisywane na żądanie.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "w trakcie partii: błąd wymienia pliki (stara → nowa nazwa), a biblioteka wskazuje ich nowe nazwy (częściową zmianę nazw można cofnąć),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modele z matrycą zaczynającą się od rzędu zielono-niebieskiego) nie mają już magentowego zafarbu (zgłoszenie #85): układ filtrów barwnych", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nazwę (bez zastępowania pliku), a dopiero potem łączy zdjęcie z plikiem albo usuwa kopię RAW — nieudany zapis", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "nigdy nie pozostawia uciętego pliku; plik XMP eksportu „Oryginał” także respektuje ustawienie „Jeśli plik istnieje”.", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "są teraz wywoływane z danych RAW, zamiast z JPEG osadzonego przez aparat, dzięki czemu styl czarno-biały lub inny ustawiony w", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "na karcie po przeglądzie importu jest zgłaszana, zamiast być importowana z nieaktualnymi danymi. Sprawdzanie ścieżki eksportu", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "pojedynczo — można je zawsze wyeksportować ponownie, a na dysku USB lub NAS synchronizacja każdego pliku osobno znacznie wydłużała duże", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "zamiast pliku o tej samej nazwie i rozmiarze, ale innej zawartości, a niejednoznaczne zdjęcia pomija (i zgłasza), zamiast zgadywać.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "część na zewnątrz) albo wpisz, co zaznaczyć („niebo”, „czerwony samochód”, „samochód, droga”); obie maski można łączyć z innymi, mają", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "z zachowaniem edycji RAW i eksportu w pełnej rozdzielczości, zamiast korzystać z osadzonych podglądów JPEG.", + "read in the wrong units.": "odczytywano w niewłaściwych jednostkach.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "ponowne odczytanie karty po raz trzeci (funkcja Przenieś nadal porównuje dane bajt po bajcie przed usunięciem źródła); plik, który zmienił się", + "reject": "odrzuć", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "renderowane obrazy i zrzuty ekranu nadal trafiają najpierw do pliku tymczasowego, potem otrzymują nazwę docelową, ale nie są już wymuszane zapisy na dysk", + "save goes through.": "zapis powiedzie się.", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "pokrewne modele) nie mają już jaskrawozielonych zdjęć (zgłoszenie #148): balans bieli i poziom czerni zapisane przez aparat odczytuje się z", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "plik XMP, który nie jest prawidłowym XMP, zostaje najpierw skopiowany do `<name>.xmp.bak-<time>`. Przy domyślnym nazewnictwie według podstawy nazwy pliki RAW i", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "żaden plik nie jest oznaczany jako brakujący. Zmiana nazwy jednego z pary RAW + JPEG kopiuje ich wspólny `IMG_0001.xmp`, zamiast zabierać go", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "podczas uruchamiania GPU następny start odbywa się z wyłączonym renderowaniem GPU i informacją, jak je ponownie włączyć.", + "starts with": "zaczyna się od", + "still saves your queued changes in the closing snapshot.": "nadal zapisuje oczekujące zmiany w migawce zamykającej.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronizowane. Import ▸ Kopiuj sprawdza teraz każdą kopię względem skrótu zawartości obliczonego podczas skanowania karty, zamiast", + "the JPEG uses `IMG_0001.JPG.xmp`.": "a JPEG korzysta z `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "drzewo folderów Lokalnie i Automatyczny import również odczytują dysk w wątkach roboczych. Okno postępu importu ma przycisk Anuluj.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "menu Zdjęcie i menu kontekstowym zdjęcia) pobiera pełnowymiarowy podgląd i uzupełnia metadane aparatu, których", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "gdy aparat; uszkodzone piksele matrycy są wypełniane. Importowane są również pliki `.rwl` i `.raw`. Zdjęcia wcześniej", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "tej samej nazwie i wybraniu „Nadpisz” (lub ponownym eksporcie z poprzednimi ustawieniami), dokładna ścieżka wyjściowa z kanału sterującego", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "ich folder nie był przeglądany przez 30 dni — pliki i XMP pozostają na dysku, a ponowne wejście do folderu je pokazuje", + "to the camera's own JPEG.": "wyglądu JPEG prosto z aparatu.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "nienaruszone — starsze wersje nie odczytują już fragmentu jako uszkodzonego dziennika. Gdy ta wersja otworzy bibliotekę,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "użyto (np. pominięty adapter programowy llvmpipe) i dlaczego ostatnie renderowanie przełączono na inne rozwiązanie.", + "video": "wideo", + "were missing, without touching anything already set.": "brakowało, bez zmiany już ustawionych wartości.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "gdy przeglądarka może usunąć bibliotekę. Hosting: przykładowe nagłówki pamięci podręcznej nie oznaczają już plików bez skrótów w nazwach jako", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "sprawdzanie obecności oryginałów odbywa się w wątku roboczym (miniatury siatki, panel Informacje, menu zdjęcia, Brakujące", + "with a counterpart here come along, the rest is listed.": "z odpowiednikami w tym programie również zostają przeniesione, pozostałe są wypisane.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "zapisana przez nowszą wersję LightCraft zostaje odrzucona z komunikatem „ta biblioteka została zapisana przez nowszą wersję LightCraft” i pozostawiona", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "zapisana przez nowszą wersję LightCraft), LightCraft wyjaśnia przyczynę i proponuje Spróbuj ponownie, Wybierz inną bibliotekę… lub Kontynuuj", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Ta wersja programu nie może jeszcze pobrać modelu. Aby zainstalować go ręcznie, umieść pliki model.safetensors, vocab.json i merges.txt od Meta (facebook/sam3) w powyższym folderze: funkcje Obiekt i Opis zadziałają, gdy pliki się tam znajdą.", + "denoiseModel.accept": "denoiseModel.accept" +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..998a1fcbe 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -126,6 +126,7 @@ language_table! { Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -667,6 +668,7 @@ mod tests { ("app.language.german", Locale::De), ("app.language.russian", Locale::Ru), ("app.language.ukrainian", Locale::Uk), + ("app.language.polish", Locale::Pl), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a46eb98..f35eb245c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -46,6 +47,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { "app.language.german" => Some(crate::i18n::Locale::De), "app.language.russian" => Some(crate::i18n::Locale::Ru), "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.polish" => Some(crate::i18n::Locale::Pl), _ => None, } } diff --git a/packaging/crates/ui-egui/locales/pl-formats.json b/packaging/crates/ui-egui/locales/pl-formats.json new file mode 100644 index 000000000..8bdf25420 --- /dev/null +++ b/packaging/crates/ui-egui/locales/pl-formats.json @@ -0,0 +1,232 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} wśród kilku podobnych plików: użyj polecenia Zlokalizuj)", + " ({} in Recently Deleted)": " ({} w Ostatnio usuniętych)", + " · {:.0}% clipped": " · {:.0}% prześwietlonych obszarów", + " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", + " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", + " · {} duplicate{} skipped": " · pominięto {} duplikatów{}", + " · {} failed: {}": " · {} nieudanych: {}", + " · {} moved": " · {} przeniesionych", + " · {} not readable": " · {} nie można odczytać", + " · {} original{} left at the source": " · {} oryginalnych{} pozostawiono w źródle", + " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", + " — not carried over: {}{}": " — nie przeniesiono: {}{}", + "+{} more": "+{} więcej", + ", {failed} file{} not readable": ", nie można odczytać {failed} plików{}", + "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", + "; {outside} outside its time range": "; {outside} poza zakresem dat", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", + "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", + "Add {} Photo{}": "Dodaj {} zdjęć{}", + "Add {} Photo{}, restore {}": "Dodaj {} zdjęć{}, przywróć {}", + "Add “{}” to Favorites": "Dodaj „{}” do ulubionych", + "Added {n} photo{} to My Photos": "Dodano {n} zdjęć{} do Moich zdjęć", + "Added {n} photo{} to “{}”": "Dodano {n} zdjęć{} do „{}”", + "Added {what} to {name}": "Dodano {what} do {name}", + "Adding photos… {} of {}": "Dodawanie zdjęć… {} z {}", + "All {n} selected photos move by the same amount.": "Wszystkie {n} zaznaczone zdjęcia zostaną przesunięte o tę samą wartość.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Już w bibliotece, w Ostatnio usuniętych: zaznaczono {} zdjęć{} · kliknij prawym przyciskiem ▸ Przywróć albo Usuń trwale, aby zaimportować ponownie", + "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece: zaznaczono {} zdjęć{} we Wszystkich zdjęciach", + "Auto ({})": "Automatycznie ({})", + "Auto Import: added {} photo{}": "Automatyczny import: dodano {} zdjęć{}", + "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", + "Automatic ({} MB)": "Automatycznie ({} MB)", + "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", + "Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})", + "Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})", + "Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…", + "Click {}:1": "Kliknij {}:1", + "Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}", + "Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych", + "Creates {} stacks from {} of {scope}": "Tworzy {} stosów z {} w zakresie: {scope}", + "Decoder: {reason}": "Dekoder: {reason}", + "Delete \"{label}\"": "Usuń „{label}”", + "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", + "Delete {n} Photos": "Usuń {n} zdjęć", + "Delete “{current}”": "Usuń „{current}”", + "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", + "Done ({what}): {done} photo{plural}": "Gotowe ({what}): {done} zdjęć{plural}", + "Duplicate \"{label}\"": "Duplikuj „{label}”", + "Example: {example}": "Przykład: {example}", + "Exported {n} photo{}": "Wyeksportowano {n} zdjęć{}", + "Exported {ok} of {total} photo{}": "Wyeksportowano {ok} z {total} zdjęć{}", + "Exported {} point curve preset{}": "Wyeksportowano {} ustawień krzywej punktowej{}", + "Exported {} preset{}": "Wyeksportowano {} ustawień wstępnych{}", + "Exporting {} of {total}": "Eksportowanie {} z {total}", + "Filter bar — {active} active filter{}": "Pasek filtrów — {active} aktywnych filtrów{}", + "Focus {:.0}": "Ostrość {:.0}", + "Folder “{}” (and its subfolders)": "Folder „{}” (wraz z podfolderami)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Znaleziono {n} brakujących zdjęć{}; nadal brakuje {left}{unsure}", + "Healed {n} dust spot{}": "Usunięto {n} plamek kurzu{}", + "Import cancelled · {} photo{} added": "Import anulowany · dodano {} zdjęć{}", + "Import failed: {e}": "Import nieudany: {e}", + "Imported {n} point curve preset{}": "Zaimportowano {n} ustawień krzywej punktowej{}", + "Imported {n} preset{}": "Zaimportowano {n} ustawień wstępnych{}", + "Imported {n} preset{}, {f} file{} not readable": "Zaimportowano {n} ustawień wstępnych{}, nie można odczytać {f} plików{}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Zaimportowano {profiles} profili{} (Przeglądarka profili ▸ odpowiednie grupy)", + "Imported {} photo{}": "Zaimportowano {} zdjęć{}", + "Insert at the cursor (also written {})": "Wstaw w miejscu kursora (zostanie też zapisane {})", + "Label Set: {name}": "Zestaw etykiet: {name}", + "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących", + "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}", + "Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.", + "Matches: {}": "Dopasowania: {}", + "Merging… {stage} {:.0}%": "Scalanie… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Przenieść {what} do Ostatnio usuniętych?", + "Named ({named_n})": "Nazwane ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Przypisywanie „{k}”: klikaj zdjęcia, aby dodać lub usunąć słowo kluczowe · Esc kończy", + "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do {n} zdjęć{} — wklejone zostaną tylko skopiowane ustawienia", + "Preset: {name}": "Ustawienie wstępne: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Tylko podgląd — edycja pliku JPEG osadzonego przez aparat ({})", + "Preview only — {}": "Tylko podgląd — {}", + "Previews ready for {done} photo{plural}": "Podglądy gotowe dla {done} zdjęć{plural}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Kopia próbna ({})", + "Proof Preview · {}": "Podgląd próbny · {}", + "Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć", + "Quick Develop: {name}": "Szybka obróbka: {name}", + "Rated {}": "Ocena: {}", + "Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}", + "Reading photos… {done} of {total}": "Odczytywanie zdjęć… {done} z {total}", + "Reading photos… {} of {}": "Odczytywanie zdjęć… {} z {}", + "Redo {}": "Ponów {}", + "Remove “{name}” and its {count} photo{} from the library?": "Usunąć „{name}” i jego {count} zdjęć{} z biblioteki?", + "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", + "Removed from {}": "Usunięto z {}", + "Removed {what} from {name}": "Usunięto {what} z {name}", + "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", + "Rename “{name}”": "Zmień nazwę „{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na {n} zdjęciach{} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", + "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", + "Replace {} with:": "Zamień {} na:", + "Reset {name}": "Wyzeruj {name}", + "Reset {} Channel": "Wyzeruj kanał {}", + "Restore {}": "Przywróć {}", + "Restore {} Photo{}": "Przywróć {} zdjęć{}", + "Saved export preset “{}”": "Zapisano ustawienie eksportu „{}”", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Ocenia ostrość i ekspozycję zdjęć z zakresu {scope} oraz znajduje podobne ujęcia wykonane w odstępie kilku sekund (serie).", + "Show {hidden} hidden location{}": "Pokaż {hidden} ukrytych lokalizacji{}", + "Showing {n} of {}": "Wyświetlono {n} z {}", + "Smart album: {}": "Album inteligentny: {}", + "Tagged {n} photo{} from the tracklog": "Dodano geotagi do {n} zdjęć{} na podstawie śladu GPS", + "The SAM 3 download failed: {e}": "Nie udało się pobrać SAM 3: {e}", + "Undo {}": "Cofnij {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Nieznane znaczniki: {s} {} pozostaną{} w wpisanej postaci (zobacz Znaczniki)", + "Updated “{name}”": "Zaktualizowano „{name}”", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", + "Version: {}": "Wersja: {}", + "Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}", + "Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…", + "none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}", + "the {n} selected photos": "{n} zaznaczonych zdjęć", + "the {} photos in view": "{} zdjęć w bieżącym widoku", + "these {count} photos": "te {count} zdjęcia", + "{count} smart preview{} · {:.1} MB": "{count} podglądów inteligentnych{} · {:.1} MB", + "{f} folders (and their subfolders)": "{f} folderów (wraz z podfolderami)", + "{label} Grading": "Gradacja: {label}", + "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", + "{label} {index} of {n}": "{label} {index} z {n}", + "{label}, {n} photos": "{label}, {n} zdjęć", + "{matching} of {t} photos": "{matching} z {t} zdjęć", + "{matching} photos": "{matching} pasujących zdjęć", + "{mode} spot {} of {}": "{mode}: plamka {} z {}", + "{month}": "{month}", + "{month} {year}": "{month} {year}", + "{msg} ({failed} failed)": "{msg} (nieudanych: {failed})", + "{name} Label": "Etykieta {name}", + "{name} Off": "{name}: wyłączone", + "{name} On": "{name}: włączone", + "{name} opened for editing; it is stacked with the original": "{name} otwarto do edycji; kopia znajduje się w stosie z oryginałem", + "{name} — double-click to reset it": "{name} — kliknij dwukrotnie, aby wyzerować", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nie można zapisać {n} zmian{} na dysku: {e}\nJeśli teraz zamkniesz program, utracisz {}.", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu oznacza utratę {}.", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e} — LightCraft ponowi próbę", + "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", + "{n} photos": "{n} zdjęć", + "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", + "{n} photo{}": "{n} zdjęć{}", + "{n} photo{} match · updates automatically as photos change": "{n} pasujących zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", + "{n} photo{} now · updates automatically as photos change": "Obecnie {n} zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", + "{pid} (missing)": "{pid} (brak)", + "{sel_n} selected · {counted}": "Zaznaczono {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} zdjęć{}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", + "{what} merge added": "Dodano po scaleniu: {what}", + "{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}", + "{} and higher": "{} i nowsze", + "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", + "{} failed": "{} nieudanych", + "{} files": "{} plików", + "{} files and {f} folder{}": "{} plików i {f} folderów{}", + "{} item{}": "{} elementów{}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", + "{} photos": "{} zdjęć", + "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Nie można odczytać {} zdjęć{}", + "{} sample{}": "{} próbek{}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} plamek na tym zdjęciu", + "{} — show their photos": "{} — pokaż ich zdjęcia", + "· {dups} already in the library": "· {dups} już w bibliotece", + "· {n} photo{}": "· {n} zdjęć{}", + "… and {more} more": "… i jeszcze {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", + "Filtered: {label}": "Filtrowanie: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} podobnych zdjęć{} · Widok ▸ Wyczyść filtry, aby zobaczyć wszystkie", + "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", + "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", + ", {} star{}": ", {} gwiazdek{}", + ", {} label": ", {} etykieta", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Pobieranie z {host}. Po pobraniu i weryfikacji LightCraft zainstaluje plik, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Działa na procesorze: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Przechowywane w folderze „denoise” biblioteki; po osiągnięciu limitu najpierw usuwane są najstarsze pliki nieużywane przez żadne zdjęcie.", + "Denoising {name}: {done} of {total} tiles{}": "Odszumianie {name}: {done} z {total} kafelków{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} z {} z serwera {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Wytrenowano na: {provenance}", + " · {queued} waiting": " · {queued} oczekuje", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.", + "In line for AI Denoise ({ahead} ahead)…": "W kolejce do odszumiania AI ({ahead} przed tym zadaniem)…", + "Running on {}.": "Działa na {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Umieść wewnątrz „{parent}”", + "Delete the keyword “{name}”?": "Usunąć słowo kluczowe „{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Zostanie usunięte z {count} zdjęć{}, razem z podrzędnymi słowami kluczowymi. Możesz to cofnąć.", + "Create Keyword Tag Inside “{name}”…": "Utwórz podrzędne słowo kluczowe w „{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}” zawiera już „{name}”. Scalić oba?", + "There is a “{name}” at the top level already. Merge the two?": "Słowo kluczowe „{name}” istnieje już na najwyższym poziomie. Scalić oba?", + "Added “{keyword}” to {n} photo{}": "Dodano „{keyword}” do {n} zdjęć{}", + "On {have} of {of} selected photos": "Na {have} z {of} zaznaczonych zdjęć", + "Remove “{name}”": "Usuń „{name}”", + "There is a keyword set “{name}” already": "Istnieje już zestaw słów kluczowych „{name}”", + "Added {n} keyword{}": "Dodano {n} słów kluczowych{}", + "; {n} gained synonyms": "; {n} otrzymało synonimy", + "Exported {n} keyword{}": "Wyeksportowano {n} słów kluczowych{}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One nie zaimportuje listy zawierającej ; , < lub > w słowie kluczowym: {names}", + " — left out, as a keyword list can't hold them: {names}": " — pominięto, ponieważ lista słów kluczowych ich nie obsługuje: {names}", + "{done} of {total}": "{done} z {total}", + "{done} of {total} MB": "{done} z {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} więcej", + "1 task is still running: {names}. Quitting stops it.": "Wciąż trwa 1 zadanie: {names}. Zamknięcie programu je zatrzyma.", + "{n} tasks are still running: {names}. Quitting stops them.": "Wciąż trwają {n} zadania: {names}. Zamknięcie programu je zatrzyma.", + "{what} merge cancelled": "Scalanie anulowane: {what}" +} diff --git a/packaging/crates/ui-egui/locales/pl.json b/packaging/crates/ui-egui/locales/pl.json new file mode 100644 index 000000000..f69e22643 --- /dev/null +++ b/packaging/crates/ui-egui/locales/pl.json @@ -0,0 +1,2043 @@ +{ + " (library default)": " (domyślne ustawienie biblioteki)", + " (inverted)": " (odwrócona)", + " · best of its burst": " · najlepsze zdjęcie z serii", + " · in a burst": " · w serii zdjęć", + "% of all commits": "% wszystkich zatwierdzonych zmian", + "+ Group": "+ Grupa", + "+ Rule": "+ Reguła", + "0: nothing is rejected.": "0: żadne zdjęcie nie jest odrzucane.", + "1 day": "1 dzień", + "= exactly": "= dokładnie", + "A blank template keeps the original names.": "Pusty szablon zachowuje oryginalne nazwy.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Filtr ukrywa zdjęcia w tym widoku: zmień go lub wyczyść (Widok → Wyczyść filtry)", + "A one-time download of about": "Jednorazowe pobranie około", + "AI Mask Model Status": "Stan modelu masek AI", + "About": "O programie", + "About LightCraft": "O LightCraft", + "Active": "Aktywny", + "Activity": "Aktywność", + "Actual size": "Rzeczywisty rozmiar", + "Add": "Dodaj", + "Add Catchlight": "Dodaj błysk w oku", + "Add Folder to Local": "Dodaj folder do lokalnych", + "Add Mask": "Dodaj maskę", + "Add Object Click": "Dodaj zaznaczenie obiektu kliknięciem", + "Add Point Color Sample": "Dodaj próbkę koloru punktowego", + "Add Red Eye Correction": "Dodaj korektę czerwonych oczu", + "Add Remove Spot": "Dodaj usuwanie plamki", + "Add Selected Photos": "Dodaj zaznaczone zdjęcia", + "Add a rule (⌥-click: a group)": "Dodaj regułę (⌥ + kliknięcie: grupa)", + "Add in place": "Dodaj bez kopiowania plików", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.", + "Add keyword": "Dodaj słowo kluczowe", + "Add number": "Dodaj numer", + "Add to": "Dodaj do", + "Add to Album": "Dodaj do albumu", + "Add to Favorites": "Dodaj do ulubionych", + "Add to Local": "Dodaj do lokalnych", + "Add to My Photos": "Dodaj do Moich zdjęć", + "Add to Selected Photos": "Dodaj do zaznaczonych zdjęć", + "Add to Target Album": "Dodaj do albumu docelowego", + "Add to this mask": "Dodaj do tej maski", + "Add/Subtract/Intersect Mask": "Dodaj/Odejmij/Przetnij maskę", + "Added to photos you import that don't already have it.": "Dodawane do importowanych zdjęć, które jeszcze tego nie mają.", + "Adding": "Dodawanie", + "Adding Lightroom photos…": "Dodawanie zdjęć z Lightrooma…", + "Advanced search…": "Wyszukiwanie zaawansowane…", + "After": "Po", + "After 30 days": "Po 30 dniach", + "After 7 days": "Po 7 dniach", + "After 90 days": "Po 90 dniach", + "After a year": "Po roku", + "Album": "Album", + "Albums": "Albumy", + "All": "Wszystkie", + "All Metadata": "Wszystkie metadane", + "All Metadata…": "Wszystkie metadane…", + "All Photos": "Wszystkie zdjęcia", + "All rights reserved": "Wszelkie prawa zastrzeżone", + "All the photos imported from this disk are included.": "Uwzględniono wszystkie zdjęcia zaimportowane z tego dysku.", + "All types": "Wszystkie typy", + "Alt Text": "Tekst alternatywny", + "Always": "Zawsze", + "Amount": "Ilość", + "Analyzing the photo for AI masks…": "Analizowanie zdjęcia pod kątem masek AI…", + "Any Searchable Text": "Dowolny tekst do wyszukania", + "Aperture": "Przysłona", + "Application": "Aplikacja", + "Apply": "Zastosuj", + "Apply Color Label Set": "Zastosuj zestaw etykiet kolorów", + "Apply Filter Preset": "Zastosuj ustawienia wstępne filtra", + "Apply Metadata Preset": "Zastosuj ustawienia wstępne metadanych", + "Apply Point Curve Preset": "Zastosuj ustawienia wstępne krzywej punktowej", + "Apply Preset": "Zastosuj ustawienie wstępne", + "Apply Settings JSON": "Zastosuj ustawienia JSON", + "Approved": "Zatwierdzone", + "Aqua": "Turkusowy", + "Aqua Hue": "Odcień turkusowego", + "Aqua Luminance": "Jasność turkusowego", + "Aqua Saturation": "Nasycenie turkusowego", + "Arrange Panel": "Rozmieść panel", + "ArtCraft Website": "Witryna ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — więcej aplikacji kreatywnych", + "As Shot": "Jak na zdjęciu", + "As shot": "Jak zrobiono zdjęcie", + "Ascending": "Rosnąco", + "Aspect": "Proporcje", + "Aspect Ratio": "Proporcje obrazu", + "Assisted Culling": "Wspomagana selekcja", + "Assisted Culling…": "Wspomagana selekcja…", + "Auto": "Automatycznie", + "Auto Advance": "Automatyczne przechodzenie dalej", + "Auto Advance Off": "Automatyczne przechodzenie dalej wyłączone", + "Auto Advance On": "Automatyczne przechodzenie dalej włączone", + "Auto Advance after rating or flagging": "Przechodź dalej po ocenie lub oznaczeniu", + "Auto Advance: move to the next photo after rating or flagging": "Automatyczne przejście: przejdź do następnego zdjęcia po nadaniu oceny lub oznaczeniu flagą", + "Auto Align": "Automatyczne wyrównanie", + "Auto B&W Mix": "Automatyczne mieszanie czerni i bieli", + "Auto Crop": "Automatyczne kadrowanie", + "Auto Import": "Automatyczny import", + "Auto Import Now": "Importuj automatycznie teraz", + "Auto Import Settings": "Automatyczne ustawienia importu", + "Auto Mask": "Automatyczna maska", + "Auto Select": "Automatyczny wybór", + "Auto Settings": "Ustawienia automatyczne", + "Auto Straighten": "Automatyczne prostowanie", + "Auto Sync": "Automatyczna synchronizacja", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatyczna synchronizacja: zmiany obejmują wszystkie zaznaczone zdjęcia. Automatyczne mieszanie czerni i bieli. Automatyczne wersje.", + "Auto settings applied": "Zastosowano ustawienia automatyczne", + "Auto-Stack by Capture Time": "Automatycznie grupuj według czasu wykonania", + "Auto-Stack by Capture Time…": "Automatycznie grupuj według czasu wykonania…", + "Auto-Tag Photos from Tracklog": "Automatycznie oznacz zdjęcia na podstawie śladu GPS", + "Auto-Tag from Tracklog": "Automatycznie oznacz na podstawie śladu GPS", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatyczne tagowanie ze śladu: przypisuje zdjęciom pozycje GPS ze śladu GPX na podstawie czasu wykonania.", + "Auto-Tag from Tracklog…": "Automatycznie oznacz na podstawie śladu GPS…", + "Automatic": "Automatycznie", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Tryb automatyczny renderuje widok szczegółów w wyświetlanym rozmiarze, nie przekraczając rozdzielczości zdjęcia. Wybierz mniejszy limit, aby przyspieszyć działanie.", + "Automatically Write Changes into XMP": "Automatycznie zapisuj zmiany w XMP", + "Automatically write changes into XMP sidecars": "Automatycznie zapisuj zmiany w plikach pomocniczych XMP", + "Autumn Gold": "Jesienne złoto", + "B&W": "Czarno-białe", + "B&W Mix": "Mieszanie czerni i bieli", + "B&W Mixer": "Mikser czerni i bieli", + "Back": "Wstecz", + "Back Up Library…": "Utwórz kopię zapasową biblioteki…", + "Back to Edit": "Wróć do edycji", + "Back to Edit (E)": "Wróć do edycji (E)", + "Back to Grid": "Wróć do siatki", + "Background": "Tło", + "Balance": "Balans", + "Basic": "Podstawowe", + "Before": "Przed", + "Before/After (Y)": "Przed/Po (Y)", + "Before/After Settings": "Ustawienia porównania przed/po", + "Before/After Split": "Podział przed/po", + "Before/After Split Top/Bottom": "Podział przed/po góra/dół", + "Before/After Top/Bottom": "Przed/Po góra/dół", + "Begin Interaction": "Rozpocznij interakcję", + "Best of Similar Shots": "Najlepsze spośród podobnych ujęć", + "Binary assets added": "Dodano zasoby binarne", + "Binary assets removed": "Usunięto zasoby binarne", + "Bit depth": "Głębia bitowa", + "Black & White": "Czarno-białe", + "Blacks": "Czernie", + "Bleach Bypass": "Pominięcie wybielania", + "Bleached": "Wyblakły", + "Blending": "Mieszanie", + "Blue": "Niebieski", + "Blue Hour Boost": "Wzmocnienie błękitnej godziny", + "Blue Hue": "Odcień niebieskiego", + "Blue Luminance": "Luminancja niebieskiego", + "Blue Primary": "Podstawowy niebieski", + "Blue Saturation": "Nasycenie niebieskiego", + "Blue channel": "Kanał niebieski", + "Blue label": "Niebieska etykieta", + "Blue/Yellow Fringe": "Niebiesko-żółte obwódki", + "Blurry photos below the score are flagged as rejects.": "Nieostre zdjęcia z oceną poniżej progu są oznaczane jako odrzucone.", + "Boundary Warp": "Dopasowanie granic", + "Bright & Airy": "Jasne i lekkie", + "Bring All to Front": "Przenieś wszystko na wierzch", + "Browse Folder": "Przeglądaj folder", + "Browse Folder…": "Przeglądaj folder…", + "Browse Profiles": "Przeglądaj profile", + "Browse…": "Przeglądaj…", + "Brush": "Pędzel", + "Brush Settings": "Ustawienia pędzla", + "Brush Stroke": "Pociągnięcie pędzla", + "Build Previews": "Utwórz podglądy", + "Build Smart Previews": "Utwórz podglądy inteligentne", + "By Date": "Według daty", + "By day (YYYY/YYYY-MM-DD)": "Według dnia (RRRR/RRRR-MM-DD)", + "By month (YYYY/YYYY-MM)": "Według miesiąca (RRRR/RRRR-MM)", + "CPU threads": "Wątki procesora", + "Calibration": "Kalibracja", + "Camera": "Aparat", + "Camera make and model": "Marka i model aparatu", + "Cameras and Cards": "Aparaty i karty pamięci", + "Cancel": "Anuluj", + "Cancel AI Mask Model Download": "Anuluj pobieranie modelu masek AI", + "Cancel Download": "Anuluj pobieranie", + "Cancel Interaction": "Anuluj interakcję", + "Cancel Preview Build": "Anuluj tworzenie podglądu", + "Candidate": "Kandydat", + "Caption": "Podpis", + "Capture Date": "Data wykonania zdjęcia", + "Capture date and time in your own format (directives below)": "Data i godzina wykonania zdjęcia we własnym formacie (dyrektywy poniżej)", + "Capture date as YYYYMMDD": "Data wykonania jako RRRRMMDD", + "Captured": "Wykonano", + "Catalog Statistics": "Statystyki katalogu", + "Catchlight": "Błysk w oku", + "Change": "Zmień", + "Change Component Mode": "Zmień tryb składnika", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Znaki niedozwolone w nazwach plików (/ \\ : * ? \" < > |) są zastępowane myślnikiem; jeśli nazwa już istnieje, dodawane są przyrostki -1, -2….", + "Check All": "Zaznacz wszystko", + "Check Output Path": "Sprawdź ścieżkę wyjściową", + "Check the files of this type": "Sprawdź pliki tego typu", + "Checking names…": "Sprawdzanie nazw…", + "Choose Another Library…": "Wybierz inną bibliotekę…", + "Choose Edit Settings to Copy": "Wybierz ustawienia edycji do skopiowania", + "Choose Edit Settings to Copy…": "Wybierz ustawienia edycji do skopiowania…", + "Choose Folder…": "Wybierz folder…", + "Choose a photo in the grid or filmstrip": "Wybierz zdjęcie w siatce lub na pasku zdjęć", + "Choose…": "Wybierz…", + "Cinematic": "Filmowy", + "City": "Miasto", + "Clarity": "Przejrzystość", + "Clean Studio": "Czyste studio", + "Clear": "Wyczyść", + "Clear Cache": "Wyczyść pamięć podręczną", + "Clear Filters": "Wyczyść filtry", + "Clear Guides": "Wyczyść prowadnice", + "Clear History": "Wyczyść historię", + "Clear Preview Cache": "Wyczyść pamięć podręczną podglądów", + "Clear Quick Collection": "Wyczyść szybką kolekcję", + "Clear Reference": "Wyczyść zdjęcie referencyjne", + "Clear all": "Wyczyść wszystko", + "Clear form": "Wyczyść formularz", + "Clear rating": "Usuń ocenę", + "Clear rating and advance": "Usuń ocenę i przejdź dalej", + "Cleared History": "Wyczyszczono historię", + "Click Zoom Ratio": "Współczynnik powiększenia po kliknięciu", + "Click a shortcut, then press the new keys (Esc cancels).": "Kliknij skrót, a następnie naciśnij nowe klawisze (Esc anuluje).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Kliknij znacznik, aby go wstawić. Przykłady dotyczą pliku IMG_0042.CR3 z 14 stycznia 2026 r., godz. 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klikaj zdjęcia w siatce, aby przełączać słowo kluczowe; Esc kończy", + "Click the object to select it · ⌥-click leaves a part out": "Kliknij obiekt, aby go zaznaczyć · ⌥-kliknięcie wyklucza fragment", + "Click the photo to pick a colour · ⇧-click adds more": "Kliknij zdjęcie, aby pobrać kolor · ⇧-kliknięcie dodaje kolejne", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknij zdjęcie, aby pobrać kolor; ⇧-kliknięcie dodaje kolejne (maks. 5)", + "Click to clear these filters": "Kliknij, aby wyczyścić te filtry", + "Click to restore · double-click to rename": "Kliknij, aby przywrócić · kliknij dwukrotnie, aby zmienić nazwę", + "Clone": "Klonuj", + "Close": "Zamknij", + "Close Panel": "Zamknij panel", + "Close Window": "Zamknij okno", + "Close all": "Zamknij wszystko", + "Close file": "Zamknij plik", + "Close panel": "Zamknij panel", + "Cloudy": "Pochmurno", + "Collapse": "Zwiń", + "Collapse All Stacks": "Zwiń wszystkie stosy", + "Collapse Stack": "Zwiń stos", + "Color": "Kolor", + "Color Grading": "Gradacja kolorów", + "Color Label": "Etykieta kolorystyczna", + "Color Label Names": "Nazwy etykiet kolorów", + "Color Label Sets": "Zestawy etykiet kolorów", + "Color Mixer": "Mikser kolorów", + "Color Noise Reduction": "Redukcja szumu koloru", + "Color Priority": "Priorytet kolorów", + "Color Range": "Zakres kolorów", + "Color label cleared": "Usunięto etykietę koloru", + "Color space": "Przestrzeń barw", + "Color space: sRGB (AVIF)": "Przestrzeń kolorów: sRGB (AVIF)", + "Colors": "Kolory", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtrowanie według kilku etykiet kolorystycznych jednocześnie; rozwijane drzewo folderów w widoku lokalnym.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Maski zakresu barw: kliknij zdjęcie, aby pobrać próbkę. Zakresy luminancji: pasek zakresu, płynność przejścia, mapa luminancji.", + "Combine files…": "Połącz pliki…", + "Command Journal": "Dziennik poleceń", + "Comments panel": "Panel komentarzy", + "Commits": "Zatwierdzone zmiany", + "Company": "Firma", + "Compare": "Porównaj", + "Compare (Shift+C)": "Porównaj (Shift+C)", + "Compare Before and After": "Porównaj przed i po", + "Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć", + "Component options": "Opcje składnika", + "Compression": "Kompresja", + "Concrete Cool": "Chłodny beton", + "Confirm before deleting": "Potwierdzaj przed usunięciem", + "Confirm before moving photos to Recently Deleted": "Potwierdzaj przeniesienie zdjęć do ostatnio usuniętych", + "Constrain Crop": "Ogranicz kadrowanie", + "Continue": "Kontynuuj", + "Continue Without Saving": "Kontynuuj bez zapisywania", + "Contrast": "Kontrast", + "Contributors": "Współtwórcy", + "Convert to DNG": "Konwertuj do DNG", + "Cool Fade": "Chłodne wyblaknięcie", + "Cool Morning": "Chłodny poranek", + "Copy": "Kopiuj", + "Copy After's Settings to Before": "Skopiuj ustawienia „Po” do „Przed”", + "Copy Before to After": "Skopiuj „Przed” do „Po”", + "Copy Before's Settings to After": "Skopiuj ustawienia „Przed” do „Po”", + "Copy Edit Settings": "Kopiuj ustawienia edycji", + "Copy History Step Settings to Before": "Skopiuj ustawienia kroku historii do „Przed”", + "Copy Metadata": "Kopiuj metadane", + "Copy as DNG": "Kopiuj jako DNG", + "Copy into the library (else use the files where they are)": "Kopiuj do biblioteki (w przeciwnym razie używaj plików w ich lokalizacji)", + "Copy pages": "Kopiuj strony", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopiuj pliki (do folderu Originals/ biblioteki lub wybranego folderu)", + "Copy to": "Kopiuj do", + "Copy to Clipboard": "Kopiuj do schowka", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopiuj: pliki zostaną skopiowane do poniższego folderu, a biblioteka będzie używać kopii; oryginały pozostaną bez zmian.", + "Copyright": "Prawa autorskie", + "Copyright Info URL": "Adres URL informacji o prawach autorskich", + "Copyright Status": "Status praw autorskich", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Stan praw autorskich, warunki wykorzystania i adres URL informacji o prawach autorskich w informacjach, ustawieniach wstępnych metadanych i eksporcie.", + "Country": "Kraj", + "Create Album": "Utwórz album", + "Create Album…": "Utwórz album…", + "Create Folder": "Utwórz folder", + "Create Folder…": "Utwórz folder…", + "Create New Mask": "Utwórz nową maskę", + "Create PDF from clipboard": "Utwórz PDF ze schowka", + "Create PDF from file…": "Utwórz PDF z pliku…", + "Create PDF from images…": "Utwórz PDF z obrazów…", + "Create Preset": "Utwórz ustawienie wstępne", + "Create Preset…": "Utwórz ustawienie wstępne…", + "Create Proof Copy": "Utwórz kopię próbną", + "Create Smart Album": "Utwórz inteligentny album", + "Create Smart Album from Filter…": "Utwórz album inteligentny z filtra…", + "Create Smart Album…": "Utwórz album inteligentny…", + "Create Stack": "Utwórz stos", + "Create Version": "Utwórz wersję", + "Create Version from Step": "Utwórz wersję z kroku", + "Create Virtual Copy": "Utwórz kopię wirtualną", + "Creator": "Twórca", + "Creator metadata": "Metadane twórcy", + "Crisp Landscape": "Wyrazisty krajobraz", + "Crisp Vista": "Wyrazisty widok", + "Crop": "Kadruj", + "Crop & Rotate": "Kadrowanie i obrót", + "Crop & Rotate (C)": "Kadruj i obracaj (C)", + "Crop Aspect": "Proporcje kadrowania", + "Crop, Rotate, Geometry": "Kadrowanie, obrót, geometria", + "Cross Process": "Obróbka krzyżowa", + "Culling": "Selekcja zdjęć", + "Curve": "Krzywa", + "Custom": "Niestandardowe", + "Custom template…": "Szablon niestandardowy…", + "Custom…": "Niestandardowe…", + "Cut": "Wytnij", + "Cut pages": "Wytnij strony", + "Cycle Crop Overlay": "Przełącz nakładkę kadrowania", + "Cycle Crop Overlay Orientation": "Przełącz orientację nakładki kadrowania", + "Cycle Info Overlay": "Przełącz nakładkę informacji", + "Cycle Mask Overlay Color": "Przełącz kolor nakładki maski", + "Cycle Mask Overlay Mode": "Przełącz tryb nakładki maski", + "Cylindrical": "Cylindryczny", + "Dark & Moody": "Ciemny i nastrojowy", + "Darken": "Przyciemnij", + "Darks": "Ciemne tony", + "Date": "Data", + "Date Groups": "Grupy dat", + "Day": "Dzień", + "Daylight": "Światło dzienne", + "Decrease Brush Feather": "Zmniejsz miękkość pędzla", + "Decrease Brush Size": "Zmniejsz rozmiar pędzla", + "Deep Sky": "Głębokie niebo", + "Defringe": "Usuń obwódki barwne", + "Deghost Amount": "Intensywność usuwania duchów", + "Dehaze": "Usuń zamglenie", + "Delete": "Usuń", + "Delete (⌫)": "Usuń (⌫)", + "Delete Album": "Usuń album", + "Delete All Masks": "Usuń wszystkie maski", + "Delete Color Label Set": "Usuń zestaw etykiet kolorów", + "Delete Component": "Usuń składnik", + "Delete Export Preset": "Usuń ustawienie wstępne eksportu", + "Delete Filter Preset": "Usuń ustawienie wstępne filtra", + "Delete Imported Profile": "Usuń zaimportowany profil", + "Delete Keyword": "Usuń słowo kluczowe", + "Delete Keyword Set": "Usuń zestaw słów kluczowych", + "Delete Mask": "Usuń maskę", + "Delete Metadata Preset": "Usuń ustawienie wstępne metadanych", + "Delete Permanently": "Usuń trwale", + "Delete Photo": "Usuń zdjęcie", + "Delete Photos": "Usuń zdjęcia", + "Delete Point Color": "Usuń kolor punktowy", + "Delete Point Color Sample": "Usuń próbkę koloru punktowego", + "Delete Point Curve Preset": "Usuń ustawienie wstępne krzywej punktowej", + "Delete Preset": "Usuń ustawienie wstępne", + "Delete Red Eye": "Usuń czerwone oczy", + "Delete Red Eye Correction": "Usuń korektę czerwonych oczu", + "Delete Spot": "Usuń plamkę", + "Delete Version": "Usuń wersję", + "Delete all": "Usuń wszystko", + "Delete all spots": "Usuń wszystkie plamki", + "Delete mask": "Usuń maskę", + "Delete them": "Usuń je", + "Density": "Gęstość", + "Depth Range": "Zakres głębi", + "Descending": "Malejąco", + "Describe": "Opisz", + "Describe what to keep": "Opisz, co zachować", + "Describe what to leave out": "Opisz, co wykluczyć", + "Describe what to select": "Opisz, co zaznaczyć", + "Describe…": "Opisz…", + "Deselect": "Odznacz", + "Deselect All": "Odznacz wszystko", + "Desert Heat": "Pustynny żar", + "Desert Warmth": "Pustynne ciepło", + "Desktop": "Pulpit", + "Detail": "Szczegóły", + "Detail (D)": "Szczegóły (D)", + "Details": "Szczegóły", + "Diagonal": "Przekątna", + "Discard Preview Cache": "Odrzuć pamięć podręczną podglądów", + "Display P3": "Display P3", + "Display name": "Nazwa wyświetlana", + "Display theme": "Motyw wyświetlania", + "Distortion": "Zniekształcenie", + "Document properties…": "Właściwości dokumentu…", + "Don't enlarge": "Nie powiększaj", + "Done": "Gotowe", + "Download": "Pobierz", + "Download AI Mask Model": "Pobierz model masek AI", + "Download the SAM 3 Model?": "Pobrać model SAM 3?", + "Downloads": "Pobrane", + "Drag along the horizon; double-click for Auto": "Przeciągnij wzdłuż horyzontu; kliknij dwukrotnie, aby użyć trybu automatycznego", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Przeciągnij po oku na zdjęciu; źrenica zostanie wykryta automatycznie.", + "Draw Guides": "Rysuj prowadnice", + "Duplicate": "Duplikuj", + "Duplicate Component": "Duplikuj składnik", + "Duplicate Mask": "Duplikuj maskę", + "Duplicate and Invert Mask": "Duplikuj i odwróć maskę", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Każdy znak / rozpoczyna kolejny poziom folderu; znaczniki są uzupełniane osobno dla każdego zdjęcia (brakujące metadane oznaczają „nieznane”).", + "Edge": "Krawędź", + "Edit": "Edytuj", + "Edit (E)": "Edytuj (E)", + "Edit Capture Time": "Edytuj czas wykonania", + "Edit Capture Time…": "Edytuj czas wykonania…", + "Edit Color Label Names": "Edytuj nazwy etykiet kolorów", + "Edit Color Label Names…": "Edytuj nazwy etykiet kolorów…", + "Edit Copy for External Editor": "Edytuj kopię w edytorze zewnętrznym", + "Edit Date": "Edytuj datę", + "Edit Info": "Edytuj informacje", + "Edit Label Names": "Edytuj nazwy etykiet", + "Edit Label Names…": "Edytuj nazwy etykiet…", + "Edit Mask": "Edytuj maskę", + "Edit Mask Component": "Edytuj składnik maski", + "Edit Sections": "Edytuj sekcje", + "Edit Smart Album": "Edytuj album inteligentny", + "Edit Smart Album…": "Edytuj album inteligentny…", + "Edit Spot": "Edytuj plamkę", + "Edit in External Editor": "Edytuj w edytorze zewnętrznym", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Edytuj w zewnętrznym edytorze (⇧⌘E): powstaje 16-bitowa kopia TIFF, dodana do stosu i odświeżana po powrocie.", + "Edit settings copied": "Skopiowano ustawienia edycji", + "Edited": "Edytowane", + "Edited or not": "Edytowane lub nie", + "Editing": "Edycja", + "Effect": "Efekt", + "Effects": "Efekty", + "Empty Recently Deleted": "Opróżnij ostatnio usunięte", + "Enable Lens Corrections": "Włącz korekcję obiektywu", + "Enable Profile Corrections": "Włącz korekcje profilu", + "End Interaction": "Zakończ interakcję", + "English": "Angielski", + "Enter Full Screen": "Przejdź na pełny ekran", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Wprowadź datę jako RRRR-MM-DD GG:MM:SS", + "Erase": "Wymaż", + "Every EXIF, GPS and XMP field in the file": "Każde pole EXIF, GPS i XMP w pliku", + "Existing previews": "Istniejące podglądy", + "Exit": "Zakończ", + "Expand": "Rozwiń", + "Expand All Stacks": "Rozwiń wszystkie stosy", + "Expand Stack": "Rozwiń stos", + "Expand/Collapse Stack": "Rozwiń/Zwiń stos", + "Export": "Eksportuj", + "Export Album with Preset": "Eksportuj album z ustawieniem wstępnym", + "Export Album…": "Eksportuj album…", + "Export As…": "Eksportuj jako…", + "Export Group…": "Eksportuj grupę…", + "Export Now": "Eksportuj teraz", + "Export Point Curve Presets": "Eksportuj ustawienia wstępne krzywej punktowej", + "Export Point Curve Presets…": "Eksportuj ustawienia wstępne krzywej punktowej…", + "Export Presets": "Eksportuj ustawienia wstępne", + "Export Presets…": "Eksportuj ustawienia wstępne…", + "Export User Presets…": "Eksportuj ustawienia wstępne użytkownika…", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Nazwy plików przy eksporcie używają znaczników Zmień nazwy zdjęć ({title}, {seq:2}, {date:%Y-%m-%d}…) oraz nowych: {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Export stopped unexpectedly": "Eksport został nieoczekiwanie przerwany", + "Export with Preset": "Eksportuj z ustawieniem wstępnym", + "Export with Previous": "Eksportuj z poprzednimi ustawieniami", + "Exporting": "Eksportowanie", + "Export…": "Eksportuj…", + "Exposure": "Ekspozycja", + "Exposure (shutter · aperture · ISO)": "Ekspozycja (migawka · przysłona · ISO)", + "Extended Description": "Opis rozszerzony", + "External Editor": "Edytor zewnętrzny", + "Face Boxes": "Ramki twarzy", + "Face boxes": "Ramki twarzy", + "Face names written to XMP by Lightroom and other apps show up here.": "Imiona osób zapisane w XMP przez Lightrooma i inne aplikacje pojawiają się tutaj.", + "Faded Matte": "Wyblakły mat", + "Faded Slide": "Wyblakły slajd", + "Faded Urban": "Wyblakłe miasto", + "Favorite Preset": "Ulubione ustawienie wstępne", + "Favorite Profile": "Ulubiony profil", + "Favorites": "Ulubione", + "Feather": "Miękkość krawędzi", + "File": "Plik", + "File & date": "Plik i data", + "File Format": "Format pliku", + "File Name": "Nazwa pliku", + "File Path": "Ścieżka pliku", + "File Size": "Rozmiar pliku", + "File Type": "Typ pliku", + "File name": "Nazwa pliku", + "File types:": "Typy plików:", + "Filename": "Nazwa pliku", + "Fill": "Wypełnij", + "Fill Edges": "Wypełnij krawędzie", + "Film": "Film", + "Filmstrip": "Pasek zdjęć", + "Filmstrip (/)": "Pasek zdjęć (/)", + "Filter": "Filtr", + "Filter Bar": "Pasek filtrów", + "Filter Presets": "Ustawienia wstępne filtrów", + "Filter bar": "Pasek filtrów", + "Filter fields": "Pola filtrów", + "Find Dust Spots": "Znajdź drobiny kurzu", + "Find Missing Photos": "Znajdź brakujące zdjęcia", + "Find Missing Photos…": "Znajdź brakujące zdjęcia…", + "Find Similar Photos": "Znajdź podobne zdjęcia", + "Find tools and commands…": "Znajdź narzędzia i polecenia…", + "Find…": "Znajdź…", + "First": "Pierwszy", + "First commit": "Pierwszy commit", + "Fit": "Dopasuj", + "Fit (F)": "Dopasuj (F)", + "Fit on Screen": "Dopasuj do ekranu", + "Fit to width": "Dopasuj do szerokości", + "Fit visible": "Dopasuj widoczne", + "Five stars": "Pięć gwiazdek", + "Fixed keys": "Stałe klawisze", + "Flag": "Oznacz", + "Flag as Pick": "Oznacz jako wybrane", + "Flag as Reject": "Oznacz jako odrzucone", + "Flagged as Pick": "Oznaczone jako wybrane", + "Flagged as Reject": "Oznaczone jako odrzucone", + "Flash": "Lampa błyskowa", + "Flip H": "Odbij w poziomie", + "Flip Horizontal": "Odbij poziomo", + "Flip Overlay (⇧O)": "Odwróć nakładkę (⇧O)", + "Flip V": "Odbij w pionie", + "Flip Vertical": "Odbij pionowo", + "Float panel": "Odłącz panel", + "Flow": "Przepływ", + "Fluorescent": "Światło fluorescencyjne", + "Focal Length": "Ogniskowa", + "Focal length": "Ogniskowa", + "Focus (assisted culling)": "Ostrość (wspomagana selekcja)", + "Folder": "Folder", + "Folder name (renamed on disk; its photos follow)": "Nazwa folderu (zmieniana na dysku wraz ze zdjęciami)", + "Folders": "Foldery", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Foldery i {date} używają daty wykonania; gdy zdjęcie jej nie ma, używana jest dzisiejsza data.", + "Food": "Jedzenie", + "Forget Unchanged Local Photos": "Zapomnij o niezmienionych zdjęciach lokalnych", + "Forget unchanged photos": "Zapomnij o niezmienionych zdjęciach", + "Form fields panel": "Panel pól formularza", + "Format": "Format", + "Forward": "Dalej", + "Frame time": "Czas klatki", + "Frame update": "Aktualizacja klatki", + "Free": "Wolne", + "Fresh & Bright": "Świeżo i jasno", + "Friday": "Piątek", + "Full": "Pełny", + "Full Screen Preview": "Podgląd pełnoekranowy", + "Full screen mode": "Tryb pełnoekranowy", + "Full size": "Pełny rozmiar", + "GPS Track Log": "Ślad GPS", + "GPU Rendering": "Renderowanie GPU", + "GPU rendering": "Renderowanie na GPU", + "General": "Ogólne", + "Generated demo photo": "Wygenerowane zdjęcie demonstracyjne", + "Geometry": "Geometria", + "Get Develop Settings": "Pobierz ustawienia wywoływania", + "Global Hue": "Globalny odcień", + "Global Luminance": "Globalna luminancja", + "Global Saturation": "Globalne nasycenie", + "Glossy": "Błyszczący", + "Go to History Step": "Przejdź do kroku historii", + "Golden": "Złocisty", + "Golden Glow": "Złota poświata", + "Golden Hour": "Złota godzina", + "Grab bag": "Zbiór różnych elementów", + "Grain": "Ziarno", + "Graphic": "Graficzny", + "Green": "Zielony", + "Green Amount": "Ilość zieleni", + "Green Hue": "Odcień zieleni", + "Green Hue High": "Górny zakres odcienia zieleni", + "Green Hue Low": "Dolny zakres odcienia zieleni", + "Green Luminance": "Luminancja zieleni", + "Green Primary": "Zieleń podstawowa", + "Green Saturation": "Nasycenie zieleni", + "Green channel": "Kanał zielony", + "Green label": "Zielona etykieta", + "Grid": "Siatka", + "Grid Info": "Informacje w siatce", + "Gritty Street": "Surowa ulica", + "Group": "Grupa", + "Group by Date: Automatic": "Grupowanie według daty: automatycznie", + "Group by Day": "Grupuj według dnia", + "Group by Month": "Grupuj według miesiąca", + "Group by Year": "Grupuj według roku", + "Group by date": "Grupuj według daty", + "Group into Stack": "Zgrupuj w stos", + "Group name": "Nazwa grupy", + "Guided": "Z prowadnicami", + "Guided Upright": "Korekcja perspektywy z prowadnicami", + "Guided Upright Guides": "Prowadnice korekcji perspektywy", + "HDR Merge": "Scalanie HDR", + "HDR Merge Preview": "Podgląd scalania HDR", + "HDR Panorama Merge": "Scal panoramę HDR", + "HDR Panorama Merge Preview": "Podgląd scalania panoramy HDR", + "HDR Panorama with Last Settings": "Panorama HDR z ostatnimi ustawieniami", + "HDR Panoramas": "Panoramy HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panoramy HDR", + "HDR with Last Settings": "HDR z ostatnimi ustawieniami", + "HDR…": "HDR…", + "Has Edits": "Ma edycje", + "Has GPS": "Ma dane GPS", + "Heal": "Korekta", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Usuń retuszem niewielkie, miękkie ciemne plamki pozostawione przez kurz na matrycy", + "Height": "Wysokość", + "Height (px)": "Wysokość (px)", + "Help": "Pomoc", + "Hide LightCraft": "Ukryj LightCraft", + "Hide Mask": "Ukryj maskę", + "Hide Others": "Ukryj pozostałe", + "Hides this shortcut only; the folder and its photos stay as they are": "Ukrywa tylko ten skrót; folder i zdjęcia pozostają bez zmian", + "High": "Wysoki", + "High Contrast B&W": "Czarno-biały o wysokim kontraście", + "Highlight": "Światła", + "Highlights": "Światła", + "Highlights Hue": "Odcień świateł", + "Highlights Luminance": "Luminancja świateł", + "Highlights Saturation": "Nasycenie świateł", + "Highlights split": "Podział świateł", + "Histogram": "Histogram", + "History": "Historia", + "History & Activity (Y)": "Historia i aktywność (Y)", + "Home": "Strona główna", + "Horizontal": "Poziomo", + "Horizontal text": "Tekst poziomy", + "Hours": "Godziny", + "Hue": "Odcień", + "Hue Range": "Zakres odcieni", + "ISO Speed": "Czułość ISO", + "ISO speed": "Czułość ISO", + "If file exists": "Jeśli plik istnieje", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Jeśli nie można otworzyć biblioteki przy uruchamianiu (jest używana w innym programie, nieczytelna lub znajduje się na odłączonym dysku,", + "Image": "Obraz", + "Import": "Importuj", + "Import Date": "Data importu", + "Import Lightroom Catalog": "Importuj katalog Lightrooma", + "Import Lightroom Catalog…": "Importuj katalog Lightrooma…", + "Import Photos": "Importuj zdjęcia", + "Import Photos…": "Importuj zdjęcia…", + "Import Point Curve Presets": "Importuj ustawienia krzywej punktowej", + "Import Point Curve Presets…": "Importuj ustawienia krzywej punktowej…", + "Import Presets": "Importuj ustawienia wstępne", + "Import Presets…": "Importuj ustawienia wstępne…", + "Import Profiles": "Importuj profile", + "Import Profiles & Presets…": "Importuj profile i ustawienia wstępne…", + "Import as new": "Importuj jako nowe", + "Import from Device": "Importuj z urządzenia", + "Import from Folder…": "Importuj z folderu…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importuj zdjęcia przez Plik → Importuj zdjęcia… (Cmd+Shift+I) lub przeciągnij je tutaj", + "Imported": "Zaimportowane", + "Imported Photos": "Zaimportowane zdjęcia", + "Importing": "Importowanie", + "Importing Lightroom Catalog": "Importowanie katalogu Lightrooma", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Importowanie wyszukuje zdjęcia w źródle; nie dodaje folderu do sekcji Lokalne (użyj Lokalne → Przeglądaj folder…, aby pracować w folderze bez importowania).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "W siatce zdjęć i siatce kwadratowej: oznacz jako wybrane i przejdź dalej", + "In Photo Grid and Square Grid: expand or collapse the stack": "W siatce zdjęć i siatce kwadratowej: rozwiń lub zwiń stos", + "In Recently Deleted": "W ostatnio usuniętych", + "In library": "W bibliotece", + "In memory — nothing is saved": "W pamięci — nic nie zapisano", + "In use": "W użyciu", + "In-memory session: these settings last until LightCraft quits.": "Sesja w pamięci: te ustawienia obowiązują do zamknięcia LightCraft.", + "Include subfolders": "Uwzględnij podfoldery", + "Increase Brush Feather": "Zwiększ miękkość pędzla", + "Increase Brush Size": "Zwiększ rozmiar pędzla", + "Info": "Informacje", + "Info (I)": "Informacje (I)", + "Info Overlay Off": "Wyłącz nakładkę informacyjną", + "Info Overlay: Exposure": "Nakładka informacyjna: ekspozycja", + "Info Overlay: File": "Nakładka informacyjna: plik", + "Info overlay": "Nakładka informacyjna", + "Insert at the cursor": "Wstaw w miejscu kursora", + "Inspect Lightroom Catalog": "Sprawdź katalog Lightrooma", + "Inspect Photo": "Sprawdź zdjęcie", + "Inspecting Lightroom Catalog": "Sprawdzanie katalogu Lightrooma", + "Instant ’70s": "Lata 70. od ręki", + "Interface": "Interfejs", + "Interface language": "Język interfejsu", + "Intersect": "Część wspólna", + "Intersect with": "Przetnij z", + "Into one folder": "Do jednego folderu", + "Invert": "Odwróć", + "Invert Component": "Odwróć składnik", + "Invert Mask": "Odwróć maskę", + "JPEG (Large)": "JPEG (duży)", + "JPEG (Small)": "JPEG (mały)", + "Join the ArtCraft Discord": "Dołącz do Discorda ArtCraft", + "Join the ArtCraft Discord…": "Dołącz do Discorda ArtCraft…", + "Join the ArtCraft community on Discord": "Dołącz do społeczności ArtCraft na Discordzie", + "Keep in Local": "Zachowaj w lokalnych", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Przechowuj ją na dysku z wolnym miejscem: podglądy inteligentne zajmują około 1 MB na zdjęcie. Pamięć podręczna miniatur pozostaje w bibliotece.", + "Keyboard Shortcuts": "Skróty klawiaturowe", + "Keyboard shortcuts": "Skróty klawiaturowe", + "Keyword": "Słowo kluczowe", + "Keyword Painter": "Pędzel słów kluczowych", + "Keyword Set": "Zestaw słów kluczowych", + "Keyword Sets": "Zestawy słów kluczowych", + "Keyword Suggestions": "Sugestie słów kluczowych", + "Keyword to paint": "Słowo kluczowe do nanoszenia", + "Keywords": "Słowa kluczowe", + "Keywords (K)": "Słowa kluczowe (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Dodane słowa kluczowe pojawiają się tutaj; skróty ⌥1–⌥9 przypisują je do zdjęć.", + "Label": "Etykieta", + "Landscape": "Krajobraz", + "Language": "Język", + "Last": "Ostatni", + "Last GPU fallback": "Ostatnie przełączenie awaryjne GPU", + "Last commit": "Ostatni zapis zmian", + "Last loupe render": "Ostatnie renderowanie lupy", + "Last view": "Ostatni widok", + "Lateral Chromatic Aberration": "Poprzeczna aberracja chromatyczna", + "Layer": "Warstwa", + "Layers": "Warstwy", + "Leave them": "Pozostaw je", + "Lens": "Obiektyw", + "Lens Blur": "Rozmycie obiektywu", + "Lens Corrections": "Korekcje obiektywu", + "Level": "Poziom", + "Library": "Biblioteka", + "Library Info": "Informacje o bibliotece", + "Library Originals": "Oryginały biblioteki", + "Library Preferences": "Preferencje biblioteki", + "Library State": "Stan biblioteki", + "Library saved": "Biblioteka zapisana", + "Licence:": "Licencja:", + "Light": "Światło", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 i starsze wersje odrzucają ten plik („nieobsługiwany format … v2”).", + "LightCraft Default": "Domyślne ustawienia LightCraft", + "LightCraft Help": "Pomoc LightCraft", + "LightCraft Preset": "Ustawienie wstępne LightCraft", + "LightCraft Presets": "Ustawienia wstępne LightCraft", + "LightCraft Website": "Strona LightCraft", + "LightCraft could not start": "Nie można uruchomić LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft nie znalazł miejsca na przechowywanie biblioteki.", + "LightCraft couldn't open the library at": "LightCraft nie mógł otworzyć biblioteki w lokalizacji", + "LightCraft on GitHub": "LightCraft w serwisie GitHub", + "LightCraft website": "Strona internetowa LightCraft", + "LightCraft — Second Window": "LightCraft — drugie okno", + "Lightroom Classic Catalog": "Katalog Lightroom Classic", + "Lightroom import cancelled": "Anulowano import z Lightrooma", + "Lightroom inspection cancelled": "Anulowano sprawdzanie Lightrooma", + "Lights": "Światła", + "Limit file size (KB, 0 = off)": "Limit rozmiaru pliku (KB, 0 = wyłączone)", + "Line delta": "Zmiana liczby wierszy", + "Linear": "Liniowy", + "Linear Gradient": "Gradient liniowy", + "Lines +/−": "Wiersze +/−", + "Lines added": "Dodane wiersze", + "Lines deleted": "Usunięte wiersze", + "List Albums": "Wyświetl albumy", + "List Develop Controls": "Wyświetl ustawienia wywoływania", + "List History": "Wyświetl historię", + "List Presets": "Wyświetl ustawienia predefiniowane", + "List Profiles": "Wyświetl profile", + "List this folder in Local from now on": "Od teraz pokazuj ten folder w widoku lokalnym", + "Local": "Lokalne", + "Local folders": "Foldery lokalne", + "Local library — no cloud account needed": "Biblioteka lokalna — konto w chmurze nie jest potrzebne", + "Locate Missing File…": "Znajdź brakujący plik…", + "Locate Photo": "Znajdź zdjęcie", + "Location": "Lokalizacja", + "Lock": "Zablokuj", + "Lock Crop Aspect": "Zablokuj proporcje kadrowania", + "Locked": "Zablokowane", + "Long Edge": "Dłuższy bok", + "Looking for photos…": "Wyszukiwanie zdjęć…", + "Lossless": "Bezstratny", + "Lossy DNG files and Smart Previews open as raw photos.": "Stratne pliki DNG i podglądy inteligentne otwierają się jako zdjęcia RAW.", + "Low": "Niski", + "Luminance": "Luminancja", + "Luminance Range": "Zakres luminancji", + "Lush Greens": "Soczysta zieleń", + "Magenta": "Purpurowy", + "Magenta Hue": "Odcień purpury", + "Magenta Luminance": "Luminancja purpury", + "Magenta Saturation": "Nasycenie purpury", + "Make Candidate the Select": "Ustaw zdjęcie kandydujące jako wybrane", + "Manual": "Ręcznie", + "Marquee zoom": "Powiększanie zaznaczonego obszaru", + "Mask Adjustment": "Korekta maski", + "Masking": "Maskowanie", + "Masking (M)": "Maskowanie (M)", + "Masks": "Maski", + "Match": "Dopasuj", + "Match Total Exposures": "Wyrównaj całkowitą ekspozycję", + "Matte": "Matowy", + "Matte Soft": "Delikatny mat", + "Med": "Śr.", + "Medium Contrast": "Średni kontrast", + "Megapixels": "Megapiksele", + "Memory Budget": "Limit pamięci", + "Memory Usage": "Wykorzystanie pamięci", + "Memory budget": "Limit pamięci", + "Memory for caches": "Pamięć na pamięć podręczną", + "Menu": "Menu", + "Merge": "Scal", + "Merge Keywords": "Połącz słowa kluczowe", + "Merge into…": "Scal z…", + "Merged": "Scalone", + "Merged PRs": "Scalone zgłoszenia PR", + "Merging…": "Scalanie…", + "Metadata": "Metadane", + "Metadata Preset": "Zestaw metadanych", + "Metadata Presets": "Zestawy metadanych", + "Metadata preset": "Zestaw metadanych", + "Midpoint": "Punkt środkowy", + "Midtones": "Tony średnie", + "Midtones Hue": "Odcień tonów średnich", + "Midtones Luminance": "Luminancja tonów średnich", + "Midtones Saturation": "Nasycenie tonów średnich", + "Midtones split": "Podział tonów średnich", + "Minimize": "Minimalizuj", + "Missing Photos": "Brakujące zdjęcia", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Brakujące metadane ({camera}, {title}…) pozostawiają puste miejsce; jeśli wynikowa nazwa jest pusta, zachowywana jest nazwa oryginalna.", + "Misty Morning": "Mglisty poranek", + "Mode": "Tryb", + "Model": "Model", + "Models": "Modele", + "Modified Date": "Data modyfikacji", + "Monday": "Poniedziałek", + "Mono Red Filter": "Monochromatyczny filtr czerwony", + "Mono Rich": "Bogata monochromia", + "Mono Soft": "Delikatna monochromia", + "Monochrome": "Monochromatyczny", + "Month": "Miesiąc", + "Moody": "Nastrojowy", + "Moody Portrait": "Nastrojowy portret", + "More": "Więcej", + "More preset options": "Więcej opcji ustawienia predefiniowanego", + "Move": "Przenieś", + "Move Album": "Przenieś album", + "Move Down": "Przenieś w dół", + "Move Down in Stack": "Przenieś niżej w stosie", + "Move Folder": "Przenieś folder", + "Move Folder To…": "Przenieś folder do…", + "Move Mask": "Przenieś maskę", + "Move Preset to Group": "Przenieś ustawienie predefiniowane do grupy", + "Move Preset to New Group": "Przenieś ustawienie predefiniowane do nowej grupy", + "Move Up": "Przenieś w górę", + "Move Up in Stack": "Przenieś wyżej w stosie", + "Move in Stack": "Przenieś w stosie", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Przenieś pliki (do folderu Originals/ w bibliotece lub do wybranego folderu), usuwając je z lokalizacji źródłowej", + "Move them": "Przenieś je", + "Move to": "Przenieś do", + "Move to Group": "Przenieś do grupy", + "Move to group": "Przenieś do grupy", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Przenosi wszystkie zdjęcia zaimportowane z tego dysku do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Przenosi zdjęcia zaimportowane z tego folderu do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", + "Muted": "Stonowany", + "Muted Film": "Stonowany film", + "My Photos": "Moje zdjęcia", + "My Photos Panel": "Panel Moje zdjęcia", + "Name": "Nazwa", + "Name (A–Z)": "Nazwa (A–Z)", + "Name of the folder the original is in": "Nazwa folderu z oryginałem", + "Named": "Nazwane", + "Named People": "Osoby z przypisanymi imionami", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Nazwy pojawiają się w menu etykiet, pasku filtrów i panelu informacji oraz są zapisywane w XMP. Puste pole oznacza nazwę koloru.", + "Navigator": "Nawigator", + "Needs Work": "Wymaga dopracowania", + "Neon Dusk": "Neonowy zmierzch", + "Neon Night": "Neonowa noc", + "Nested group": "Grupa zagnieżdżona", + "Neutral": "Neutralny", + "Never": "Nigdy", + "New": "Nowy", + "New Album": "Nowy album", + "New Album…": "Nowy album…", + "New Folder…": "Nowy folder…", + "New Group…": "Nowa grupa…", + "New Smart Album": "Nowy album inteligentny", + "New Smart Album from Filter…": "Nowy album inteligentny z filtra…", + "New Smart Album…": "Nowy album inteligentny…", + "New album": "Nowy album", + "New album…": "Nowy album…", + "New blank PDF": "Nowy pusty PDF", + "New name": "Nowa nazwa", + "New time": "Nowy czas", + "New…": "Nowy…", + "Next Photo": "Następne zdjęcie", + "Night Blue": "Nocny błękit", + "No": "Nie", + "No Camera or Card Found": "Nie znaleziono aparatu ani karty pamięci", + "No Date Groups": "Brak grup według dat", + "No Stars": "Brak gwiazdek", + "No Tool": "Brak narzędzia", + "No automatic versions.": "Brak automatycznych wersji.", + "No camera": "Brak aparatu", + "No contributor data was built into this copy.": "W tej wersji nie zapisano danych współtwórców.", + "No date": "Brak daty", + "No dust spots found": "Nie znaleziono plamek kurzu", + "No edits yet.": "Nie wprowadzono jeszcze zmian.", + "No exposure information": "Brak informacji o ekspozycji", + "No filter presets yet": "Brak zapisanych zestawów filtrów", + "No lens data embedded in this file (DNG lens opcodes only).": "Ten plik nie zawiera danych obiektywu (dostępne są jedynie instrukcje obiektywu DNG).", + "No masks yet. Choose a mask type above.": "Nie ma jeszcze masek. Wybierz typ maski powyżej.", + "No matching commands": "Brak pasujących poleceń", + "No matching photos": "Brak pasujących zdjęć", + "No metadata found": "Nie znaleziono metadanych", + "No model credits were built into this copy.": "W tej wersji nie zapisano informacji o autorach modeli.", + "No named people in these photos": "Na tych zdjęciach nie ma osób z przypisanymi imionami", + "No named people yet": "Nie nazwano jeszcze żadnych osób", + "No new presets": "Brak nowych ustawień predefiniowanych", + "No photo selected": "Nie wybrano zdjęcia", + "No photos": "Brak zdjęć", + "No photos found": "Nie znaleziono zdjęć", + "No photos in this folder": "W tym folderze nie ma zdjęć", + "No photos match the active filters": "Żadne zdjęcia nie spełniają aktywnych filtrów", + "No photos match the filters (View → Clear Filters)": "Żadne zdjęcia nie spełniają filtrów (Widok → Wyczyść filtry)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Brak zdjęć RAW. Aparaty pojawią się tutaj po dodaniu ich zdjęć do biblioteki.", + "No reference photo": "Brak zdjęcia referencyjnego", + "No usable GPU found: rendering on the CPU": "Nie znaleziono obsługiwanej karty graficznej: renderowanie na procesorze", + "No versions yet. Create one to keep this look.": "Nie ma jeszcze wersji. Utwórz wersję, aby zachować ten wygląd.", + "Noise": "Szum", + "Noise Reduction": "Redukcja szumu", + "Non-raw photos": "Zdjęcia inne niż RAW", + "None": "Brak", + "Not Now": "Nie teraz", + "Nothing to compare": "Nie ma czego porównywać", + "Nothing to survey": "Nie ma czego przeglądać", + "Nudge Develop Value": "Zmień nieznacznie wartość wywoływania", + "OK": "OK", + "Object": "Obiekt", + "Object Mask": "Maska obiektu", + "October 2026": "Październik 2026", + "Off": "Wyłączone", + "Offset X": "Przesunięcie X", + "Offset Y": "Przesunięcie Y", + "Opacity": "Krycie", + "Opacity (%)": "Krycie (%)", + "Open Library": "Otwórz bibliotekę", + "Open Library…": "Otwórz bibliotekę…", + "Open in": "Otwórz w", + "Open in Detail": "Otwórz w widoku szczegółowym", + "Open the place in OpenStreetMap": "Otwórz lokalizację w OpenStreetMap", + "Open…": "Otwórz…", + "Optics": "Optyka", + "Optimize Library": "Optymalizuj bibliotekę", + "Optional name": "Nazwa (opcjonalnie)", + "Options": "Opcje", + "Orange": "Pomarańczowy", + "Orange Hue": "Odcień pomarańczowego", + "Orange Luminance": "Luminancja pomarańczowego", + "Orange Saturation": "Nasycenie pomarańczowego", + "Orientation": "Orientacja", + "Original": "Oryginał", + "Original + Settings": "Oryginał + ustawienia", + "Original extension without the dot": "Oryginalne rozszerzenie bez kropki", + "Original file name without its extension": "Oryginalna nazwa pliku bez rozszerzenia", + "Original files": "Pliki oryginalne", + "Original offline · editing the smart preview": "Oryginał niedostępny · edycja podglądu inteligentnego", + "Original offline · no smart preview": "Oryginał niedostępny · brak podglądu inteligentnego", + "Originals": "Oryginały", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Inne obrazy (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Nakładka", + "Overwrite": "Nadpisz", + "Pages": "Strony", + "Paint": "Malowanie", + "Paint only areas like the one under the brush": "Maluj tylko obszary podobne do tego pod pędzlem", + "Paint over a distraction on the photo to remove it.": "Zamaluj niepożądany element na zdjęciu, aby go usunąć.", + "Paint overlay": "Nakładka malowania", + "Panels": "Panele", + "Panorama": "Panorama", + "Panorama Merge": "Łączenie panoramy", + "Panorama Merge Preview": "Podgląd łączenia panoramy", + "Panorama with Last Settings": "Panorama z ostatnimi ustawieniami", + "Panoramas": "Panoramy", + "Panorama…": "Panorama…", + "Parametric curve": "Krzywa parametryczna", + "Paste": "Wklej", + "Paste Edit Settings": "Wklej ustawienia edycji", + "Paste Metadata": "Wklej metadane", + "Paste Selected Settings": "Wklej wybrane ustawienia", + "Paste Selected Settings…": "Wklej wybrane ustawienia…", + "Paste Settings": "Wklej ustawienia", + "Paste Settings from Previous": "Wklej ustawienia z poprzedniego", + "Paste from Previous": "Wklej z poprzedniego", + "Paste pages": "Wklej strony", + "Pastel Haze": "Pastelowa mgiełka", + "People": "Osoby", + "Percent": "Procent", + "Percentage": "Wartość procentowa", + "Performance": "Wydajność", + "Person": "Osoba", + "Perspective": "Perspektywa", + "Pet Eye": "Oczy zwierząt", + "Pet Eye Catchlight": "Blik w oczach zwierząt", + "Photo": "Zdjęcie", + "Photo Grid": "Siatka zdjęć", + "Photo Grid (G)": "Siatka zdjęć (G)", + "Photo Merge": "Łączenie zdjęć", + "Photos": "Zdjęcia", + "Photos per bracket (0 = auto)": "Zdjęć w serii ekspozycji (0 = automatycznie)", + "Pick": "Wybierz", + "Pick Flag": "Flaga wyboru", + "Pick a colour on the photo with the eyedropper.": "Wybierz kolor ze zdjęcia za pomocą pipety.", + "Pick the sharpest photo of each burst": "Wybierz najostrzejsze zdjęcie z każdej serii", + "Picked": "Wybrane", + "Picks": "Wybrane zdjęcia", + "Pictures": "Obrazy", + "Pixels": "Piksele", + "Point Color": "Kolor punktowy", + "Point Curve": "Krzywa punktowa", + "Point Curve Presets": "Ustawienia wstępne krzywej punktowej", + "Point curve": "Krzywa punktowa", + "Portrait": "Portret", + "Position": "Położenie", + "Preferences": "Preferencje", + "Preferences…": "Preferencje…", + "Prepare AI Masks": "Przygotuj maski AI", + "Preset": "Ustawienie wstępne", + "Preset name": "Nazwa ustawienia wstępnego", + "Presets": "Ustawienia wstępne", + "Presets & Profiles": "Ustawienia wstępne i profile", + "Presets (Shift+P)": "Ustawienia wstępne (Shift+P)", + "Presets and profiles": "Ustawienia wstępne i profile", + "Presets ▾": "Ustawienia wstępne ▾", + "Press keys…": "Naciśnij klawisze…", + "Preview": "Podgląd", + "Preview Build Progress": "Postęp tworzenia podglądów", + "Preview only": "Tylko podgląd", + "Preview size": "Rozmiar podglądu", + "Previews": "Podglądy", + "Previous Photo": "Poprzednie zdjęcie", + "Print": "Drukuj", + "Print…": "Drukuj…", + "Profile": "Profil", + "Profile Browser": "Przeglądarka profili", + "Profile Distortion": "Dystorsja profilu", + "Profile Menu": "Menu profili", + "Profile Vignetting": "Winietowanie profilu", + "Profile:": "Profil:", + "Profiles": "Profile", + "Projection": "Odwzorowanie", + "Proof copy created": "Utworzono kopię próbną", + "Properties": "Właściwości", + "Public domain": "Domena publiczna", + "Pupil Size": "Rozmiar źrenicy", + "Purple": "Purpurowy", + "Purple Amount": "Ilość purpury", + "Purple Hue": "Odcień purpury", + "Purple Hue High": "Górna granica odcienia purpury", + "Purple Hue Low": "Dolna granica odcienia purpury", + "Purple Luminance": "Luminancja purpury", + "Purple Saturation": "Nasycenie purpury", + "Put the locations you removed from Local back (no files change)": "Przywróć lokalizacje usunięte z widoku Lokalne (bez zmiany plików)", + "Quality": "Jakość", + "Query Photos": "Wyszukaj zdjęcia", + "Quick Collection": "Szybka kolekcja", + "Quick Develop": "Szybka obróbka", + "Quiet Green": "Spokojna zieleń", + "Quit": "Zakończ", + "Quit Anyway": "Zamknij mimo to", + "Quit LightCraft": "Zamknij LightCraft", + "Quit with unsaved changes?": "Zamknąć z niezapisanymi zmianami?", + "RAW decoding": "Dekodowanie RAW", + "Radial": "Promieniowy", + "Radial Gradient": "Gradient promieniowy", + "Radius": "Promień", + "Random": "Losowo", + "Range": "Zakres", + "Rate ★": "Oceń ★", + "Rate ★ and advance": "Oceń ★ i przejdź dalej", + "Rate ★★": "Oceń ★★", + "Rate ★★ and advance": "Oceń ★★ i przejdź dalej", + "Rate ★★★": "Oceń ★★★", + "Rate ★★★ and advance": "Oceń ★★★ i przejdź dalej", + "Rate ★★★★": "Oceń ★★★★", + "Rate ★★★★ and advance": "Oceń ★★★★ i przejdź dalej", + "Rate ★★★★★": "Oceń ★★★★★", + "Rate ★★★★★ and advance": "Oceń ★★★★★ i przejdź dalej", + "Rating": "Ocena", + "Rating cleared": "Usunięto ocenę", + "Ratings & flags": "Oceny i flagi", + "Raw": "RAW", + "Raw default": "Domyślne ustawienia RAW", + "Raw defaults": "Domyślne ustawienia RAW", + "Raw files": "Pliki RAW", + "Raw photos": "Zdjęcia RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Zdjęcia RAW jako DNG z osadzonymi zmianami. Opcje rozmiaru, koloru i wyjścia nie mają zastosowania.", + "Read Metadata from File": "Wczytaj metadane z pliku", + "Read mode": "Tryb odczytu", + "Read the SAM License": "Przeczytaj licencję SAM", + "Reading": "Wczytywanie", + "Reading Lightroom catalog…": "Wczytywanie katalogu Lightroom…", + "Reading folder…": "Wczytywanie folderu…", + "Real name": "Imię i nazwisko", + "Recent": "Ostatnie", + "Recent Keywords": "Ostatnie słowa kluczowe", + "Recently Added": "Ostatnio dodane", + "Recently Deleted": "Ostatnio usunięte", + "Red": "Czerwony", + "Red Eye": "Czerwone oczy", + "Red Hue": "Odcień czerwieni", + "Red Luminance": "Luminancja czerwieni", + "Red Primary": "Czerwony podstawowy", + "Red Saturation": "Nasycenie czerwieni", + "Red channel": "Kanał czerwony", + "Red label": "Czerwona etykieta", + "Red/Cyan Fringe": "Czerwono-turkusowa obwódka", + "Redo": "Ponów", + "Reference": "Odniesienie", + "Reference View": "Widok referencyjny", + "Reference the files where they are": "Pozostaw pliki w bieżącym położeniu", + "Refine": "Dopracuj", + "Refine AI Mask Detail": "Dopracuj szczegóły maski AI", + "Refine Edges": "Dopracuj krawędzie", + "Refine Mask Edges": "Dopracuj krawędzie maski", + "Refine Saturation": "Dopracuj nasycenie", + "Refining the mask's detail…": "Dopracowywanie szczegółów maski…", + "Refresh Source": "Odśwież źródło", + "Refresh Source (/)": "Odśwież źródło (/)", + "Reject": "Odrzuć", + "Reject below focus": "Odrzuć poniżej progu ostrości", + "Rejected": "Odrzucone", + "Rejects": "Odrzucone zdjęcia", + "Reliability": "Niezawodność", + "Relink Photo": "Ponownie połącz zdjęcie", + "Reload Changed Files": "Wczytaj ponownie zmienione pliki", + "Reload from Disk": "Wczytaj ponownie z dysku", + "Remove": "Usuń", + "Remove (H)": "Usuń (H)", + "Remove CA": "Usuń aberrację chromatyczną", + "Remove Chromatic Aberration": "Usuń aberrację chromatyczną", + "Camera local tone mapping": "Lokalne mapowanie tonów aparatu", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Zastosuj lokalne mapowanie tonów zapisane w pliku RAW (Apple ProRAW), tak jak robi to aparat", + "Remove Disk from Library": "Usuń dysk z biblioteki", + "Remove Disk from Library…": "Usuń dysk z biblioteki…", + "Remove Face Box": "Usuń ramkę twarzy", + "Remove Folder from Library": "Usuń folder z biblioteki", + "Remove a filter above, or choose Clear all": "Usuń powyższy filtr lub wybierz Wyczyść wszystko", + "Remove from Album": "Usuń z albumu", + "Remove from Favorites": "Usuń z ulubionych", + "Remove from Library…": "Usuń z biblioteki…", + "Remove from Local": "Usuń z lokalnych", + "Remove from Photo": "Usuń ze zdjęcia", + "Remove from Selected Photos": "Usuń z zaznaczonych zdjęć", + "Remove from Stack": "Usuń ze stosu", + "Remove from Target Album": "Usuń z albumu docelowego", + "Remove from survey": "Usuń z widoku przeglądu", + "Remove location info": "Usuń informacje o lokalizacji", + "Remove shortcut": "Usuń skrót klawiszowy", + "Remove this face box (undo with Edit ▸ Undo)": "Usuń tę ramkę twarzy (cofnij przez Edycja ▸ Cofnij)", + "Remove this filter": "Usuń ten filtr", + "Remove this group": "Usuń tę grupę", + "Remove this rule": "Usuń tę regułę", + "Rename": "Zmień nazwę", + "Rename Album": "Zmień nazwę albumu", + "Rename Component": "Zmień nazwę elementu", + "Rename Folder": "Zmień nazwę folderu", + "Rename Folder…": "Zmień nazwę folderu…", + "Rename Keyword": "Zmień nazwę słowa kluczowego", + "Rename Keyword…": "Zmień nazwę słowa kluczowego…", + "Rename Mask": "Zmień nazwę maski", + "Rename Photos": "Zmień nazwy zdjęć", + "Rename Photos…": "Zmień nazwy zdjęć…", + "Rename Preset": "Zmień nazwę ustawienia wstępnego", + "Rename Preview": "Zmień nazwę podglądu", + "Rename Template Tags": "Zmień nazwy znaczników szablonu", + "Rename Version": "Zmień nazwę wersji", + "Rename…": "Zmień nazwę…", + "Rendering": "Renderowanie", + "Rendering…": "Renderowanie…", + "Reorder Masks": "Zmień kolejność masek", + "Reset": "Resetuj", + "Reset All": "Zresetuj wszystko", + "Reset All Curves": "Zresetuj wszystkie krzywe", + "Reset All Keyboard Shortcuts": "Przywróć wszystkie skróty klawiszowe", + "Reset Before to Import State": "Przywróć stan przed edycją do stanu po imporcie", + "Reset Control": "Zresetuj element sterujący", + "Reset Crop": "Zresetuj kadrowanie", + "Reset Edits": "Zresetuj edycje", + "Reset Panel Layout": "Przywróć układ paneli", + "Reset Parametric Curve": "Zresetuj krzywą parametryczną", + "Reset Section": "Zresetuj sekcję", + "Reset Tone Curve": "Zresetuj krzywą tonalną", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Zresetuj wszystkie krzywe: punktowe (wszystkie kanały) i parametryczną (kliknij dwukrotnie kanał, aby zresetować tylko jego krzywą)", + "Reset all edits (Cmd+Shift+R)": "Zresetuj wszystkie edycje (Cmd+Shift+R)", + "Reshuffle": "Wylosuj ponownie", + "Resize Face Box": "Zmień rozmiar ramki twarzy", + "Resize panel window": "Zmień rozmiar okna panelu", + "Resize panels": "Zmień rozmiary paneli", + "Resize to": "Zmień rozmiar do", + "Resolution (ppi)": "Rozdzielczość (ppi)", + "Restore": "Przywróć", + "Restore (no shortcut)": "Przywróć (bez skrótu)", + "Restore Library from Backup…": "Przywróć bibliotekę z kopii zapasowej…", + "Restore Version": "Przywróć wersję", + "Restore keeps their edits; Import as new deletes the old record first.": "Przywracanie zachowuje edycje; import jako nowe zdjęcie najpierw usuwa stary wpis.", + "Restore them": "Przywróć je", + "Retouch": "Retusz", + "Reverse the order": "Odwróć kolejność", + "Revert": "Cofnij zmiany", + "Review": "Przegląd", + "Review Import": "Sprawdź import", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem ▸ Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)", + "Right-click a preset to delete it": "Kliknij ustawienie wstępne prawym przyciskiem, aby je usunąć", + "Rights Usage Terms": "Warunki korzystania z praw", + "Rotate": "Obróć", + "Rotate Crop": "Obróć kadr", + "Rotate Crop Aspect": "Obróć proporcje kadru", + "Rotate Left": "Obróć w lewo", + "Rotate Right": "Obróć w prawo", + "Roughness": "Chropowatość", + "Roundness": "Zaokrąglenie", + "Rules": "Reguły", + "SAM 3 download stopped: it resumes where it left off next time.": "Pobieranie SAM 3 zatrzymano; zostanie wznowione przy następnej próbie.", + "Same as raw default": "Tak jak domyślne ustawienia RAW", + "Sample Mask Color": "Pobierz próbkę koloru maski", + "Sample a colour on the photo": "Wybierz próbkę koloru ze zdjęcia", + "Saturation": "Nasycenie", + "Saturation Range": "Zakres nasycenia", + "Saturday": "Sobota", + "Save": "Zapisz", + "Save As…": "Zapisz jako…", + "Save Color Label Set": "Zapisz zestaw etykiet kolorystycznych", + "Save Current Filter as Preset…": "Zapisz bieżący filtr jako ustawienie wstępne…", + "Save Current Keywords as Set…": "Zapisz bieżące słowa kluczowe jako zestaw…", + "Save Export Preset": "Zapisz ustawienie wstępne eksportu", + "Save Filter Preset": "Zapisz ustawienie wstępne filtra", + "Save Keyword Set": "Zapisz zestaw słów kluczowych", + "Save Metadata Preset": "Zapisz ustawienie wstępne metadanych", + "Save Metadata Preset…": "Zapisz ustawienie wstępne metadanych…", + "Save Metadata to File": "Zapisz metadane do pliku", + "Save Point Curve Preset": "Zapisz ustawienie wstępne krzywej punktowej", + "Save Point Curve…": "Zapisz krzywą punktową…", + "Save as Preset": "Zapisz jako ustawienie wstępne", + "Save as Set…": "Zapisz jako zestaw…", + "Save as Smart Album…": "Zapisz jako album inteligentny…", + "Save as set": "Zapisz jako zestaw", + "Save as…": "Zapisz jako…", + "Save preset": "Zapisz ustawienie wstępne", + "Saving Lightroom import index…": "Zapisywanie indeksu importu Lightroom…", + "Scale": "Skala", + "Scan failed": "Skanowanie nie powiodło się", + "Scanning": "Skanowanie", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Oceny pozostają przypisane do zdjęć: możesz filtrować według ostrości i najlepszych podobnych ujęć lub tworzyć albumy inteligentne.", + "Screen": "Ekran", + "Scroll menu down": "Przewiń menu w dół", + "Scroll menu up": "Przewiń menu w górę", + "Search": "Szukaj", + "Search Photos": "Szukaj zdjęć", + "Search commands or keys": "Szukaj poleceń lub klawiszy", + "Search…": "Szukaj…", + "Seasons": "Pory roku", + "Second Window": "Drugie okno", + "Select": "Zaznacz", + "Select All": "Zaznacz wszystko", + "Select Luminance Range": "Zaznacz zakres luminancji", + "Select Mask": "Wybierz maskę", + "Select Photos": "Wybierz zdjęcia", + "Select Spot": "Wybierz punkt", + "Select a photo to edit": "Wybierz zdjęcie do edycji", + "Select by": "Wybierz według", + "Select by Flag, Rating or Label": "Wybierz według flagi, oceny lub etykiety", + "Select photos, then choose View → Survey (N)": "Wybierz zdjęcia, a następnie Widok → Przegląd (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Wybierz dwa zdjęcia, a następnie Widok → Porównaj (Shift+C)", + "Selected": "Zaznaczone", + "Selecting…": "Wybieranie…", + "Selenium Tone": "Tonowanie selenowe", + "Send Feedback…": "Wyślij opinię…", + "Sepia Tone": "Tonowanie sepią", + "Sequence number zero-padded to N digits (1–9)": "Numer kolejny dopełniony zerami do N cyfr (1–9)", + "Sequence number, counting from the start number": "Numer kolejny liczony od numeru początkowego", + "Services": "Usługi", + "Set": "Zestaw", + "Set Album Cover": "Ustaw okładkę albumu", + "Set Before": "Ustaw stan „Przed”", + "Set Color Label": "Ustaw etykietę kolorystyczną", + "Set Crop": "Ustaw kadr", + "Set Develop Values": "Ustaw wartości wywoływania", + "Set Flag": "Ustaw flagę", + "Set Image Zoom and Pan": "Ustaw powiększenie i przesunięcie obrazu", + "Set Keyboard Shortcut": "Ustaw skrót klawiszowy", + "Set Mask Adjustments": "Ustaw korekty maski", + "Set Point Curve": "Ustaw krzywą punktową", + "Set Profile": "Ustaw profil", + "Set Rating": "Ustaw ocenę", + "Set Top of Stack": "Przenieś na wierzch stosu", + "Set as Album Cover": "Ustaw jako okładkę albumu", + "Set as Before": "Ustaw jako stan „Przed”", + "Set as Reference Photo": "Ustaw jako zdjęcie referencyjne", + "Set as Target Album": "Ustaw jako album docelowy", + "Set as Target Album (B adds to it)": "Ustaw jako album docelowy (B dodaje do niego zdjęcia)", + "Set as Top of Stack": "Przenieś na wierzch stosu", + "Set date & time": "Ustaw datę i godzinę", + "Set name": "Ustaw nazwę", + "Set the mix from the photo's colours": "Ustal proporcje na podstawie kolorów zdjęcia", + "Settings": "Ustawienia", + "Settings file problem": "Problem z plikiem ustawień", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Ustawienia początkowe nowych zdjęć RAW. Zmiana nie wpływa na zdjęcia już znajdujące się w bibliotece.", + "Settings pasted": "Wklejono ustawienia", + "Settings to include": "Ustawienia do uwzględnienia", + "Settings…": "Ustawienia…", + "Shade": "Cień", + "Shadow": "Cień", + "Shadows": "Cienie", + "Shadows Hue": "Odcień cieni", + "Shadows Luminance": "Luminancja cieni", + "Shadows Saturation": "Nasycenie cieni", + "Shadows Tint": "Zabarwienie cieni", + "Shadows split": "Podział cieni", + "Sharpen": "Wyostrz", + "Sharpening": "Wyostrzanie", + "Sharpness": "Wyostrzenie", + "Shift": "Przesunięcie", + "Shift by": "Przesuń o", + "Short Edge": "Krótsza krawędź", + "Show": "Pokaż", + "Show All": "Pokaż wszystko", + "Show Clipping": "Pokaż prześwietlenia i niedoświetlenia", + "Show Deghost Overlay": "Pokaż nakładkę usuwania duchów", + "Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne", + "Show Luminance Map": "Pokaż mapę luminancji", + "Show Mask": "Pokaż maskę", + "Show Mask Overlay": "Pokaż nakładkę maski", + "Show Mask Pins": "Pokaż znaczniki masek", + "Show Original": "Pokaż oryginał", + "Show Photo Counts": "Pokaż liczbę zdjęć", + "Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym", + "Show Pins": "Pokaż znaczniki", + "Show Source": "Pokaż źródło", + "Show Thumbnails": "Pokaż miniatury", + "Show commands without a shortcut": "Pokaż polecenia bez skrótu", + "Show destination gamut warning": "Pokaż ostrzeżenie o gamucie docelowym", + "Show display gamut warning": "Pokaż ostrzeżenie o gamucie wyświetlacza", + "Show file names": "Pokaż nazwy plików", + "Show in Explorer": "Pokaż w Eksploratorze", + "Show in File Manager": "Pokaż w menedżerze plików", + "Show in Finder": "Pokaż w Finderze", + "Show on Map": "Pokaż na mapie", + "Show overlay (O)": "Pokaż nakładkę (O)", + "Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji", + "Show the Navigator while zoomed in": "Pokaż Nawigator podczas powiększenia", + "Show the template tags; click one to insert it": "Pokaż znaczniki szablonu; kliknij znacznik, aby go wstawić", + "Show/Hide Mask": "Pokaż/ukryj maskę", + "Show/hide My Photos panel": "Pokaż/ukryj panel Moje zdjęcia", + "Shutter speed": "Czas naświetlania", + "Side panels": "Panele boczne", + "Sidecar names": "Nazwy plików pomocniczych", + "Size": "Rozmiar", + "Size (% of short edge)": "Rozmiar (% krótszej krawędzi)", + "Size limit": "Limit rozmiaru", + "Skip": "Pomiń", + "Sky": "Niebo", + "Slideshow": "Pokaz slajdów", + "Slideshow paused": "Pokaz slajdów wstrzymany", + "Slideshow resumed": "Pokaz slajdów wznowiony", + "Slideshow · Space pauses · Esc ends": "Pokaz slajdów · Spacja: pauza · Esc: koniec", + "Slideshow, second window, All Metadata, System Info.": "Pokaz slajdów, drugie okno, wszystkie metadane, informacje o systemie.", + "Smart Album": "Inteligentny album", + "Smart Album Rule Fields": "Pola reguł albumu inteligentnego", + "Smart Preview Status": "Stan podglądu inteligentnego", + "Smart Previews Location": "Lokalizacja podglądów inteligentnych", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Albumy inteligentne z edytorem reguł: dopasuj wszystkie / dowolne / żadne, grupy zagnieżdżone, 26 pól.", + "Smart preview available": "Dostępny podgląd inteligentny", + "Smart previews": "Podglądy inteligentne", + "Smart previews folder changed": "Zmieniono folder podglądów inteligentnych", + "Smoothness": "Wygładzenie", + "Soft B&W": "Delikatna czerń i biel", + "Soft Pastel": "Delikatne pastele", + "Soft Proofing": "Próba koloru", + "Soft Skin": "Wygładzanie skóry", + "Sort": "Sortuj", + "Sort by": "Sortuj według", + "Source": "Źródło", + "Source code on GitHub": "Kod źródłowy na GitHubie", + "Spherical": "Sferyczny", + "Spiral": "Spirala", + "Split Stack": "Rozdziel stos", + "Spring Fresh": "Wiosenna świeżość", + "Square Grid": "Siatka kwadratowa", + "Square Grid (G toggles)": "Siatka kwadratowa (G przełącza)", + "Square Grid: show file names and formats": "Siatka kwadratowa: pokaż nazwy i formaty plików", + "Stack": "Stos", + "Stack photos taken within this time of each other:": "Umieszczaj na jednym stosie zdjęcia wykonane w odstępie nie większym niż:", + "Stacks": "Stosy", + "Standard": "Standardowy", + "Star rating (0–5)": "Ocena gwiazdkowa (0–5)", + "Start at": "Zacznij od", + "Start number": "Numer początkowy", + "Starting": "Uruchamianie", + "Startup": "Uruchamianie programu", + "State / Province": "Stan / województwo", + "Stop": "Zatrzymaj", + "Stop Building Previews": "Zatrzymaj tworzenie podglądów", + "Stop Using as Target Album": "Przestań używać jako albumu docelowego", + "Stopping…": "Zatrzymywanie…", + "Straighten": "Wyprostuj", + "Straighten Tool": "Narzędzie prostowania", + "Strong Contrast": "Silny kontrast", + "Style": "Styl", + "Subfolder": "Podfolder", + "Subfolder name": "Nazwa podfolderu", + "Subject": "Obiekt", + "Subtract": "Odejmij", + "Subtract from this mask": "Odejmij od tej maski", + "Suggestions": "Sugestie", + "Summer Haze": "Letnia mgiełka", + "Sunday": "Niedziela", + "Survey": "Przegląd", + "Survey (N)": "Przegląd (N)", + "Swap": "Zamień", + "Swap Aspect (X)": "Zamień proporcje (X)", + "Swap Before and After": "Zamień „Przed” i „Po”", + "Swap Before and After Settings": "Zamień ustawienia „Przed” i „Po”", + "Swap Compare Photos": "Zamień porównywane zdjęcia", + "Switch light / dark theme": "Przełącz jasny/ciemny motyw", + "Sync Settings": "Synchronizuj ustawienia", + "System": "System", + "System Info": "Informacje o systemie", + "System Info…": "Informacje o systemie…", + "System default": "Domyślne ustawienie systemowe", + "Table": "Tabela", + "Tags": "Tagi", + "Take a snapshot": "Utwórz migawkę", + "Targeted Adjustment": "Korekta punktowa", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdujące się tam kolory", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdującą się tam krzywą", + "Teal & Amber": "Turkus i bursztyn", + "Teal & Orange": "Turkus i pomarańcz", + "Temp": "Temperatura", + "Temperature": "Temperatura", + "Template": "Szablon", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sesja tymczasowa — biblioteka nie jest otwarta, a żadne zmiany nie zostaną zapisane.", + "Text": "Tekst", + "Text direction": "Kierunek tekstu", + "Texture": "Tekstura", + "That includes the photos in the folders inside it.": "Dotyczy to również zdjęć w znajdujących się wewnątrz folderach.", + "That key can't be used as a shortcut": "Tego klawisza nie można użyć jako skrótu", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 jest zainstalowany: maski Obiekt i Opis są gotowe.", + "The download didn't work:": "Pobieranie nie powiodło się:", + "The number at the end of the original name (IMG_0042 → 0042)": "Liczba na końcu oryginalnej nazwy (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Oryginalne rozszerzenie jest zawsze dodawane; {ext} umieszcza je również wewnątrz nazwy.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Oryginalne, niezmienione pliki, każdy z plikiem pomocniczym XMP zawierającym zapisane edycje.", + "The photo has these settings": "Zdjęcie ma następujące ustawienia", + "Theme": "Motyw", + "They can be restored from Recently Deleted until it is emptied.": "Można je przywrócić z Ostatnio usuniętych, dopóki ten folder nie zostanie opróżniony.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Trafią do Ostatnio usuniętych i będzie można je przywrócić; żadne pliki na dysku nie zostaną zmienione.", + "Thirds": "Trójpodział", + "This Computer": "Ten komputer", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Ten folder jest niedostępny (odłączony dysk?). Podglądy inteligentne nie będą tworzone ani używane, dopóki folder nie wróci lub nie wybierzesz innego.", + "This photo can't be opened": "Nie można otworzyć tego zdjęcia", + "Threshold": "Próg", + "Thumbnail cache": "Pamięć podręczna miniatur", + "Thursday": "Czwartek", + "Time zone": "Strefa czasowa", + "Tint": "Zabarwienie", + "Title": "Tytuł", + "Title metadata": "Metadane tytułu", + "Toggle Keyword from Set": "Przełącz słowo kluczowe z zestawu", + "Toggle Mask": "Przełącz maskę", + "Toggle Section": "Przełącz sekcję", + "Toggle Zoom": "Przełącz powiększenie", + "Tone Curve": "Krzywa tonalna", + "Tools": "Narzędzia", + "Top Level": "Poziom główny", + "Transfer": "Przenieś", + "Treatment": "Sposób obróbki", + "Triangle": "Trójkąt", + "Try Again": "Spróbuj ponownie", + "Try Saving Again": "Spróbuj ponownie zapisać", + "Tuesday": "Wtorek", + "Tungsten": "Światło żarowe", + "Turn Off": "Wyłącz", + "Turn on Include subfolders, or pick another folder under Local": "Włącz „Uwzględnij podfoldery” lub wybierz inny folder w obszarze Lokalne", + "Type": "Typ", + "Uncheck All": "Odznacz wszystko", + "Uncheck the files of this type": "Odznacz pliki tego typu", + "Undo": "Cofnij", + "Unedited": "Bez edycji", + "Unedited raws": "Nieedytowane pliki RAW", + "Unflag": "Usuń flagę", + "Unflagged": "Bez flagi", + "Ungroup Stack": "Rozgrupuj stos", + "Unknown Date": "Nieznana data", + "Unknown tags stay as typed — check the preview for a {typo}.": "Nieznane znaczniki pozostają bez zmian — sprawdź podgląd pod kątem {typo}.", + "Unrated": "Bez oceny", + "Unreadable": "Nieczytelny", + "Update": "Aktualizuj", + "Update Mask Shape": "Zaktualizuj kształt maski", + "Update Preset with Current Settings": "Zaktualizuj ustawienie wstępne bieżącymi ustawieniami", + "Update Rules from Current Filter": "Zaktualizuj reguły według bieżącego filtra", + "Update Version": "Zaktualizuj wersję", + "Update Version with Current Settings": "Zaktualizuj wersję bieżącymi ustawieniami", + "Update with Current Settings": "Zaktualizuj bieżącymi ustawieniami", + "Updated the edits saved in the external editor": "Zaktualizowano edycje zapisane w edytorze zewnętrznym", + "Upright": "Korekcja perspektywy", + "Urban": "Miejski", + "Use Keyword Set": "Użyj zestawu słów kluczowych", + "Use Library Folder": "Użyj folderu biblioteki", + "Use camera-specific defaults": "Użyj domyślnych ustawień aparatu", + "Use the GPU for rendering": "Użyj procesora graficznego do renderowania", + "User Presets": "Ustawienia wstępne użytkownika", + "Username": "Nazwa użytkownika", + "Value": "Wartość", + "Variance": "Wariancja", + "Version": "Wersja", + "Version name": "Nazwa wersji", + "Versions": "Wersje", + "Versions (Shift+V)": "Wersje (Shift+V)", + "Vertical": "Pionowo", + "Vertical text": "Tekst pionowy", + "Vibrance": "Dynamika", + "Videos": "Wideo", + "View": "Widok", + "Viewing and sharing": "Wyświetlanie i udostępnianie", + "Vignette": "Winieta", + "Vignetting": "Winietowanie", + "Vintage": "Styl retro", + "Virtual Copy": "Kopia wirtualna", + "Visible": "Widoczne", + "Visualize Spots": "Wizualizuj plamki", + "Visualize Spots (A)": "Wizualizuj plamki (A)", + "Visualize range": "Pokaż zakres", + "Vivid": "Żywe kolory", + "Vivid Pop": "Wyraziste kolory", + "Warm Film": "Ciepły film", + "Warm Glow": "Ciepła poświata", + "Warm Print": "Ciepły wydruk", + "Warm Table": "Ciepła paleta", + "Watched folder": "Folder obserwowany", + "Watermark": "Znak wodny", + "Wednesday": "Środa", + "What's New": "Co nowego", + "When rated or hovered": "Po ocenieniu lub wskazaniu kursorem", + "White Balance": "Balans bieli", + "White Balance Selector": "Próbnik balansu bieli", + "White Balance Selector (W)": "Próbnik balansu bieli (W)", + "Whites": "Biele", + "Width": "Szerokość", + "Width (% of photo)": "Szerokość (% zdjęcia)", + "Width (px)": "Szerokość (px)", + "Width × Height": "Szerokość × wysokość", + "Will be replaced": "Zostanie zastąpiony", + "Will be restored": "Zostanie przywrócony", + "Window": "Okno", + "Winter Blue": "Zimowy błękit", + "XMP Sidecar Preferences": "Preferencje plików pomocniczych XMP", + "XMP sidecars": "Pliki pomocnicze XMP", + "Year": "Rok", + "Yellow": "Żółty", + "Yellow Hue": "Odcień żółci", + "Yellow Luminance": "Luminancja żółci", + "Yellow Saturation": "Nasycenie żółci", + "Yellow label": "Żółta etykieta", + "Yes": "Tak", + "You can close this: the download continues, and resumes if interrupted.": "Możesz zamknąć to okno: pobieranie będzie kontynuowane i wznowi się po przerwaniu.", + "Your Name": "Twoje imię i nazwisko", + "Your library couldn't be opened": "Nie można otworzyć biblioteki", + "Zoom": "Powiększenie", + "Zoom 100%": "Powiększenie 100%", + "Zoom In": "Powiększ", + "Zoom Out": "Pomniejsz", + "Zoom to Fit": "Dopasuj powiększenie", + "Zoom to page level": "Dopasuj powiększenie do strony", + "a literal %": "Dosłowny znak %", + "a removed photo": "Usunięte zdjęcie", + "all": "wszystkie", + "and": "i", + "any": "dowolne", + "are empty": "Są puste", + "aren't empty": "Nie są puste", + "blue": "Niebieski", + "comma, separated": "Rozdzielone przecinkami", + "contains": "Zawiera", + "copyrighted": "Chronione prawem autorskim", + "day 01–31": "Dzień 01–31", + "days": "dni", + "discarding smart previews": "Odrzucanie podglądów inteligentnych", + "doesn't contain": "Nie zawiera", + "e.g. sky · the red car · car, road": "np. niebo · czerwony samochód · samochód, droga", + "ends with": "Kończy się na", + "false": "fałsz", + "green": "zielony", + "hour 00–23": "Godzina 00–23", + "hours": "godziny", + "image": "Obraz", + "includes the folders inside it": "Uwzględnia foldery wewnątrz", + "is": "jest", + "is <": "Jest <", + "is >": "Jest >", + "is after": "Jest po", + "is before": "Jest przed", + "is between": "Jest pomiędzy", + "is empty": "jest puste", + "is in the last": "Jest w ciągu ostatnich", + "is unknown": "Jest nieznany", + "is ≤": "Jest ≤", + "is ≥": "Jest ≥", + "isn't": "Nie jest", + "isn't empty": "nie jest puste", + "isn't in the last": "Nie występuje w ciągu ostatnich", + "keep names — or e.g. {date}_{seq:3}": "Zachowaj nazwy — lub np. {date}_{seq:3}", + "latitude, longitude": "Szerokość i długość geograficzna", + "library locked: the lock is the operating system's and goes away with the program.": "Biblioteka zablokowana: blokadę nakłada system operacyjny i znika ona po zamknięciu programu.", + "memory only": "Tylko w pamięci", + "minute": "minuta", + "month 01–12": "Miesiąc 01–12", + "months": "miesiące", + "none": "brak", + "none (CPU rendering)": "Brak (renderowanie przez CPU)", + "none of": "Żaden z", + "not available here": "Niedostępne tutaj", + "of the following rules:": "Następujących reguł:", + "or": "lub", + "pick": "Wybierz", + "previews": "Podglądy", + "publicDomain": "Domena publiczna", + "purple": "Fioletowy", + "raw": "RAW", + "red": "czerwony", + "saved in": "Zapisano w", + "second": "Sekunda", + "smart previews": "Podglądy inteligentne", + "the photos in view": "Zdjęcia w widoku", + "the selected photos": "Zaznaczone zdjęcia", + "this photo": "To zdjęcie", + "true": "prawda", + "unknown": "nieznany", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "Zdjęcie", + "weeks": "tygodnie", + "year, 2 digits": "Rok, 2 cyfry", + "year, 4 digits": "Rok, 4 cyfry", + "years": "lata", + "yellow": "żółty", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} oznacza datę wykonania; gdy jej brak, używana jest data importu.", + "© 2026 Your Name": "© 2026 Twoje imię i nazwisko", + "© Your Name": "© Twoje imię i nazwisko", + "− harder border · + softer border": "− ostrzejsza krawędź · + bardziej miękka krawędź", + "≤ at most": "≤ co najwyżej", + "≥ at least": "≥ co najmniej", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘ i przeciągnij, aby wyprostować, ⇧G: korekcja perspektywy z liniami pomocniczymi; podczas przekształcania wyświetlana jest siatka.", + "Any camera": "Dowolny aparat", + "Any lens": "Dowolny obiektyw", + "Any keyword": "Dowolne słowo kluczowe", + "Label Set": "Zestaw etykiet", + ", picked": ", wybrano", + ", rejected": ", odrzucono", + "Preview only: editing the camera's embedded JPEG": "Tylko podgląd: edycja JPEG-a osadzonego przez aparat", + "Captured from": "Wykonane od", + "Captured until": "Wykonane do", + "Only": "Tylko", + "How to install the model": "Jak zainstalować model", + "Install the SAM 3 Model": "Zainstaluj model SAM 3", + "Open Log Folder": "Otwórz folder dzienników", + "Back to this person": "Wróć do tej osoby", + "AI Denoise": "Odszumianie AI", + "AI Denoise Model": "Model odszumiania AI", + "AI Denoise is off: press Use on a model.": "Odszumianie AI jest wyłączone: kliknij „Użyj” przy modelu.", + "AI Denoise needs a model. Install one in Settings.": "Odszumianie AI wymaga modelu. Zainstaluj go w Ustawieniach.", + "Accept & Download": "Akceptuj i pobierz", + "Already installed. Remove it before installing a replacement.": "Już zainstalowano. Usuń obecny model przed zainstalowaniem nowego.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Tryb automatyczny używa karty graficznej, jeśli jest szybsza od procesora. Przy pierwszym przetwarzaniu zdjęcia LightCraft mierzy wydajność obu urządzeń.", + "Cache": "Pamięć podręczna", + "Checking and installing the model…": "Sprawdzanie i instalowanie modelu…", + "Checking…": "Sprawdzanie…", + "Clear cache": "Wyczyść pamięć podręczną", + "Denoise Models": "Modele odszumiania", + "Denoise Settings": "Ustawienia odszumiania", + "Denoise all photos with an Amount": "Odszum wszystkie zdjęcia z ustawioną intensywnością", + "Downloaded and checked. Installing…": "Pobrano i sprawdzono. Instalowanie…", + "Failed its last test": "Nie przeszedł ostatniego testu", + "Graphics card": "Karta graficzna", + "I have read these terms and accept them for my own use": "Przeczytałem warunki i akceptuję je na własny użytek", + "Inspect Denoise Model": "Sprawdź model odszumiania", + "Install Denoise Model": "Zainstaluj model odszumiania", + "Install from file…": "Zainstaluj z pliku…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Zainstaluj własny model ONNX z plikiem denoise-model.json w tym samym katalogu. W tej kompilacji nie można pobierać modeli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Nie utworzono jeszcze odszumionego obrazu, a obrazy nie są przetwarzane automatycznie (Ustawienia, Odszumianie AI).", + "LightCraft does not know this model, so it assumed:": "LightCraft nie rozpoznaje tego modelu, dlatego przyjęto następujące założenia:", + "Limit": "Limit", + "Make it now": "Utwórz teraz", + "Make the denoised picture of the photos I am looking at": "Utwórz odszumione wersje oglądanych zdjęć", + "Next up for AI Denoise…": "Następne w kolejce do odszumiania AI…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nic nie zostanie pobrane bez Twojej zgody. Model jest przechowywany tylko na tym komputerze; LightCraft go nie przesyła ani nie udostępnia.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Przetwarzane są tylko zdjęcia z intensywnością odszumiania AI większą od zera. Podczas eksportu brakujące wersje są zawsze tworzone.", + "Open page": "Otwórz stronę", + "Open the model's page": "Otwórz stronę modelu", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Otwiera stronę modelu w przeglądarce, gdzie można przeczytać o nim lub samodzielnie pobrać plik.", + "Processor": "Procesor", + "Set up AI Denoise…": "Skonfiguruj odszumianie AI…", + "Speed": "Szybkość", + "Starting…": "Uruchamianie…", + "Tested and works on this computer": "Przetestowano — działa na tym komputerze", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model odszumiania jest zainstalowany i używany: suwak intensywności odszumiania AI w panelu Szczegóły jest teraz aktywny", + "The graphics card is set up when the next photo is made.": "Karta graficzna zostanie skonfigurowana podczas przetwarzania następnego zdjęcia.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model jest przechowywany wyłącznie na tym komputerze. LightCraft nigdy go nie przesyła ani nie udostępnia.", + "The processor does the work.": "Przetwarzanie wykonuje procesor.", + "There is no model file to install": "Brak pliku modelu do zainstalowania", + "This build cannot run denoise models.": "Ta kompilacja nie obsługuje modeli odszumiania.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Ta kompilacja nie ma miejsca na przechowywanie modeli odszumiania, więc odszumianie AI jest niedostępne (dostępne w aplikacji komputerowej).", + "This file cannot be used yet": "Nie można jeszcze użyć tego pliku", + "Tick the box to accept the model's terms first": "Najpierw zaznacz pole akceptacji warunków modelu", + "Try again": "Spróbuj ponownie", + "Try the graphics card again": "Spróbuj ponownie użyć karty graficznej", + "Turn off": "Wyłącz", + "Unknown licence": "Nieznana licencja", + "Use": "Użyj", + "Waiting to start…": "Oczekiwanie na uruchomienie…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.", + "cached picture": "obraz w pamięci podręcznej", + "cached pictures": "obrazy w pamięci podręcznej", + "commercial use allowed": "dozwolony użytek komercyjny", + "in use": "w użyciu", + "installed": "zainstalowano", + "makes AI Denoise available": "włącza odszumianie AI", + "non-commercial use only": "wyłącznie do użytku niekomercyjnego", + "photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.", + "photo waiting": "zdjęcie oczekuje", + "photos have their denoised picture.": "zdjęcia mają już odszumione wersje.", + "photos waiting": "zdjęć oczekuje", + "starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI", + "terms unclear": "niejasne warunki", + "the graphics card": "karta graficzna", + "the graphics card is not used": "karta graficzna nie jest używana", + "Cancel Denoise": "Anuluj odszumianie", + "Cancel Denoise Model Download": "Anuluj pobieranie modelu odszumiania", + "Choose Denoise Model": "Wybierz model odszumiania", + "Clear Denoise Cache": "Wyczyść pamięć podręczną odszumiania", + "Denoise Model Downloads": "Pobieranie modeli odszumiania", + "Denoise Photos": "Odszum zdjęcia", + "Denoise Pump": "Kolejka odszumiania", + "Denoise Status": "Stan odszumiania", + "Download Denoise Model": "Pobierz model odszumiania", + "Remove Denoise Model": "Usuń model odszumiania", + "Test Denoise Model": "Przetestuj model odszumiania", + "AI Denoise is enabled on the requesting photo": "Odszumianie AI włączono dla wybranego zdjęcia", + "After installation, AI Denoise will turn on for the requesting photo.": "Po instalacji odszumianie AI zostanie włączone dla wybranego zdjęcia.", + "Cancel pending action": "Anuluj oczekującą operację", + "Install a denoise model to enable AI Denoise on this photo": "Zainstaluj model odszumiania, aby włączyć odszumianie AI dla tego zdjęcia", + "Pending photo action cancelled": "Anulowano oczekującą operację", + "Pending photo action cancelled because its library or photo changed": "Anulowano oczekującą operację, ponieważ zmieniła się biblioteka lub zdjęcie", + "ONNX model": "Model ONNX", + "commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL", + "Decrease Rating": "Zmniejsz ocenę", + "Increase Rating": "Zwiększ ocenę", + "Click to type a value": "Kliknij, aby wpisać wartość", + "Angle": "Kąt", + "Video": "Wideo", + "Copyrighted": "Chronione prawem autorskim", + "Public Domain": "Domena publiczna", + "Unknown": "Nieznany", + "Landscape (wide)": "Poziomo (szeroko)", + "Portrait (tall)": "Pionowo (wysoko)", + "Square": "Kwadrat", + "In Color": "Kolorowe", + "No Label": "Bez etykiety", + "Choose an album…": "Wybierz album…", + "No albums yet": "Brak albumów", + "Fix the marked rules to save this album.": "Popraw oznaczone reguły, aby zapisać album.", + "Some rules need fixing (Edit Smart Album…):": "Niektóre reguły wymagają poprawienia (Edytuj album inteligentny…):", + "isn't a field rules know": "nie jest obsługiwanym polem reguły", + "can't be tested that way": "nie można sprawdzić w ten sposób", + "is yes or no": "wymaga odpowiedzi tak lub nie", + "needs a number": "wymaga liczby", + "needs a time like 1/250 or 2": "wymaga czasu, np. 1/250 lub 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "wymaga daty, np. 2026, 2026-08 lub 2026-08-14", + "has the first date after the second": "pierwsza data jest późniejsza od drugiej", + "needs two values": "wymaga dwóch wartości", + "needs a number more than 0": "wymaga liczby większej od zera", + "needs hours, days, weeks, months or years": "wymaga godzin, dni, tygodni, miesięcy lub lat", + "isn't one of the choices": "nie jest jedną z dostępnych opcji", + "needs something to look for (or use “is empty”)": "wymaga tekstu do wyszukania (lub opcji „jest puste”)", + "needs something to look for (or use “isn't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie jest puste”)", + "needs text": "wymaga tekstu", + "can't match any rating from 0 to 5": "nie może odpowiadać żadnej ocenie od 0 do 5", + "needs an album": "wymaga albumu", + "names an album that no longer exists": "wskazuje album, który już nie istnieje", + "can't test a folder; choose an album in it": "nie można sprawdzić folderu; wybierz album, który się w nim znajduje", + "This group is empty: add a rule or remove it.": "Ta grupa jest pusta: dodaj regułę albo usuń grupę.", + "needs something to look for (or use “are empty”)": "wymaga tekstu do wyszukania (lub opcji „są puste”)", + "needs something to look for (or use “aren't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie są puste”)", + "Mo": "Pn", + "Tu": "Wt", + "We": "Śr", + "Th": "Cz", + "Fr": "Pt", + "Sa": "So", + "Su": "Nd", + "Pick a date": "Wybierz datę", + "would make this album include itself through other smart albums": "spowodowałoby włączenie tego albumu do niego samego przez inne albumy inteligentne", + "Search albums": "Wyszukaj albumy", + "No albums match": "Brak pasujących albumów", + "This is the album you're editing": "To album, który edytujesz", + "It tests this album, so testing it back would loop": "Sprawdza ten album, więc odwrotne sprawdzenie utworzyłoby pętlę", + "Copy Name": "Kopiuj nazwę", + "In a Stack": "W stosie", + "File Extension": "Rozszerzenie pliku", + "Video Duration": "Czas trwania filmu", + "Keyword Count": "Liczba słów kluczowych", + "People Count": "Liczba osób", + "Shutter Speed": "Czas migawki", + "Cropped": "Przycięte", + "Keywords & People": "Słowa kluczowe i osoby", + "Description": "Opis", + "Camera Info": "Informacje o aparacie", + "Develop": "Wywoływanie", + "Keyword List": "Lista słów kluczowych", + "Filter Keywords": "Filtruj słowa kluczowe", + "No keywords yet": "Brak słów kluczowych", + "No keywords match": "Brak pasujących słów kluczowych", + "Keyword Name": "Nazwa słowa kluczowego", + "Synonyms": "Synonimy", + "Separated by commas": "Oddzielone przecinkami", + "Keyword Tag Options": "Opcje etykiety słowa kluczowego", + "Include on Export": "Uwzględnij przy eksporcie", + "Export Containing Keywords": "Eksportuj nadrzędne słowa kluczowe", + "Export Synonyms": "Eksportuj synonimy", + "Create Keyword Tag": "Utwórz słowo kluczowe", + "Edit Keyword Tag": "Edytuj słowo kluczowe", + "Create": "Utwórz", + "No photo has it; the keywords below it go too.": "Żadne zdjęcie go nie używa; podrzędne słowa kluczowe również zostaną usunięte.", + "Edit Keyword Tag…": "Edytuj słowo kluczowe…", + "Put New Keywords Inside This Keyword": "Umieszczaj nowe słowa kluczowe wewnątrz tego", + "Purge Unused Keywords": "Usuń nieużywane słowa kluczowe", + "Delete Keyword…": "Usuń słowo kluczowe…", + "Drop here for the top level": "Upuść tutaj, aby umieścić na najwyższym poziomie", + "Their photos and the keywords below them come together under one keyword.": "Zdjęcia i podrzędne słowa kluczowe zostaną zebrane pod jednym słowem kluczowym.", + "Select photos to give them keywords.": "Wybierz zdjęcia, którym chcesz przypisać słowa kluczowe.", + "Add to All Selected Photos": "Dodaj do wszystkich wybranych zdjęć", + "Will Export": "Zostanie wyeksportowane", + "No keywords are exported": "Nie zostaną wyeksportowane żadne słowa kluczowe", + "& Containing": "i nadrzędne", + "Edit Set…": "Edytuj zestaw…", + "Edit Keyword Set": "Edytuj zestaw słów kluczowych", + "Keywords (⌥1–⌥9)": "Słowa kluczowe (⌥1–⌥9)", + "A keyword set needs a name": "Zestaw słów kluczowych musi mieć nazwę", + "Save as a new set": "Zapisz jako nowy zestaw", + "Recent Keywords is built in: choose another name": "Ostatnie słowa kluczowe to zestaw wbudowany: wybierz inną nazwę", + "Import Keywords…": "Importuj słowa kluczowe…", + "Export Keywords…": "Eksportuj słowa kluczowe…", + "Import Keywords": "Importuj słowa kluczowe", + "Export Keywords": "Eksportuj słowa kluczowe", + "Keyword Lists": "Listy słów kluczowych", + "Synchronize Folder…": "Synchronizuj folder…", + "Synchronize Folder": "Synchronizuj folder", + "Synchronize": "Synchronizuj", + "Scanning…": "Skanowanie…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Znajdź zdjęcia dodane do folderu lub brakujące na dysku oraz pliki XMP zmienione przez inne aplikacje", + "Bring the library up to date with this folder and the folders inside it:": "Zaktualizuj bibliotekę na podstawie tego folderu i wszystkich jego podfolderów:", + "Import new photos": "Importuj nowe zdjęcia", + "Remove missing photos from the library": "Usuń brakujące zdjęcia z biblioteki", + "Read metadata updates from XMP sidecars": "Odczytaj aktualizacje metadanych z plików XMP", + "The library is up to date with this folder.": "Biblioteka jest aktualna względem tego folderu.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Brakujące zdjęcia trafiają do Ostatnio usuniętych i można je przywrócić; pliki na dysku pozostają bez zmian.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Odczyt pliku XMP zastępuje ocenę, etykiety, metadane i edycje zdjęcia jego zawartością.", + "Skipped: files already in the library elsewhere": "Pominięto: pliki znajdujące się już w bibliotece w innym miejscu", + "Skipped: files that can't be read": "Pominięto: pliki, których nie można odczytać", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Ten folder nie istnieje: przeniesiono go, zmieniono nazwę albo odłączono dysk.", + "Relink photos whose file was renamed or moved": "Połącz ponownie zdjęcia, których pliki przeniesiono lub przemianowano", + "A folder is already being synchronized": "Trwa już synchronizacja folderu", + "Synchronizing…": "Synchronizowanie…", + "Relinked": "Połączono ponownie", + "Removed": "Usunięto", + "Metadata read": "Odczytano metadane", + "Failed": "Niepowodzenie", + "Stopped": "Zatrzymano", + "Stopped by an error (see the log)": "Zatrzymano z powodu błędu (zobacz dziennik)", + "Not saved": "Nie zapisano", + "A folder is being synchronized": "Trwa synchronizacja folderu", + "Wait for the import to finish": "Poczekaj na zakończenie importu", + "Show less": "Pokaż mniej", + "Quit while tasks are running?": "Zakończyć, gdy trwają zadania?", + "Exporting contact sheet": "Eksportowanie arkusza stykowego", + "Reading folder": "Odczytywanie folderu", + "Scanning folder": "Skanowanie folderu", + "Building previews": "Tworzenie podglądów", + "Building smart previews": "Tworzenie podglądów inteligentnych", + "Discarding smart previews": "Odrzucanie podglądów inteligentnych", + "Importing Lightroom catalog": "Importowanie katalogu Lightroom", + "Reading Lightroom catalog": "Odczytywanie katalogu Lightroom", + "Merging": "Scalanie", + "Downloading model": "Pobieranie modelu", + "Finding faces": "Wyszukiwanie twarzy", + "Synchronizing folder": "Synchronizowanie folderu", + "Looking for changes": "Wyszukiwanie zmian", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„zapisano w pamięci, ale nie na dysku”, na górnym pasku pojawia się ostrzeżenie, a LightCraft ponawia próby, dopóki", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): plik DNG jest porównywany z danymi RAW, zapisywany do pliku tymczasowego, synchronizowany i odczytywany ponownie, zanim", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Pomoc ▸ Informacje o systemie i Ustawienia ▸ Wydajność wyjaśniają, dlaczego procesor graficzny nie jest", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(np. D750, D780, D850, D7500, Z 50) nie wyświetlają się już niemal czarne ani z utraconymi szczegółami w cieniach: ich poziom czerni był", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(zgłoszenie #94): żądanie HTTP ze strony internetowej nie może już przekazać polecenia w treści. Długość wierszy ograniczono do", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 na studio-mac”) zamiast pozwalać obu procesom zapisywać i po cichu gubić wzajemne zmiany. Awaria nigdy nie pozostawia", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "podgląd 1620 × 1080 i są importowane wraz z metadanymi: datą wykonania, aparatem, obiektywem, ekspozycją, GPS i XMP. Ich dane RAW", + "2026-04 or 2026-04-12": "2026-04 lub 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nowe wbudowane ustawienia predefiniowane: Portret, Krajobraz, Miasto, Jedzenie, Pory roku, Vintage oraz tonowanie czarno-białe.", + "4 MiB and connections at 16.": "4 MiB, a liczbę połączeń do 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630): w systemie Windows okno i renderowanie GPU korzystają wyłącznie z DirectX 12 i nigdy nie ładują sterownika Vulkan, chyba że", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Zmiana, której nie można zapisać na dysku (pełny lub odłączony nośnik), nie pozostaje już bez sygnału: polecenie zgłasza", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Jedna biblioteka może być otwarta tylko w jednym programie naraz (zgłoszenie #99): próba otwarcia biblioteki używanej przez LightCraft lub `lightcraft-cli`", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Zapis przerwany w połowie (pełny dysk, zerwane połączenie z udziałem sieciowym) nie sprawia już wrażenia uszkodzonego katalogu", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Uśpiony NAS, niedostępny udział sieciowy albo uruchamiający się dysk USB nie blokują już okna (zgłoszenie #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Maski AI wykorzystujące SAM 3 (Obiekt i Opis w panelu Maskowanie): kliknij obiekt, aby go zaznaczyć (kliknięcie z ⌥ pozostawia", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Wersja przeglądarkowa (eksperymentalna), ochrona biblioteki (zgłoszenie #107): Plik ▸ Utwórz kopię zapasową biblioteki… pobiera katalog", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Zdjęcia Canon CR2 z aparatów EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II i 1D Mark IV (oraz innych", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Pliki Canon CR3 pokazują pełnowymiarowy podgląd JPEG osadzony w pliku (np. 6960 × 4640 w EOS R6 Mark III), zamiast", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R zachowuje zdjęcie, ale usuwa jego edycje).", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Skompresowane pliki Nikon NEF (kompresja bezstratna i stratna, 12 i 14 bitów — np. D3200, D5100, D7000, D750, D850, Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Konwersja do DNG, Kopiuj jako DNG, Łączenie zdjęć i podglądy inteligentne nie zapisują już bezpośrednio do pliku docelowego (zgłoszenie", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ustawienie krawędzi i uzyskaj ostrzejszy podgląd po powiększeniu, obliczany w tle. Model działa wewnątrz LightCraft w czystym Ruście", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Wyeksportowane pliki zapisuje się do pliku tymczasowego, a następnie zmienia ich nazwę na docelową, dzięki czemu pełny lub odłączony dysk", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Eksport i renderowanie nigdy nie nadpisują oryginału zdjęcia (zgłoszenie #93): eksport do folderu ze zdjęciem przy", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Eksporty nie są już czarne z powodu GPU (zgłoszenie #78): renderowanie GPU, któremu zabraknie pamięci urządzenia", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Szybszy eksport i import z kart, przy zachowaniu ochrony danych nieodtwarzalnych (zgłoszenie #134): eksporty,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importuj ustawienia predefiniowane z innych edytorów: ustawienia XMP, klasyczne pliki `.lrtemplate`, „ustawienia DNG” z aplikacji mobilnych i archiwa `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import ▸ Kopiuj weryfikuje każdą kopię, podobnie jak Przenieś (zgłoszenie #96): każdy plik jest zapisywany jako nowy, synchronizowany z dyskiem i", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import ▸ Przenieś: zdjęcia trafiają do folderu docelowego (z takim samym układem folderów i zmianą nazw jak przy kopiowaniu, np.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: kopiowanie do dowolnego folderu, podział według dni lub miesięcy albo do jednego folderu, zmiana nazw przy imporcie, zestaw metadanych, kopiowanie jako DNG.", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "Plik JPEG o tej samej nazwie (`IMG_0001.CR3` i `IMG_0001.JPG`) nie współdzieli już jednego pliku XMP: RAW zachowuje `IMG_0001.xmp`,", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft nie zawiesza się już podczas uruchamiania na komputerach Windows z uszkodzonym sterownikiem Vulkan (zgłoszenie #136, np. niektóre Intel UHD", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Style Luminar: pliki `.lmp` i kolekcje `.mplumpack` są importowane jako ustawienia predefiniowane (grupowane według kolekcji); suwaki", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Warunki licencji firmy Meta, a nie LightCraft. Pobranie oznacza ich akceptację.", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Przenieś: pliki są przenoszone do folderu poniżej; oryginał (wraz z plikiem XMP) jest usuwany ze źródła dopiero po weryfikacji kopii. Duplikaty i pliki z błędami pozostają na miejscu.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Pliki Nikon NEF otrzymują wstępne kolory i tonalność dopasowane do JPEG z aparatu, podobnie jak Sony ARW, zamiast wyblakłego,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Pliki Nikon podzielone na dwie części o różnych rodzajach kompresji nadal korzystają na razie z podglądu).", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Maski Obiekt i Opis korzystają z modelu segmentacji SAM 3 firmy Meta. Nie jest on częścią LightCraft; wszystkie pozostałe funkcje działają bez niego.", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Pliki RAW Panasonic i Leica (RW2, RWL i starsze RAW, od DMC-LX1 do DC-S1R II) są wywoływane z danych RAW w", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Zdjęcie. Pasek zdjęć ma także menu kontekstowe. Ponowne dodanie pliku znajdującego się w Ostatnio usuniętych nie", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Zdjęcie ▸ Edytuj w edytorze zewnętrznym (⇧⌘E) tworzy 16-bitową kopię TIFF, grupuje ją z oryginałem i otwiera w wybranym programie (nazwa aplikacji w macOS, ścieżka programu w pozostałych systemach).", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Zdjęcia w Ostatnio usuniętych można przywrócić w aplikacji: kliknij prawym przyciskiem ▸ Przywróć (lub Usuń trwale), również w", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Zdjęcia widoczne w widoku Lokalnie, lecz nigdy niedodane ani niezmienione, są usuwane z katalogu, jeśli ich folder nie był przeglądany przez określony czas (sprawdzane przy otwieraniu biblioteki). Pliki pozostają na dysku; ponowne przejrzenie folderu znów je pokazuje.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Zdjęcia pojawiające się w tym folderze (z aparatu podłączonego do komputera, skanera lub aplikacji synchronizującej) są dodawane od razu po ukończeniu zapisu.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Zdjęcia); importowanie (także przeciąganiem), Znajdź brakujące zdjęcia, Zbuduj / Odrzuć podglądy inteligentne, podgląd zmiany nazw,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Kolekcja podręczna i album docelowy (B w siatce), zestawy słów kluczowych (⌥1–⌥9), zestawy etykiet kolorów.", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Zmień nazwę folderu… i Przenieś folder do… (Lokalnie) można cofnąć i ponowić (zgłoszenie #97): folder wraca na dysku wraz ze", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Zmień nazwy zdjęć nigdy nie nadpisuje innego zdjęcia przy zmianie wyłącznie wielkości liter (zgłoszenie #95): w systemach z rozróżnianiem", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Zmień nazwy zdjęć zgłasza pliki, których nie udało się przywrócić po błędzie (zgłoszenie #105), np. po zerwaniu połączenia z udziałem sieciowym", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Zapis metadanych do pliku XMP utworzonego przez inną aplikację nie zastępuje już jego zawartości (zgłoszenie #92): LightCraft scala swoje", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Pliki ustawień nie są już po cichu resetowane (zgłoszenie #103): uszkodzone `prefs.json`, `presets.json` lub `view.json` zachowuje się", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Mniejsze i szybsze katalogi: zdjęcia jedynie przeglądane w widoku Lokalnie (niedodane, nieocenione i nieedytowane) są zapominane po", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Pliki Sony ARW sprzed około 2017 r. (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II i pokrewne", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni RGB,", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Znaczniki: zobacz Znaczniki obok szablonu. Nazwy plików na dysku (wraz z plikami XMP) są zmieniane; kolizje otrzymują przyrostek -1, -2…", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatyczny import z obserwowanego folderu; Konwertuj do DNG; Duplikuj; Zbuduj podglądy standardowe / 1:1 / inteligentne.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Bez zapisywania i Zakończ (zgłoszenie #100). Program nie uruchamia już po cichu sesji demonstracyjnej wyglądającej jak zresetowana biblioteka, przez co", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) wraz z ich plikami XMP; każdy oryginał znika z karty dopiero po", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; na woluminach nierozróżniających wielkości liter zmiana wielkości liter również przechodzi poprawnie.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "pełny dysk) nie jest już uznawany za gotowy, a funkcja Zbuduj podglądy inteligentne zastępuje go.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "potem (zgłoszenie #101): niepełny zapis jest obcinany przed ponowieniem przez LightCraft, więc przy następnym uruchomieniu odtwarzane są wszystkie", + "again. Change the period (or turn it off) in Settings → Performance.": "ponownie. Zmień ten okres (lub wyłącz go) w Ustawienia → Wydajność.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "z oryginałami biblioteki nie skanuje już całej biblioteki, jeśli pod wskazaną ścieżką nie ma żadnych plików.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ma już folder `thumbs/` z innymi zdjęciami — nie tracą one danych przy przycinaniu lub czyszczeniu pamięci podręcznej.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "i sprawdza plik przed użyciem. Maski zachowują swoje zaznaczenie, więc renderowanie i eksport działają także bez modelu.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "i każde importowane zdjęcie w jednym archiwum ZIP, a Plik ▸ Przywróć bibliotekę z kopii zapasowej… przywraca je (bieżąca biblioteka", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "oraz nie blokuje okna. Funkcja jest opcjonalna i nie należy do LightCraft (licencja SAM firmy Meta, około 3,4 GB):", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "jako `<name>.corrupt-<time>` i informuje o tym; plik, którego nie można odczytać (np. zablokowany przez inny program), pozostaje bez zmian", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "gdy o to poprosisz. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (lub `WGPU_BACKEND` biblioteki wgpu, którą renderowanie GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "pakiety zawierające dowolne z nich — całe foldery naraz, grupowane według pakietu. Maski zawarte w ustawieniach również są przenoszone.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "zapis aparatu nie jest już na stałe stosowany do obrazu. Zdjęcia wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu. (Pliki, których", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "takiej zmiany. Katalogi zawierające już taki fragment wczytują się w całości. Zamykanie programu, gdy dziennika katalogu nie da się zapisać", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "zmian nie udało się zapisać, podejmuje jeszcze jedną próbę, a następnie pyta: Spróbuj zapisać ponownie, Zakończ mimo to lub Anuluj.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "przez kanał sterujący lub MCP, ścieżka docelowa łączenia albo `lightcraft-cli render IMG.jpg -o IMG.jpg` są odrzucane z czytelnym", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "weryfikowane względem zawartości odczytanej z karty. Nieudaną lub różniącą się kopię usuwa się i zgłasza jako nieudany", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "kolory i tonację dopasowane do JPEG z aparatu, podobnie jak w plikach Nikon i Sony RAW, z kadrem zgodnym z proporcjami ustawionymi w", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "kopia została zweryfikowana i dodana do biblioteki. Duplikaty i pliki z błędami pozostają na swoim miejscu.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dane nie są jeszcze dekodowane, więc pozostają dostępne tylko jako podgląd. Dla plików CR3 zaimportowanych wcześniej funkcja Zdjęcie ▸ Wczytaj ponownie z dysku (teraz w", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "usuwane są krawędzie, a „12-bitowe nieskompresowane” pliki nie są już przycinane tonalnie. Seria RX100 wyświetla się znacznie bliżej", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "każdy format zapisywany przez aparaty. Większość modeli wcześniej otwierała tylko podgląd: GH1–GH5, serie G, GX, GF, GM, FZ, LX i", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "eksport. Katalog, pliki XMP, ustawienia, DNG, połączone zdjęcia, podglądy inteligentne i kopie utworzone podczas importu nadal są", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "pola i zachowuje pozostałe — np. ustawienia wywoływania oraz historię edycji z tamtej aplikacji — bajt po bajcie. Plik", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "pliku (Sony przechowuje je w tych modelach jedynie w zaszyfrowanych danych producenta), kilka kolumn wyrównania przy prawej", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "przy pierwszym użyciu maski AI LightCraft prosi o zgodę na pobranie, pokazuje postęp, pozwala anulować i wznowić,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "na czas sesji, zamiast nadpisywać je wartościami domyślnymi. Ustawienia aplikacji (`ui.json`, które zapamiętują", + "gets -1, -2… instead of being replaced.": "otrzymuje przyrostek -1, -2… zamiast zostać zastąpiony.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "zielonkawego, neutralnego obrazu (zgłoszenie #150); balans bieli korygowany jest względem wyglądu zapisanego przez aparat. Pliki NEF 12-bitowe", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "jest otwarta — na tym komputerze lub innym współdzielącym folder — wskazuje kto ją otworzył („już otwarta w LightCraft (proces", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "jak je przywrócić albo trwale usunąć i ponownie zaimportować. (Aby zacząć od nowa z danym zdjęciem, użyj Resetuj edycje,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "wcześniej ignorowany) wybiera zaplecze graficzne; `off` renderuje na CPU. GPU uruchamia się teraz po otwarciu okna", + "immutable, and HOSTING.md describes the actual build.": "i niezmienne, a HOSTING.md opisuje rzeczywistą konfigurację kompilacji.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importu — dlatego „import ukończony” oznacza zweryfikowane kopie, zanim sformatujesz kartę — a zajęta nazwa", + "imported as \"preview only\" switch over on Reload.": "wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "w krótkich partiach, aby mechanizmy nadzoru wolnych zintegrowanych GPU nie resetowały urządzenia. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "niedokończone renderowanie jest powtarzane na CPU — plik wynikowy zawiera ten sam obraz w obu przypadkach. Praca jest wysyłana do GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "jest zachowywany). Nieudany zapis (pełny nośnik) wyświetla teraz ostrzeżenie o niezapisanych zmianach i jest ponawiany; zdjęcie, którego nie można zapisać", + "is refused and nothing is overwritten.": "jest odrzucana i nic nie zostaje nadpisane.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "działa, i tylko jeśli w Ustawienia ▸ Wydajność włączono używanie GPU do renderowania; jeśli LightCraft kiedykolwiek zakończy się awaryjnie podczas", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "nie zostaje dodana, druga karta pokazuje komunikat zamiast nadpisywać pierwszą, `?reset` najpierw pyta, a strona informuje", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "z dala od drugiego pliku (zgłoszenie #92). Znajdź brakujące zdjęcia wyszukuje również pliki o zmienionych nazwach na podstawie zawartości, preferuje zgodność zawartości", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ego zdjęcia i pliki XMP, a zdjęcia ponownie na niego wskazują. Jeśli stare miejsce jest już zajęte, cofnięcie", + "layout is read from each file instead of assumed.": "układ jest odczytywany z każdego pliku, zamiast przyjmować go z góry.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "nie pozostawia pliku DNG i zachowuje RAW. Podglądy inteligentne zapisuje się tak samo; uszkodzony podgląd (ucięty przez awarię lub", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteki) są zapisywane atomowo i natychmiast po otwarciu innej biblioteki, nie tylko przy wychodzeniu. Zamykanie programu, gdy", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "nie ogranicza się już do komunikatu „pominięto duplikat”: otwiera panel boczny Ostatnio usunięte, zaznacza zdjęcie i informuje,", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "wszystko zostało utracone przy wyjściu; sesja tymczasowa przez cały czas pokazuje baner i nigdy nie zapisuje w Twojej bibliotece.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "pamięci, przekroczy limity bufora GPU, napotka błąd lub reset sterownika albo zwróci", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "komunikatem, a oryginał pozostaje niezmieniony bajt po bajcie. Wcześniejsze zwykłe eksporty nadal są nadpisywane na żądanie.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "w trakcie partii: błąd wymienia pliki (stara → nowa nazwa), a biblioteka wskazuje ich nowe nazwy (częściową zmianę nazw można cofnąć),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modele z matrycą zaczynającą się od rzędu zielono-niebieskiego) nie mają już magentowego zafarbu (zgłoszenie #85): układ filtrów barwnych", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nazwę (bez zastępowania pliku), a dopiero potem łączy zdjęcie z plikiem albo usuwa kopię RAW — nieudany zapis", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "nigdy nie pozostawia uciętego pliku; plik XMP eksportu „Oryginał” także respektuje ustawienie „Jeśli plik istnieje”.", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "są teraz wywoływane z danych RAW, zamiast z JPEG osadzonego przez aparat, dzięki czemu styl czarno-biały lub inny ustawiony w", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "na karcie po przeglądzie importu jest zgłaszana, zamiast być importowana z nieaktualnymi danymi. Sprawdzanie ścieżki eksportu", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "pojedynczo — można je zawsze wyeksportować ponownie, a na dysku USB lub NAS synchronizacja każdego pliku osobno znacznie wydłużała duże", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "zamiast pliku o tej samej nazwie i rozmiarze, ale innej zawartości, a niejednoznaczne zdjęcia pomija (i zgłasza), zamiast zgadywać.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "część na zewnątrz) albo wpisz, co zaznaczyć („niebo”, „czerwony samochód”, „samochód, droga”); obie maski można łączyć z innymi, mają", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "z zachowaniem edycji RAW i eksportu w pełnej rozdzielczości, zamiast korzystać z osadzonych podglądów JPEG.", + "read in the wrong units.": "odczytywano w niewłaściwych jednostkach.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "ponowne odczytanie karty po raz trzeci (funkcja Przenieś nadal porównuje dane bajt po bajcie przed usunięciem źródła); plik, który zmienił się", + "reject": "odrzuć", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "renderowane obrazy i zrzuty ekranu nadal trafiają najpierw do pliku tymczasowego, potem otrzymują nazwę docelową, ale nie są już wymuszane zapisy na dysk", + "save goes through.": "zapis powiedzie się.", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "pokrewne modele) nie mają już jaskrawozielonych zdjęć (zgłoszenie #148): balans bieli i poziom czerni zapisane przez aparat odczytuje się z", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "plik XMP, który nie jest prawidłowym XMP, zostaje najpierw skopiowany do `<name>.xmp.bak-<time>`. Przy domyślnym nazewnictwie według podstawy nazwy pliki RAW i", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "żaden plik nie jest oznaczany jako brakujący. Zmiana nazwy jednego z pary RAW + JPEG kopiuje ich wspólny `IMG_0001.xmp`, zamiast zabierać go", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "podczas uruchamiania GPU następny start odbywa się z wyłączonym renderowaniem GPU i informacją, jak je ponownie włączyć.", + "starts with": "zaczyna się od", + "still saves your queued changes in the closing snapshot.": "nadal zapisuje oczekujące zmiany w migawce zamykającej.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronizowane. Import ▸ Kopiuj sprawdza teraz każdą kopię względem skrótu zawartości obliczonego podczas skanowania karty, zamiast", + "the JPEG uses `IMG_0001.JPG.xmp`.": "a JPEG korzysta z `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "drzewo folderów Lokalnie i Automatyczny import również odczytują dysk w wątkach roboczych. Okno postępu importu ma przycisk Anuluj.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "menu Zdjęcie i menu kontekstowym zdjęcia) pobiera pełnowymiarowy podgląd i uzupełnia metadane aparatu, których", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "gdy aparat; uszkodzone piksele matrycy są wypełniane. Importowane są również pliki `.rwl` i `.raw`. Zdjęcia wcześniej", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "tej samej nazwie i wybraniu „Nadpisz” (lub ponownym eksporcie z poprzednimi ustawieniami), dokładna ścieżka wyjściowa z kanału sterującego", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "ich folder nie był przeglądany przez 30 dni — pliki i XMP pozostają na dysku, a ponowne wejście do folderu je pokazuje", + "to the camera's own JPEG.": "wyglądu JPEG prosto z aparatu.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "nienaruszone — starsze wersje nie odczytują już fragmentu jako uszkodzonego dziennika. Gdy ta wersja otworzy bibliotekę,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "użyto (np. pominięty adapter programowy llvmpipe) i dlaczego ostatnie renderowanie przełączono na inne rozwiązanie.", + "video": "wideo", + "were missing, without touching anything already set.": "brakowało, bez zmiany już ustawionych wartości.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "gdy przeglądarka może usunąć bibliotekę. Hosting: przykładowe nagłówki pamięci podręcznej nie oznaczają już plików bez skrótów w nazwach jako", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "sprawdzanie obecności oryginałów odbywa się w wątku roboczym (miniatury siatki, panel Informacje, menu zdjęcia, Brakujące", + "with a counterpart here come along, the rest is listed.": "z odpowiednikami w tym programie również zostają przeniesione, pozostałe są wypisane.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "zapisana przez nowszą wersję LightCraft zostaje odrzucona z komunikatem „ta biblioteka została zapisana przez nowszą wersję LightCraft” i pozostawiona", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "zapisana przez nowszą wersję LightCraft), LightCraft wyjaśnia przyczynę i proponuje Spróbuj ponownie, Wybierz inną bibliotekę… lub Kontynuuj", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Ta wersja programu nie może jeszcze pobrać modelu. Aby zainstalować go ręcznie, umieść pliki model.safetensors, vocab.json i merges.txt od Meta (facebook/sam3) w powyższym folderze: funkcje Obiekt i Opis zadziałają, gdy pliki się tam znajdą.", + "denoiseModel.accept": "denoiseModel.accept" +} diff --git a/packaging/crates/ui-egui/src/i18n.rs b/packaging/crates/ui-egui/src/i18n.rs new file mode 100644 index 000000000..998a1fcbe --- /dev/null +++ b/packaging/crates/ui-egui/src/i18n.rs @@ -0,0 +1,1354 @@ +//! Presentation-only localization. Command ids, user names and catalog data remain unchanged. +//! +//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the +//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its +//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry +//! plus its catalogs and the corresponding language command in `menus.rs`. Settings read the +//! locale table, while menus and the control channel share the command mapping. + +use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The language table. +/// +/// The source language comes first and alone: it is the text the UI is authored in, which every +/// other language translates. `name` is written in the language itself (an endonym, so a user who +/// cannot read the current UI language can still find their own). +macro_rules! language_table { + ($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => { + /// A UI language. + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] + pub enum Locale { + /// The language the UI is written in. + #[default] + $source, + $($variant,)* + } + + impl Locale { + /// Every language, in menu order (the source language first). + pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*]; + + pub const fn code(self) -> &'static str { + match self { Self::$source => $source_code, $(Self::$variant => $code,)* } + } + + /// The language's own name, as shown in the Language menus. + pub const fn name(self) -> &'static str { + match self { Self::$source => "English", $(Self::$variant => $name,)* } + } + + /// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…): + /// the craft-fonts faces for it are the ones this language needs. + pub const fn script(self) -> &'static str { + match self { Self::$source => "Latn", $(Self::$variant => $script,)* } + } + + /// The language's embedded catalog, parsed once. The source language has none: its + /// lookups return the source text itself. + fn catalog(self) -> &'static BTreeMap<String, String> { + static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new(); + static EMPTY: OnceLock<BTreeMap<String, String>> = OnceLock::new(); + // A malformed catalog logs and comes back empty: a broken translation degrades to + // English text, it never takes the app down. + let parse = |catalog: Option<&'static str>| match catalog { + Some(json) => match serde_json::from_str(json) { + Ok(messages) => messages, + Err(error) => { + log::error!("Invalid message catalog for {}: {error}", self.code()); + BTreeMap::new() + } + }, + None => BTreeMap::new(), + }; + CATALOGS + .get_or_init(|| { + Locale::ALL + .iter() + .map(|language| { + let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None }; + (*language, parse(catalog)) + }) + .collect() + }) + .get(&self) + .unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new)) + } + + pub fn parse(code: &str) -> Option<Self> { + match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None } + } + + /// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a + /// shipped language. A region or script the table does not ship falls back to the + /// language itself, so a user is never left with no language at all. + pub fn parse_tag(tag: &str) -> Option<Self> { + let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-"); + let lower = tag.to_ascii_lowercase(); + if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) { + return Some(*exact); + } + let mut parts = lower.split('-'); + let language = parts.next()?; + // `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language. + let script = parts.next().and_then(|part| match part { + "hans" | "cn" | "sg" => Some("Hans"), + "hant" | "tw" | "hk" | "mo" => Some("Hant"), + _ => None, + }); + Self::ALL + .iter() + .find(|candidate| { + let base = candidate.code().split('-').next().unwrap_or_default(); + base == language && script.is_none_or(|script| candidate.script() == script) + }) + .copied() + } + + pub fn tr(self, source: &str) -> &str { + tr_in(self, source) + } + } + }; +} + +// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded +// catalog (`locales/<code>.json`). +language_table! { + En, "en"; + ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); + ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); + Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); + PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json"); + Es, "es", "Español", "Latn", include_str!("../locales/es.json"); + De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); + Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); + Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); + Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json"); +} + +// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a +// language can be renamed in code without invalidating anyone's settings. +impl Serialize for Locale { + fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(self.code()) + } +} + +impl<'de> Deserialize<'de> for Locale { + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let code = String::deserialize(deserializer)?; + Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}"))) + } +} + +thread_local! { + static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) }; +} + +/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs. +pub fn default_language() -> Locale { + std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En) +} + +pub fn set_language(language: Locale) { + LOCALE.with(|value| value.set(language)); +} + +pub fn language() -> Locale { + LOCALE.with(std::cell::Cell::get) +} + +/// Translate a built-in display label, preserving unknown labels verbatim. +/// Never call this on editable user text, filenames or command identifiers. +pub fn tr(source: &str) -> &str { + tr_in(language(), source) +} + +/// [`tr`] for a message whose translation depends on where it appears: a catalog entry keyed +/// `"<context>|<source>"` wins, else the plain message. A language adds the contextual entry only +/// where its shared translation reads wrongly there (Japanese はい / いいえ as a rule's value). +fn tr_ctx_in<'a>(language: Locale, context: &str, source: &'a str) -> &'a str { + match language.catalog().get(&format!("{context}|{source}")) { + Some(translation) => translation.as_str(), + None => tr_in(language, source), + } +} + +/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed +/// out for as long as the `'static` key it was looked up by. +fn tr_in(language: Locale, source: &str) -> &str { + if language.catalog().is_empty() { + return source; + } + thread_local! { + static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = const { RefCell::new(BTreeMap::new()) }; + } + VERBATIM.with_borrow_mut(|cache| { + let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language)); + verbatim.get(source).copied().unwrap_or(source) + }) +} + +/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow. +fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> { + language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() +} + +// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from +// `locales/*-formats.json`. +include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); + +/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays +/// exactly as its owner wrote it. +/// The title and text of the message box shown when the desktop app's window can't start +/// (issue #260), in the UI language: the error and, when there is one, the log file to look in. +pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) { + let text = match log_file { + Some(path) => tr_format!("LightCraft could not open its window: {e}\n\nThe log file has the details: {path}", e = error, path = path), + None => tr_format!("LightCraft could not open its window: {e}", e = error), + }; + (tr("LightCraft could not start").to_string(), text) +} + +pub fn builtin_label(source: &str, builtin: bool) -> &str { + if builtin { tr(source) } else { source } +} + +/// A profile's name for display: a built-in profile's is translated, an imported LUT's is shown as +/// its file named it (it may happen to match a message, like "Vivid"). +pub fn profile_label<'a>(id: &str, name: &'a str) -> &'a str { + builtin_label(name, lightcraft_engine::presets::profile(id).is_some()) +} + +/// An Activity (history) step for display. The step's stored label stays English; generated steps +/// are translated around the names they carry, and a user preset's name is kept verbatim. +pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String { + if let Some(name) = source.strip_prefix("Preset: ") { + // A user preset may share a stock preset's name: then the step could be either, so it + // keeps the name as written. + let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name); + return tr_format!("Preset: {name}", name = builtin_label(name, builtin)); + } + if let Some(name) = source.strip_prefix("Reset ") { + return tr_format!("Reset {name}", name = tr(name)); + } + if let Some(name) = source.strip_prefix("Quick Develop: ") { + return tr_format!("Quick Develop: {name}", name = tr(name)); + } + tr(source).to_string() +} + +/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's +/// name is the user's and stays verbatim. +pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String { + let label = source.label(catalog); + if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() } +} + +/// What the grid is titled for the session's source: [`source_label`], or a library folder's last +/// two names (`photos/travel`; the folder's name is the user's and stays verbatim). +pub fn source_title(session: &lightcraft_engine::Session) -> String { + match (session.source, session.library_folder.as_deref()) { + (lightcraft_engine::LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path), + (source, _) => source_label(source, &session.catalog), + } +} + +/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full +/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the +/// catalog's own wording; every other language formats it with its `*-formats.json` date +/// patterns and weekday names. The grouping keys themselves never change. +pub fn date_group_label(key: &str, short: bool) -> String { + let label = lightcraft_catalog::dates::group_label(key); + if language() == Locale::En { + return if short { + match key.len() { + // "September 2026" → "September" + 7 => label.split(' ').next().unwrap_or(&label).to_string(), + // "Sunday, 20 September 2026" → "Sunday, 20" + 10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(), + _ => label, + } + } else { + label + }; + } + let Some(year) = key.get(..4).and_then(|y| y.parse::<u32>().ok()) else { return tr(&label).to_string() }; + if key.len() == 4 { + return tr_format!("{year}", year = year); + } + let Some(month) = key.get(5..7).and_then(|m| m.parse::<u32>().ok()).filter(|m| (1..=12).contains(m)) else { + return tr(&label).to_string(); + }; + if key.len() == 7 { + return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) }; + } + if key.len() == 10 + && let Some(weekday) = lightcraft_catalog::dates::weekday(key) + && let Some(day) = key.get(8..10).and_then(|d| d.parse::<u32>().ok()) + { + let weekday = tr(weekday); + return if short { + tr_format!("{weekday}, {day}", weekday = weekday, day = day) + } else { + tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year) + }; + } + tr(&label).to_string() +} + +/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM"); +/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is. +pub fn display_time(iso: &str) -> String { + if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() { + return lightcraft_catalog::dates::display_time(iso); + } + let date = date_group_label(iso.get(..10).unwrap_or(iso), false); + match iso.get(11..).filter(|time| !time.is_empty()) { + Some(time) => format!("{date} {time}"), + None => date, + } +} + +/// Built-in colour names are translated; custom label names are user data. +pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_catalog::ColorLabel) -> String { + catalog.custom_label_name(label).map_or_else(|| tr(&catalog.label_name(label)).to_string(), str::to_string) +} + +/// A legacy smart-album filter summary, using the same display labels as filter chips. +pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { + if language() == Locale::En { + filter.describe_with(catalog) + } else { + lightcraft_engine::filter_chips(filter, catalog) + .iter() + .map(|chip| crate::panels::chips::display_label(chip, filter, catalog)) + .collect::<Vec<_>>() + .join(" · ") + } +} + +/// What people read for a smart-album rule's problem, under its row: the field's name and the +/// issue, in the current language ("Title: needs something to look for"). +pub fn problem_text(problem: &lightcraft_catalog::rules::Problem) -> String { + problem_text_in(language(), problem) +} + +/// [`problem_text`] with the rule's position first ("#2.1 Rating: …"), for lists away from the +/// editor (the sidebar's tooltip). +pub fn problem_line(problem: &lightcraft_catalog::rules::Problem) -> String { + problem_line_in(language(), problem) +} + +fn problem_text_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { + let issue = tr_in(language, problem.issue.text()); + // Japanese and Chinese catalogs join a label and its text with a full-width colon + let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; + match problem.field.as_deref() { + Some(field) => format!("{}{colon}{issue}", tr_in(language, lightcraft_catalog::rules::field_label(field).unwrap_or(field))), + None => issue.to_string(), + } +} + +fn problem_line_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { + let at: Vec<String> = problem.path.iter().map(|i| i.saturating_add(1).to_string()).collect(); + // a problem with the album filter around the rules has no rule to point at + if at.is_empty() { + return problem_text_in(language, problem); + } + format!("#{} {}", at.join("."), problem_text_in(language, problem)) +} + +/// What people read for smart-album choice `id` of `field` ("Gemeinfrei" for `publicDomain` in +/// German); an id the field doesn't know, as written. +pub fn choice_text<'a>(field: &str, id: &'a str) -> &'a str { + choice_text_in(language(), field, id) +} + +fn choice_text_in<'a>(language: Locale, field: &str, id: &'a str) -> &'a str { + lightcraft_catalog::rules::choice_label(field, id).map_or(id, |label| tr_in(language, label)) +} + +/// What people read for a smart-album yes/no value ("Ja" / "Nein" in German). +pub fn bool_text(b: bool) -> &'static str { + bool_text_in(language(), b) +} + +/// The context of a smart-album yes/no value ([`tr_ctx_in`]). +const BOOL_CONTEXT: &str = "smart album value"; + +fn bool_text_in(language: Locale, b: bool) -> &'static str { + tr_ctx_in(language, BOOL_CONTEXT, lightcraft_catalog::rules::bool_label(b)) +} + +/// A rule summary for display, keeping free-text rule values verbatim. +pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_catalog::Catalog) -> String { + fn describe(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_catalog::Catalog, depth: usize) -> String { + use lightcraft_catalog::{ + Match, Rule, + rules::{FIELDS, Kind, ops_for}, + }; + if depth >= 16 { + return tr("Nested group").to_string(); + } + let parts: Vec<String> = rules + .rules + .iter() + .map(|rule| match rule { + Rule::Group { group } => format!("({})", describe(group, catalog, depth + 1)), + Rule::Field { field, op, value } => { + let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() }; + let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1); + let text = match kind { + // the album's name, as people know it (a user's name is never translated) + Kind::Album => lightcraft_catalog::rules::album_name(value, Some(catalog)), + Kind::Choice(_) => value.as_str().map_or_else(|| value.to_string(), |id| choice_text(field, id).to_string()), + Kind::Bool => lightcraft_catalog::rules::bool_value(value).map_or_else(|| value.to_string(), |b| bool_text(b).to_string()), + _ if matches!(op.as_str(), "inLast" | "notInLast") => format!( + "{} {}", + value.get("n").unwrap_or(&serde_json::Value::Null), + tr(value.get("unit").and_then(serde_json::Value::as_str).unwrap_or("days")) + ), + _ => value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }), + }; + format!("{} {} {text}", tr(label), tr(operator)).trim().to_string() + } + }) + .collect(); + let join = match rules.mode { + Match::All => tr("and"), + Match::Any | Match::None => tr("or"), + }; + let text = parts.join(&format!(" {join} ")); + if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text } + } + // Preserve the established source-language summary. + if language() == Locale::En { rules.describe_with(catalog) } else { describe(rules, catalog, 0) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_language_has_a_catalog_covering_the_source() { + assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); + for language in Locale::ALL { + assert!(!language.name().is_empty(), "{language:?} has a name"); + assert!(!language.script().is_empty(), "{language:?} has a script"); + assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); + if *language == Locale::En { + continue; + } + let messages = language.catalog(); + assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code()); + for (key, value) in messages { + assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}"); + } + } + } + + /// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover + /// different sets of messages (a message a language lacks shows in English): the gaps are + /// reported, not failed, so a translation can grow at its own pace. + #[test] + fn catalogs_agree_on_placeholders_and_report_gaps() { + let fields = |text: &str| { + let mut out: Vec<String> = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); + rest = &rest[start + end + 1..]; + } + out.sort(); + out + }; + let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); + let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect(); + for language in &translated { + let catalog = language.catalog(); + for (key, value) in catalog { + assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); + } + let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !missing.is_empty() { + eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len()); + } + } + } + + /// Locale tags are matched leniently, so a system locale reaches a shipped language. + /// The message box for a desktop start that failed (issue #260) names the error and the log + /// file, in the UI language. + #[test] + fn startup_failure_message_names_the_error_and_the_log() { + let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log")); + assert_eq!(title, "LightCraft could not start"); + assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}"); + assert!(!startup_failed_message("no adapter", None).1.contains("log file")); + } + + #[test] + fn locale_tags_normalize() { + assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); + assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); + assert_eq!(Locale::parse_tag("fr"), Some(Locale::Fr)); + assert_eq!(Locale::parse_tag("fr-FR"), Some(Locale::Fr)); + assert_eq!(Locale::parse_tag("fr_FR.UTF-8"), Some(Locale::Fr)); + for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}"); + } + for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}"); + } + for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); + } + assert_eq!(Locale::parse_tag("xx"), None); + } + + #[test] + fn switching_language_translates_and_restores() { + set_language(Locale::ZhHans); + assert_eq!(tr("Exposure"), "曝光"); + assert_eq!(tr("Settings"), "设置"); + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + set_language(Locale::Ja); + assert_eq!(tr("Exposure"), "露出"); + set_language(Locale::PtBr); + assert_eq!(tr("Exposure"), "Exposição"); + assert_eq!(tr("Settings"), "Configurações"); + set_language(Locale::En); + assert_eq!(tr("Exposure"), "Exposure"); + } + + /// Format strings carry their values in every language, and a language without a translation + /// falls back to the English format rather than dropping the values. + #[test] + fn formats_render_in_every_language() { + // Every language's format string is checked by `format!` against the call site's values. + set_language(Locale::En); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); + set_language(Locale::ZhHans); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片"); + set_language(Locale::Ja); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); + set_language(Locale::Fr); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 photos importée(s)"); + assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 photo importée(s)"); + // Format specs survive translation: precision, sign, and values a language reorders. + for language in Locale::ALL { + set_language(*language); + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + } + set_language(Locale::En); + assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); + } + + /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation + /// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK + /// catalogs are skipped (the same degradation the Japanese UI has always had). + #[test] + fn catalog_characters_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + for language in Locale::ALL { + let messages = language.catalog(); + if messages.is_empty() { + continue; + } + // The faces this language's script needs; a language the craft-fonts input does not + // cover (a translation added ahead of its font) is reported, not failed. + let mut faces: Vec<&str> = + lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); + faces.dedup(); + if faces.is_empty() { + eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family.clone()); + let mut missing: Vec<char> = messages + .values() + .flat_map(|message| message.chars()) + .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) + .collect(); + missing.sort_unstable(); + missing.dedup(); + assert!( + missing.is_empty(), + "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", + language.code(), + missing.iter().collect::<String>() + ); + } + }); + } + } + + /// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a + /// translation must not carry a symbol no installed font has (egui's defaults cover the rest). + #[test] + fn interface_symbols_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). + ctx.fonts_mut(|fonts| { + for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); + } + }); + } + + #[test] + fn translated_catalogs_do_not_use_missing_menu_triangles() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|ext| ext != "json") { + continue; + } + let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + for (source, text) in messages { + assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display()); + } + } + } + + /// The language menu covers every language, and a language's own command selects it. + #[test] + fn language_commands_cover_every_language() { + let commands = [ + ("app.language.traditionalChinese", Locale::ZhHant), + ("app.language.english", Locale::En), + ("app.language.simplifiedChinese", Locale::ZhHans), + ("app.language.japanese", Locale::Ja), + ("app.language.portuguese", Locale::PtBr), + ("app.language.french", Locale::Fr), + ("app.language.spanish", Locale::Es), + ("app.language.german", Locale::De), + ("app.language.russian", Locale::Ru), + ("app.language.ukrainian", Locale::Uk), + ("app.language.polish", Locale::Pl), + ]; + // One command per language, and every command reachable from the menu table. + assert_eq!(commands.len(), Locale::ALL.len()); + for (id, language) in commands { + assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); + assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); + } + assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); + assert_eq!(crate::menus::language_from_command("view.detail"), None); + } + + #[test] + fn preferences_round_trip_and_old_settings_remain_readable() { + let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(old.language, Locale::En); + // Settings written before the language list grew still load. + let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); + assert_eq!(legacy.language, Locale::Ja); + let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; + let saved = serde_json::to_string(&settings).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Locale::ZhHans); + } + + #[test] + fn catalogs_contain_core_workflows() { + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { + let value = language.tr(key); + assert!(!value.is_empty() && value != key, "{language:?}: {key}"); + } + } + } + + /// Smart-album choice values read as words in every language (English "Public Domain", German + /// "Gemeinfrei"), never as rule ids like `publicDomain`; an id the field doesn't know shows as + /// written. Unlike other labels these must be translated: an untranslated one would be English + /// in the middle of a translated rule. + #[test] + fn choice_values_read_as_words_in_every_language() { + use lightcraft_catalog::rules::{FIELDS, Kind}; + assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); + assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "publicDomain"), "Gemeinfrei"); + assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "someday"), "someday"); + assert_eq!(choice_text_in(Locale::ZhHans, "treatment", "color"), "彩色", "in colour, not the colour noun"); + assert_eq!(choice_text_in(Locale::Ru, "treatment", "color"), "Цветное"); + assert_eq!(choice_text_in(Locale::Es, "label", "none"), "Sin etiqueta"); + // yes/no fields read Yes / No, never true / false + assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); + assert_eq!(bool_text_in(Locale::De, false), "Nein"); + // Japanese answers はい / いいえ don't read as a rule's value: its own wording, あり / なし, + // through a context, while the shared Yes / No keep their meaning everywhere else + assert_eq!((bool_text_in(Locale::Ja, true), bool_text_in(Locale::Ja, false)), ("あり", "なし")); + assert_eq!((Locale::Ja.tr("Yes"), Locale::Ja.tr("No")), ("はい", "いいえ")); + assert_eq!(tr_ctx_in(Locale::Ja, "nope", "Yes"), "はい", "no contextual entry: the plain message"); + assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); + // and the yes/no fields are named by plain nouns, so "編集 … なし" doesn't read "edits: yes … none" + for (field, label, kind) in FIELDS { + if *kind != Kind::Bool { + continue; + } + let ja = Locale::Ja.catalog().get(*label).unwrap_or_else(|| panic!("ja lacks the {field} label {label:?}")); + assert!(!ja.contains("あり") && !ja.contains("なし"), "{field}: {ja}"); + } + // a contextual entry names a real value (a typo would silently fall back to はい / いいえ) + let values = [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)]; + for language in Locale::ALL { + for key in language.catalog().keys() { + if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { + assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); + } + } + } + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + for label in [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)] { + assert!(language.catalog().contains_key(label), "{} lacks {label:?}", language.code()); + } + } + for language in Locale::ALL { + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, label) in *choices { + let text = choice_text_in(*language, field, id); + assert_ne!(text, *id, "{}: {field} {id}", language.code()); + if *language != Locale::En { + assert!(language.catalog().contains_key(*label), "{} lacks {label:?}", language.code()); + } + } + } + } + } + + /// A rule's problem reads as the field's name and the issue, in the editor's language ("Titel: + /// braucht einen Suchbegriff …"); the sidebar's list puts the rule's position first. Every issue + /// is translated in every language: an English one would stand out in a translated editor. + #[test] + fn problems_read_in_every_language() { + use lightcraft_catalog::rules::Issue; + let cat = lightcraft_catalog::Catalog::new(); + let problems = |rules: serde_json::Value| { + serde_json::from_value::<lightcraft_catalog::RuleSet>(serde_json::json!({"rules": rules})).unwrap().check(&cat) + }; + let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); + assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); + assert_eq!(problem_text_in(Locale::De, &p[0]), "Titel: braucht einen Suchbegriff (oder „ist leer“)"); + assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); + // Japanese and Chinese join with a full-width colon, as their catalogs do + assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); + // a problem with the album filter itself has no rule position to show + let filter_loop = + lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; + assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); + let g = problems(serde_json::json!([{"group": {"rules": []}}])); + assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); + // the issues, the operators their hints name and the album picker's words, in every language + for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { + // the field menu: every field and every group, so it never mixes languages + for label in lightcraft_catalog::rules::FIELDS.iter().map(|f| f.1).chain(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|g| g.0)) { + assert!(language.catalog().contains_key(label), "{} lacks the rule label {label:?}", language.code()); + } + for word in [ + "Choose an album…", + "No albums yet", + "Search albums", + "No albums match", + "This is the album you're editing", + "It tests this album, so testing it back would loop", + "Fix the marked rules to save this album.", + ] { + assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); + } + for issue in Issue::ALL { + assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); + } + for (_, _, kind) in lightcraft_catalog::rules::FIELDS { + for (_, op) in lightcraft_catalog::rules::ops_for(*kind) { + assert!(language.catalog().contains_key(*op), "{} lacks the operator {op:?}", language.code()); + } + } + } + } + + /// The date picker's own words (its tooltip, the weekday headers) are in every language, as the + /// month and day labels it borrows from the date headings are. + #[test] + fn date_picker_words_are_translated() { + for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { + for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { + assert!(language.catalog().contains_key(*word), "{} lacks {word:?}", language.code()); + } + } + } + + /// Ukrainian's coverage of every catalog message and display label. Gaps are reported, not failed + /// (like `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR that adds a label doesn't + /// have to ship its Ukrainian text, which shows in English until the translation catches up. + #[test] + fn ukrainian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::Uk.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("uk lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("uk lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn ukrainian_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.ukrainian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Uk); + assert_eq!(crate::menubar::checked(&app, "app.language.ukrainian"), Some(true)); + assert_eq!(tr("Light"), "Світло"); + assert_eq!(tr("Landscape"), "Пейзаж"); + assert_eq!(tr("Home"), "Домашня тека"); + assert_eq!(date_group_label("2026-09-20", false), "Неділя, 20.09.2026"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Неділя, 20.09.2026 16:04:05"); + // Count-neutral labels work for every Ukrainian integer category, with no English suffix. + for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Імпортовано фото: {n}")); + assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Файлів: {n}, тек: {n}")); + let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); + assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), format!("Кешовані зображення: {n}")); + } + // This trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + assert_eq!( + tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), + "Усунення шуму IMG.CR3: ділянки — 1 із 4 · очікують: 5" + ); + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("До «{name}» додано фото: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Тепле сяйво"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"uk""#)); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Uk); + let ctx = egui::Context::default(); + for code in ["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::Uk)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Uk); + } + set_language(Locale::En); + } + + #[test] + fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::Uk); + assert!(text.contains("Мої фото") && text.contains("Усі фото"), "{text}"); + for (command, title) in [("app.about", "Про LightCraft"), ("app.shortcuts", "Клавіатурні скорочення"), ("app.settings", "Налаштування")] + { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::Uk); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ҐґЄєІіЇї’«»" + .chars() + .chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + + /// Every command, control, rule and rename label, and every line of What's New, is a message a + /// catalog can translate. What a language lacks is reported, not failed (like + /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every + /// language, and the translations catch up at their own pace. + #[test] + fn display_label_gaps_are_reported() { + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let ui_labels = labels.len(); + for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) { + if line.is_empty() || line.starts_with("# ") { + continue; + } + let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line); + labels.push(label); + } + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + let catalog = language.catalog(); + let missing: Vec<&&str> = labels[..ui_labels].iter().filter(|label| !catalog.contains_key(**label)).collect(); + let notes = labels[ui_labels..].iter().filter(|label| !catalog.contains_key(**label)).count(); + if !missing.is_empty() || notes > 0 { + eprintln!( + "{} lacks {} display label(s) (shown in English): {missing:?}; and {notes} What's New line(s)", + language.code(), + missing.len() + ); + } + } + // the German catalog, which set out to cover them all, still covers every other catalog + let german = Locale::De.catalog(); + let gaps: Vec<&String> = + Locale::ALL.iter().flat_map(|language| language.catalog().keys()).filter(|key| !german.contains_key(key.as_str())).collect(); + if !gaps.is_empty() { + eprintln!("de lacks {} message(s) another catalog has: {gaps:?}", gaps.len()); + } + } + + #[test] + fn german_switches_via_menu_and_control_persists_and_formats_dates() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.german", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::De); + assert_eq!(tr("File"), "Datei"); + assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); + assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026"); + assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20."); + assert_eq!(date_group_label("2026-09", false), "09/2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05"); + assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert"); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"de""#)); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::De); + let ctx = egui::Context::default(); + for code in ["de-DE", "de_AT.UTF-8", "de-CH"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::De); + } + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz"); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }], + }; + let mut catalog = lightcraft_catalog::Catalog::new(); + catalog + .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) + .unwrap(); + assert_eq!(rules_label(&rules, &catalog), "Stichwörter enthält Color"); + let album = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], + }; + assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { + field: "copyrightStatus".into(), + op: "is".into(), + value: serde_json::json!("publicDomain"), + }], + }; + assert!(rules_label(&rules, &catalog).ends_with(" Gemeinfrei"), "{}", rules_label(&rules, &catalog)); + for value in [serde_json::json!(false), serde_json::json!("false")] { + let rules = lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::Match::All, + rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], + }; + assert!(rules_label(&rules, &catalog).ends_with(" Nein"), "{}", rules_label(&rules, &catalog)); + } + let filter = lightcraft_catalog::Filter { + labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], + ..Default::default() + }; + assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau"); + app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); + assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau"); + set_language(Locale::En); + } + + #[test] + fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}"); + app.ui.view = crate::state::ViewMode::People; + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains("Benannte Personen"), "{text}"); + for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::De); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "ÄÖÜäöüß„“".chars() { + assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + + #[test] + fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() { + fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { + match shape { + egui::epaint::Shape::Text(shape) => { + bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size()))) + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)), + _ => {} + } + } + for language in [Locale::De, Locale::Uk] { + let ctx = fonts_ctx(&[]); + let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.language = language; + let mut bounds = Vec::new(); + for export in [false, true] { + if export { + app.run("dialog.export", serde_json::json!({})).unwrap(); + } + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + bounds.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut bounds); + } + } + let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1; + if export { + let dialog = rect("dialog:window"); + for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] { + assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id)); + } + } else { + let button = rect("button:copySettings"); + let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap(); + assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}"); + assert!(!button.intersects(rect("icon:copyGear"))); + } + } + } + set_language(Locale::En); + } + + /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. + #[test] + fn menu_labels_translate_but_user_names_survive() { + for language in Locale::ALL { + set_language(*language); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); + } + set_language(Locale::ZhHans); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); + set_language(Locale::Ja); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); + set_language(Locale::En); + } + + /// The text the whole window paints over a few frames in `language`. + fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { + fn collect(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + app.ui.language = language; + let mut text = String::new(); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // This inspects shapes without a renderer; discard texture uploads explicitly. + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + text + } + + /// Every language is painted by the real window, switching at runtime reinstalls the fonts + /// (their CJK fallback order follows the language), and command ids never change with it. + #[test] + fn every_language_is_painted_and_menu_ids_stay_the_same() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>(); + let english_ids = ids(&app); + for language in Locale::ALL { + let text = painted_text(&ctx, &mut app, *language); + for label in ["My Photos", "All Photos"] { + assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); + } + assert_eq!(app.font_language, *language, "the fonts follow the language"); + assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); + } + let text = painted_text(&ctx, &mut app, Locale::Ja); + assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); + let text = painted_text(&ctx, &mut app, Locale::ZhHans); + assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); + let text = painted_text(&ctx, &mut app, Locale::PtBr); + assert!(text.contains("Minhas fotos") && text.contains("Todas as fotos"), "{text}"); + set_language(Locale::En); + } + + /// The UI families, after one frame so the font definitions are loaded. + fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx + } + + /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI + /// family, Monospace included. + #[test] + fn craft_fonts_render_cjk_in_the_ui() { + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; + for (language, sample) in samples { + if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { + eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + set_language(language); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in + [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] + { + let font = egui::FontId::new(13.0, family); + for ch in sample.chars() { + assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); + let wide = 13.0 * (sample.chars().count() as f32 - 1.0); + assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); + } + }); + } + set_language(Locale::En); + } + + /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; + /// Latin text keeps Inter. + #[test] + fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + for language in Locale::ALL { + app.ui.language = *language; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + } + set_language(Locale::En); + } + + /// Traditional Chinese through the menu command and the control channel, persisted, with + /// localised headings and dates; ids and user text are untouched. + #[test] + fn traditional_chinese_switches_through_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::ZhHant); + // Applied at once, not on the next frame. + assert_eq!(tr("File"), "檔案"); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片"); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); + assert_eq!(date_group_label("2026-09-20", true), "20日 星期日"); + assert_eq!(date_group_label("2026-09", false), "2026年9月"); + assert_eq!(date_group_label("2026-09", true), "9月"); + assert_eq!(date_group_label("2026", false), "2026年"); + assert_eq!(date_group_label("", false), "未知日期"); + assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05"); + assert_eq!(display_time("a-user-value"), "a-user-value"); + assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); + assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { + assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); + } + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩"); + assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::ZhHant); + let ctx = egui::Context::default(); + app.ui.language = Locale::En; + for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], true, "{code}"); + assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); + assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); + } + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.ui.language, Locale::ZhHant); + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>(); + let chinese_ids = ids(&app); + app.run("app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(tr("File"), "File"); + assert_eq!(chinese_ids, ids(&app)); + } + + /// Dates follow every language's own patterns; English keeps the catalog's wording. + #[test] + fn date_headings_in_every_language() { + set_language(Locale::En); + assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); + assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); + assert_eq!(date_group_label("2026-09", true), "September"); + assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); + set_language(Locale::Ja); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)"); + assert_eq!(date_group_label("2026-09", true), "9月"); + set_language(Locale::ZhHans); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); + for language in Locale::ALL { + set_language(*language); + // A key that is not a date never panics and never shows a raw pattern. + for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { + assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); + assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); + } + } + set_language(Locale::En); + } + + /// Stock presets and panel headers are translated; a user preset keeps its name, also when it + /// shares a stock preset's name. + #[test] + fn traditional_chinese_presets_and_panel_headers_are_painted() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.presets = true; + let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); + app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); + for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] { + app.ui.right = panel; + let text = painted_text(&ctx, &mut app, Locale::ZhHant); + assert!(text.contains(title), "{text}"); + assert!(!text.contains("History") && !text.contains("Versions"), "{text}"); + assert!(text.contains("暖光"), "stock preset: {text}"); + assert!(text.contains("Warm Glow"), "user preset: {text}"); + assert!(text.contains("色彩"), "stock group: {text}"); + } + assert_eq!(history_label("Exposure", &app.session.presets), "曝光"); + assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片"); + assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); + set_language(Locale::En); + } +} diff --git a/packaging/crates/ui-egui/src/menus.rs b/packaging/crates/ui-egui/src/menus.rs new file mode 100644 index 000000000..f35eb245c --- /dev/null +++ b/packaging/crates/ui-egui/src/menus.rs @@ -0,0 +1,1715 @@ +//! UI-level commands (views, panels, zoom, tools, dialogs) and the menu model shared by the native +//! menu bar, the shortcut handler and the control channel. + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::pick::{PickRequest, Picked}; +use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; + +/// (id, label, shortcut, menu path) +pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); + +/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language +/// has a matching menu command (also listed through the control channel). Tests check coverage. +pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ + ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), + ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), + ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), + ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), + ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), + ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), + ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), +]; + +/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from +/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared. +pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> { + LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) +} + +/// The language a Language-menu command selects, if the id is one. The engine and the UI both go +/// through here, so the menu, the settings row and the control channel agree on the mapping. +pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { + match id { + "app.language.english" => Some(crate::i18n::Locale::En), + "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), + "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), + "app.language.japanese" => Some(crate::i18n::Locale::Ja), + "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), + "app.language.french" => Some(crate::i18n::Locale::Fr), + "app.language.spanish" => Some(crate::i18n::Locale::Es), + "app.language.german" => Some(crate::i18n::Locale::De), + "app.language.russian" => Some(crate::i18n::Locale::Ru), + "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.polish" => Some(crate::i18n::Locale::Pl), + _ => None, + } +} + +pub const UI_COMMANDS: &[UiCommand] = &[ + ("ui.dock", "Arrange Panel", None, ""), + ("ui.dock.reset", "Reset Panel Layout", None, "Window"), + ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), + ("view.photoGrid", "Photo Grid", None, "View"), + ("view.squareGrid", "Square Grid", None, "View"), + // G: Photo Grid ↔ Square Grid (from other views: the photo grid) + ("view.gridToggle", "Grid", Some("G"), ""), + ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), + ("view.detail", "Detail", Some("D"), "View"), + ("view.compare", "Compare", Some("Shift+C"), "View"), + ("view.survey", "Survey", Some("N"), "View"), + ("view.people", "People", None, "View"), + ("view.person", "Show Person", None, ""), + ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.reference", "Reference View", Some("Shift+R"), "View"), + ("photo.setReference", "Set as Reference Photo", None, ""), + ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.makeSelect", "Make Candidate the Select", None, "View"), + ("view.autoAdvance", "Auto Advance", None, "Photo"), + ("view.filmstrip", "Filmstrip", Some("/"), "View"), + ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), + ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), + ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), + ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), + ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), + ("view.showOriginal", "Show Original", Some("\\"), "View"), + ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), + ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), + ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), + // the ratio a click (and Z / Space) zooms to + ("view.clickZoom", "Click Zoom Ratio", None, ""), + ("view.navigate", "Set Image Zoom and Pan", None, ""), + ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), + ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), + ("view.clipping", "Show Clipping", Some("J"), "View"), + ("view.visualizeHdr", "Visualize HDR", None, "View"), + // in grids S expands/collapses stacks (the engine command it shadows) + ("view.softProof", "Soft Proofing", Some("S"), "View"), + ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), + ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) + ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), + ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), + ("view.maskPins", "Show Mask Pins", None, "View"), + ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), + ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), + ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), + ("view.back", "Back to Grid", Some("Escape"), ""), + ("tool.done", "Done", Some("Enter"), ""), + ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), + ("local.addRoot", "Add Folder to Local", None, ""), + ("local.hide", "Remove from Local", None, ""), + ("local.restoreHidden", "Show Hidden Local Locations", None, ""), + ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), + ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), + ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.navigator", "Navigator", None, "View"), + ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.profiles", "Profile Browser", None, "Window"), + ("panel.crop", "Crop & Rotate", Some("C"), "Window"), + ("panel.remove", "Remove", Some("H"), "Window"), + ("panel.masking", "Masking", Some("M"), "Window"), + ("panel.redeye", "Red Eye", None, "Window"), + ("panel.presets", "Presets", Some("Shift+P"), "Window"), + ("panel.info", "Info", Some("I"), "Window"), + ("panel.keywords", "Keywords", Some("K"), "Window"), + ("panel.versions", "Versions", Some("Shift+V"), "Window"), + ("panel.activity", "History", None, "Window"), + ("panel.close", "Close Panel", None, ""), + ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), + ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), + ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), + ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), + ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + ("tool.brush", "Brush", Some("B"), "Window>Tools"), + ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), + ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), + ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), + ("tool.none", "No Tool", None, ""), + // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) + ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), + ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), + ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), + ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), + ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), + ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), + ("dialog.smartAlbum", "New Smart Album…", None, "File"), + ("view.photoCounts", "Show Photo Counts", None, "View"), + ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), + ("tool.keywordPainter", "Keyword Painter", None, ""), + ("view.gridInfo", "Grid Info", None, ""), + ("dialog.allMetadata", "All Metadata…", None, "Photo"), + ("dialog.faceModel", "Add Face Model…", None, ""), + ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), + ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), + ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), + ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), + ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), + ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), + ("dialog.export", "Export…", None, "File"), + ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), + ("app.contactSheet", "Export Contact Sheet PDF", None, ""), + ("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"), + ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), + ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), + ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), + ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), + ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), + ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), + ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), + ("file.addFolder", "Import from Folder…", None, "File"), + ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), + ("file.addFromDevice", "Import from Device", None, ""), + ("file.findMissing", "Find Missing Photos…", None, "File"), + ("file.backupLibrary", "Back Up Library…", None, "File"), + ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), + ("photo.locate", "Locate Missing File…", None, ""), + ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), + ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), + ("file.importPresets", "Import Profiles & Presets…", None, "File"), + ("file.exportPresets", "Export Presets…", None, "File"), + ("file.importKeywords", "Import Keywords…", None, "File"), + ("file.exportKeywords", "Export Keywords…", None, "File"), + // Edit panel ▸ Curve ▸ Point Curve dropdown + ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), + ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), + ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), + ("app.openLibrary", "Open Library…", None, "File"), + // Settings ▸ Display + ("app.displayProfile", "Display Profile", None, ""), + ("app.about", "About LightCraft", None, "Help"), + ("app.systemInfo", "System Info…", None, "Help"), + ("app.openLogFolder", "Open Log Folder", None, "Help"), + ("app.whatsNew", "What's New", None, "Help"), + ("dialog.cull", "Assisted Culling…", None, "Photo"), + ("app.help", "LightCraft Help", Some("F1"), "Help"), + ("app.discord", "Join the ArtCraft Discord…", None, "Help"), + ("app.feedback", "Send Feedback…", None, "Help"), + ("app.website", "LightCraft Website", None, "Help"), + ("app.github", "LightCraft on GitHub", None, "Help"), + ("app.artcraft", "ArtCraft Website", None, "Help"), + ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), + ("app.setShortcut", "Set Keyboard Shortcut", None, ""), + ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), + ("app.export", "Export Now", None, ""), + ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), + ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), + ("dialog.labelNames", "Edit Color Label Names…", None, ""), + ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), + ("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"), + ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), +]; + +fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { + if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { + app.ui.right = RightPanel::None; + app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); + } else { + app.ui.right = p; + app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); + if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + } + if p != RightPanel::Masking && app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); +} + +/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's +/// (and its selected spot's) or the Masking brush's. +fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { + if app.ui.right == RightPanel::Remove { + app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); + app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); + if app.session.active_spot.is_some() { + let mut p = json!({}); + if k != 1.0 { + p["size"] = json!(app.ui.remove_size); + } + if df != 0.0 { + p["feather"] = json!(app.ui.remove_feather); + } + let _ = app.run("spot.update", p); + } + json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) + } else { + app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); + app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) + } +} + +/// The `parent` folder id of a `dialog.new*` command (none: the top level). +fn parent_param(p: &Value) -> Option<u64> { + p.get("parent").and_then(Value::as_u64) +} + +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { + if let Some(s) = v.as_str() { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 { + return None; + } + let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + return Some([c(0)?, c(2)?, c(4)?]); + } + let a = v.as_array()?; + let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + +/// Handle UI commands; `None` means "not a UI command — send it to the engine". +pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } + if matches!(id, "library.inspectLightroom" | "library.importLightroom") { + // the app's own context: a fresh one's repaints reach no window and its clock starts at zero + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + return Some(crate::lightroom_import::command(app, id, p, &ctx)); + } + if let Some(language) = language_from_command(id) { + app.ui.language = language; + // Immediately, not on the next frame: the reply and anything else run this frame + // (menus rebuilt from it, toasts) are already in the new language. + crate::i18n::set_language(app.ui.language); + return Some(Ok(json!(app.ui.language))); + } + // the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0, + // and a toast set by it was long over by the app's clock: it never showed) + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + let r: Result<Value, String> = match id { + "view.photoGrid" => { + app.ui.view = ViewMode::PhotoGrid; + Ok(Value::Null) + } + "view.squareGrid" => { + app.ui.view = ViewMode::SquareGrid; + Ok(Value::Null) + } + "view.gridInfo" => { + // {info?: filename | exposure | date} (cycles when omitted) + let next = match p.get("info").and_then(Value::as_str) { + Some(i @ ("filename" | "exposure" | "date")) => i.to_string(), + Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))), + None => match app.ui.grid_info.as_str() { + "filename" => "exposure".into(), + "exposure" => "date".into(), + _ => "filename".into(), + }, + }; + app.ui.grid_info = next; + app.ui.show_filenames = true; + Ok(json!({"info": app.ui.grid_info})) + } + "tool.keywordPainter" => { + // {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops + app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string); + if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + if let Some(k) = app.ui.keyword_painter.clone() { + app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k)); + } + Ok(json!({"keyword": app.ui.keyword_painter})) + } + "view.secondWindow" => { + app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); + Ok(json!({"show": app.ui.second_window})) + } + "view.photoCounts" => { + app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); + Ok(json!({"show": app.ui.show_counts})) + } + "view.gridToggle" => { + app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; + Ok(Value::Null) + } + "view.detail" => { + app.ui.view = ViewMode::Detail; + Ok(Value::Null) + } + "view.compare" => crate::panels::compare::enter_compare(app), + "view.faceBoxes" => { + app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); + Ok(json!({"show": app.ui.face_boxes})) + } + "view.people" => { + // Everyone; the page just left stays one click away (the chip next to the title) + if let Some(name) = app.ui.person_page.take() { + app.ui.last_person = Some(name); + } + app.ui.person_from = None; + app.ui.view = ViewMode::People; + Ok(json!({"people": app.session.catalog.people().len()})) + } + "view.person" => { + // {name}: one person's page in the People view: their faces, and the faces that look like them + let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { + return Some(Err("view.person: missing `name`".into())); + }; + app.ui.view = ViewMode::People; + app.ui.person_from = None; + app.ui.person_page = Some(name.to_string()); + Ok(json!({"person": name})) + } + "view.survey" => { + app.ui.view = ViewMode::Survey; + Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) + } + "view.reference" => { + // the reference: the one set before, else the active photo (the next one becomes active) + let vis = app.session.visible_cloned(); + let reference = app + .ui + .reference + .filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some()) + .or_else(|| app.session.active().map(|a| a.0)); + let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) }; + app.ui.reference = Some(r); + if app.session.active().is_none_or(|a| a.0 == r) + && let Some(i) = vis.iter().position(|x| x.0 == r) + && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) + { + let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); + } + app.ui.view = ViewMode::Reference; + Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) + } + "photo.setReference" => { + let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0)); + app.ui.reference = id; + Ok(json!({"reference": id})) + } + "compare.swap" => crate::panels::compare::swap(app), + "compare.makeSelect" => crate::panels::compare::make_select(app), + "view.autoAdvance" => { + app.ui.auto_advance = !app.ui.auto_advance; + app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + Ok(json!({"autoAdvance": app.ui.auto_advance})) + } + "view.back" => { + if app.ui.dialog.is_some() { + app.ui.dialog = None; + } else if app.ui.keyword_painter.is_some() { + app.ui.keyword_painter = None; + } else if app.ui.fullscreen { + app.ui.fullscreen = false; + app.ui.slideshow = None; + } else if !app.ui.tool.is_empty() { + app.ui.tool.clear(); + } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { + app.ui.person_page = None; + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { + app.ui.view = ViewMode::Detail; + } else if app.ui.view == ViewMode::Detail { + // a photo opened from a person's page goes back to that page, any other to the grid + match app.ui.person_from.take() { + Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { + app.ui.view = ViewMode::People; + app.ui.person_page = Some(name); + } + _ => app.ui.view = ViewMode::PhotoGrid, + } + } + Ok(Value::Null) + } + "view.slideshow" => { + // {interval?: seconds (4)}: the photos in view, full screen, one after another + let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0); + if app.session.active().is_none() { + let first = app.session.visible_cloned().first().copied(); + match first { + Some(f) => { + let _ = app.run("library.select", json!({"ids": [f.0]})); + } + None => return Some(Err("no photos to show".into())), + } + } + let now = ctx.input(|i| i.time); + app.ui.slideshow = Some((interval, now + interval, false)); + app.ui.fullscreen = true; + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + Ok(json!({"interval": interval})) + } + "view.fullScreenPreview" => { + app.ui.fullscreen = !app.ui.fullscreen; + if !app.ui.fullscreen { + app.ui.slideshow = None; + } + if app.ui.fullscreen { + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + } + Ok(json!({"fullscreen": app.ui.fullscreen})) + } + "view.enterFullScreen" => { + // applied by the host's frame logic (it knows the window's current state) + let on = p.get("on").and_then(Value::as_bool); + app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); + Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) + } + "view.infoOverlay" => { + app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { + Some(m) => match serde_json::from_value(json!(m)) { + Ok(v) => v, + Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), + }, + None => app.ui.info_overlay.next(), + }; + let label = match app.ui.info_overlay { + crate::state::InfoOverlay::Off => "Info Overlay Off", + crate::state::InfoOverlay::Basic => "Info Overlay: File", + crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", + }; + app.toast(&ctx, label); + Ok(json!({"infoOverlay": app.ui.info_overlay})) + } + "view.navigator" => { + app.ui.navigator = !app.ui.navigator; + Ok(json!({"navigator": app.ui.navigator})) + } + "app.settings" => { + let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); + if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { + let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect(); + return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|")))); + } + app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); + Ok(Value::Null) + } + "app.openLibrary" => crate::panels::settings::open_library(app, p), + "app.displayProfile" => crate::panels::settings::display_profile(app, p), + "file.backupLibrary" | "file.restoreLibrary" => { + let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; + match action { + Some(f) => f(&mut app.session), + None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), + } + } + "view.filmstrip" => { + app.ui.filmstrip = !app.ui.filmstrip; + Ok(Value::Null) + } + "view.leftPanel" => { + app.ui.left_panel = !app.ui.left_panel; + Ok(Value::Null) + } + "view.beforeAfter" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; + Ok(Value::Null) + } + "view.beforeAfterSplit" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split }; + Ok(Value::Null) + } + "view.beforeAfterTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; + Ok(Value::Null) + } + "view.beforeAfterSplitTopBottom" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; + Ok(Value::Null) + } + "view.showOriginal" => { + app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; + Ok(Value::Null) + } + "view.zoomFit" => { + app.ui.zoom = Zoom::Fit; + Ok(Value::Null) + } + "view.zoom100" => { + app.ui.zoom = Zoom::Percent(100.0); + Ok(Value::Null) + } + "view.zoomToggle" => { + // the same ratio a click on the photo zooms to + app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + app.ui.zoom_anim = true; + Ok(Value::Null) + } + "view.clickZoom" => { + // {ratio?: 1|2|3|4|8} → {ratio} + if let Some(r) = p.get("ratio").and_then(Value::as_f64) { + let pct = (r * 100.0).round() as u32; + if !crate::state::CLICK_ZOOMS.contains(&pct) { + return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); + } + app.ui.click_zoom = pct; + } + Ok(json!({"ratio": app.ui.click_zoom / 100})) + } + "view.zoomIn" | "view.zoomOut" => { + let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; + let cur = match app.ui.zoom { + Zoom::Percent(p) => p, + _ => 25.0, + }; + let next = if id == "view.zoomIn" { + steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) + } else { + steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) + }; + app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; + Ok(Value::Null) + } + "view.navigate" => { + // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). + // Validate the complete request before changing either part of the viewport. + let zoom = match p.get("zoom") { + Some(v) => match serde_json::from_value::<Zoom>(v.clone()) { + Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { + return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); + } + Ok(z) => z, + Err(e) => return Some(Err(format!("view.navigate: {e}"))), + }, + None => app.ui.zoom, + }; + let pan = match p.get("pan") { + Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { + Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), + _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), + }, + None => app.ui.pan, + }; + app.ui.zoom = zoom; + app.ui.pan = pan; + app.ui.zoom_anim = false; + Ok(json!({"zoom": zoom, "pan": pan})) + } + "view.clipping" => { + app.ui.show_clipping = !app.ui.show_clipping; + Ok(Value::Null) + } + "view.visualizeHdr" => { + // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). + app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); + Ok(json!({"on": app.ui.hdr_visualize})) + } + "view.softProof" => { + // {on?, space?, destWarning?, displayWarning?}; no params toggles + let has = |k: &str| p.get(k).is_some(); + if let Some(s) = p.get("space").and_then(Value::as_str) { + match lightcraft_engine::pipeline::OutputSpace::parse(s) { + Some(sp) => app.ui.proof.space = sp, + None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), + } + } + if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { + app.ui.proof.dest_warning = b; + } + if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { + app.ui.proof.display_warning = b; + } + app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { + Some(b) => b, + None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof, + None => !app.ui.soft_proof, + }; + if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { + app.ui.view = ViewMode::Detail; + } + let pr = app.ui.proof; + Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) + } + "view.histogram" => { + app.ui.histogram = !app.ui.histogram; + Ok(Value::Null) + } + "view.maskOverlay" => { + app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); + Ok(json!({"maskOverlay": app.ui.mask_overlay})) + } + "view.maskOverlayMode" => { + use lightcraft_engine::pipeline::MaskView; + let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + let next = match p.get("mode").and_then(Value::as_str) { + Some(m) => match MaskView::parse(m) { + Some(v) => v, + None => { + let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); + return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); + } + }, + None => cur.next(), + }; + app.ui.mask_overlay_mode = next.name().into(); + app.ui.mask_overlay = true; + app.toast(&ctx, next.label()); + Ok(json!({"mode": next.name()})) + } + "view.maskOverlayColor" => { + // no params: the next of the panel's swatch colours + if p.get("color").is_none() && p.get("opacity").is_none() { + let all = crate::panels::masking::OVERLAY_COLORS; + let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); + app.ui.mask_overlay_color = all[i]; + } + if let Some(c) = p.get("color") { + match parse_rgb(c) { + Some(rgb) => app.ui.mask_overlay_color = rgb, + None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), + } + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; + } + let [r, g, b] = app.ui.mask_overlay_color; + Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) + } + "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { + let (k, df) = match id { + "brush.smaller" => (1.0 / 1.2, 0.0), + "brush.larger" => (1.2, 0.0), + "brush.featherLess" => (1.0, -10.0), + _ => (1.0, 10.0), + }; + Ok(adjust_brush(app, k, df)) + } + "view.maskPins" => { + app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); + Ok(json!({"maskPins": app.ui.mask_pins})) + } + "view.visualizeSpots" => { + // like Lightroom's A: opens the Remove tool with the view on, or toggles it there + if app.ui.right == RightPanel::Remove { + app.ui.visualize_spots = !app.ui.visualize_spots; + } else { + app.ui.right = RightPanel::Remove; + app.ui.visualize_spots = true; + } + Ok(Value::Null) + } + "view.cropOverlay" => { + use crate::state::CropOverlay::*; + app.ui.crop_overlay = match app.ui.crop_overlay { + Thirds => Grid, + Grid => Golden, + Golden => Diagonal, + Diagonal => Triangle, + Triangle => Spiral, + Spiral => None, + None => Thirds, + }; + Ok(json!({"overlay": app.ui.crop_overlay})) + } + "view.cropOverlayOrientation" => { + app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4; + Ok(json!({"orientation": app.ui.crop_overlay_orient})) + } + "local.addRoot" => { + // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes + let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; + let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); + let abs = if abs.is_empty() { path.to_string() } else { abs }; + if !std::path::Path::new(&abs).is_dir() { + return Some(Err(format!("{path}: not a folder"))); + } + use crate::panels::left::same_folder; + if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { + app.ui.local_roots.push(abs.clone()); + } + app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); + Ok(json!({"roots": app.ui.local_roots})) + } + "local.hide" => match p.get("path").and_then(Value::as_str) { + Some(path) => { + if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { + app.ui.hidden_locations.push(path.to_string()); + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + None => Err("local.hide needs a path".into()), + }, + "local.restoreHidden" => { + // one path, or (no path) every hidden location + match p.get("path").and_then(Value::as_str) { + Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), + None => app.ui.hidden_locations.clear(), + } + Ok(json!({"hidden": app.ui.hidden_locations})) + } + "view.filterBar" => { + app.ui.filter_bar = !app.ui.filter_bar; + if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { + app.ui.view = ViewMode::PhotoGrid; + } + Ok(json!({"filterBar": app.ui.filter_bar})) + } + "panel.edit" => { + panel(app, &ctx, RightPanel::Edit, "Edit"); + Ok(Value::Null) + } + "panel.profiles" => { + // toggles between the profile browser and the Edit panel it belongs to + app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { + RightPanel::Edit + } else { + RightPanel::Profiles + }; + if !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + Ok(json!({"open": app.ui.right == RightPanel::Profiles})) + } + "panel.crop" => { + panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); + Ok(Value::Null) + } + "panel.remove" => { + panel(app, &ctx, RightPanel::Remove, "Remove"); + if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { + app.ui.tool = "remove".into(); + } + Ok(Value::Null) + } + "panel.masking" => { + panel(app, &ctx, RightPanel::Masking, "Masking"); + Ok(Value::Null) + } + "panel.redeye" => { + panel(app, &ctx, RightPanel::RedEye, "Red Eye"); + Ok(Value::Null) + } + "panel.info" => { + panel(app, &ctx, RightPanel::Info, "Info"); + Ok(Value::Null) + } + "panel.keywords" => { + panel(app, &ctx, RightPanel::Keywords, "Keywords"); + Ok(Value::Null) + } + "panel.versions" => { + panel(app, &ctx, RightPanel::Versions, "Versions"); + Ok(Value::Null) + } + "panel.activity" => { + panel(app, &ctx, RightPanel::Activity, "History"); + Ok(Value::Null) + } + "panel.presets" => { + app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); + if app.ui.presets && app.ui.view != ViewMode::Detail { + app.ui.view = ViewMode::Detail; + } + Ok(Value::Null) + } + "tool.done" => { + // Return commits a tool panel (crop, remove, red eye, masking): back to Edit + use RightPanel::*; + if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { + let _ = app.session.end_interaction(); + app.ui.tool.clear(); + app.ui.right = Edit; + } + Ok(Value::Null) + } + "panel.close" => { + app.ui.right = RightPanel::None; + app.ui.presets = false; + Ok(Value::Null) + } + s if s.starts_with("section.") => { + let sec = &s["section.".len()..]; + if app.ui.right != RightPanel::Edit { + app.ui.right = RightPanel::Edit; + } + app.ui.toggle_section(sec); + Ok(Value::Null) + } + s if s.starts_with("tool.") => { + let tool = &s["tool.".len()..]; + match tool { + "none" => app.ui.tool.clear(), + "guidedUpright" => { + // Crop & Geometry with Guided Upright on, ready to draw guides + app.ui.right = RightPanel::Crop; + app.ui.view = ViewMode::Detail; + let guided = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided); + if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) { + return Some(Err(e)); + } + app.ui.tool = "guidedUpright".into(); + } + "brush" => { + // A new mask and Add/Subtract are selection operations, not the brush's + // Erase mode. Paint positively into a component with the requested op. + let create = if p.get("new").and_then(Value::as_bool) == Some(true) { + Some(app.run("mask.add", json!({"kind": "brush"}))) + } else { + p.get("op").map(|op| app.run("mask.addComponent", json!({"kind": "brush", "op": op}))) + }; + if let Some(result) = create { + if let Err(e) = result { + return Some(Err(e)); + } + app.ui.brush_erase = false; + app.ui.mask_overlay = true; + } + app.ui.right = RightPanel::Masking; + app.ui.view = ViewMode::Detail; + app.ui.tool = "brush".into(); + } + "linear" | "radial" => { + app.ui.right = RightPanel::Masking; + app.ui.view = ViewMode::Detail; + app.ui.tool = tool.into(); + return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string())); + } + "wbPicker" => { + app.ui.right = RightPanel::Edit; + app.ui.view = ViewMode::Detail; + app.ui.tool = "wbPicker".into(); + } + other => return Some(Err(format!("unknown tool `{other}`"))), + } + Ok(Value::Null) + } + "dialog.newFolder" => { + app.ui.dialog = + Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) }); + Ok(Value::Null) + } + "dialog.newAlbum" => { + app.ui.dialog = + Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) }); + Ok(Value::Null) + } + "dialog.autoStack" => { + app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); + Ok(Value::Null) + } + "dialog.allMetadata" => { + let r = match app.session.execute("photo.allMetadata", p) { + Ok(r) => r, + Err(e) => return Some(Err(e.to_string())), + }; + let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default(); + app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() }); + Ok(Value::Null) + } + "dialog.smartAlbum" => { + // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 + let album = p + .get("id") + .and_then(Value::as_u64) + .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); + app.ui.dialog = Some(match album { + Some(a) => Dialog::SmartRules { + id: Some(a.id.0), + name: a.name.clone(), + // older rules read as they always matched (RuleSet::upgrade) + rules: a + .smart + .as_ref() + .and_then(|f| f.rule_set.clone()) + .map(|mut r| { + r.upgrade(); + r + }) + .unwrap_or_default(), + parent: None, + }, + None => Dialog::SmartRules { + id: None, + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: parent_param(p), + }, + }); + Ok(Value::Null) + } + "dialog.newSmartAlbum" => { + app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); + Ok(Value::Null) + } + "dialog.captureTime" => { + let time = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.date().replace('T', " ")).unwrap_or_default(); + let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); + app.ui.dialog = + Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); + Ok(Value::Null) + } + "dialog.labelNames" => { + let names = + lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect(); + app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() }); + Ok(Value::Null) + } + "dialog.rename" => { + let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); + app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); + Ok(Value::Null) + } + "dialog.createPreset" => { + app.ui.dialog = Some(Dialog::create_preset()); + Ok(Value::Null) + } + "dialog.pasteSettings" => { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::PasteSettings { groups }); + Ok(Value::Null) + } + "view.focusSearch" => { + // the search field lives in the top bar of the library and detail views alike + app.ui.focus_search = true; + Ok(Value::Null) + } + "dialog.copySettings" => { + let groups = + app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); + app.ui.dialog = Some(Dialog::CopySettings { groups }); + Ok(Value::Null) + } + "dialog.contactSheet" => { + app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); + Ok(Value::Null) + } + "app.contactSheet" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_contact_sheet(app, ¶ms) + } + "dialog.export" => { + let prev = app.session.last_export.clone().unwrap_or_default(); + let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); + let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); + let opts = lightcraft_engine::export::ExportOptions::from_json(&prev); + // no previous export: 2048 px long edge; a previous full-size export: full size + let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); + let resize = opts.resize.unwrap_or_default(); + app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir }); + Ok(Value::Null) + } + "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), + "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), + "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), + "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), + "dialog.faceModel" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()), + }; + match path { + Some(path) => crate::panels::faces::open_dialog(app, &path), + None => Ok(Value::Null), + } + } + "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), + "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), + "app.about" => { + app.ui.dialog = Some(Dialog::About); + Ok(Value::Null) + } + "photo.editInExternal" => { + // render an edit copy (stacked on the original), then open it in the editor + let mut params = p.clone(); + if !params.is_object() { + params = json!({}); + } + let r = match app.session.execute("photo.editExternal", ¶ms) { + Ok(r) => r, + Err(e) => return Some(Err(e.to_string())), + }; + let path = r["path"].as_str().unwrap_or_default().to_string(); + if let Some(id) = r["id"].as_u64() { + app.ui.external_edits.push(id); + } + let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone()); + if let Some(f) = app.services.open_with.as_mut() + && let Err(e) = f(&path, &editor) + { + app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); + } + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(&ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); + Ok(r) + } + "dialog.cull" => { + app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); + Ok(Value::Null) + } + "app.whatsNew" => { + app.ui.dialog = Some(Dialog::WhatsNew); + Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) + } + "app.systemInfo" => { + let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); + let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); + let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); + let mut rows = vec![ + ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), + ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), + ( + "GPU".to_string(), + gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { + Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), + None => "none (CPU rendering)".into(), + }), + ), + ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), + ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), + ( + "Preview size".to_string(), + match app.ui.settings.preview_limit { + 0 => "automatic".to_string(), + n => format!("{n} px"), + }, + ), + ("Photos".to_string(), info["photos"].to_string()), + ("Albums".to_string(), info["albums"].to_string()), + ]; + if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { + rows.push(("Library".into(), dir.to_string())); + } + rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); + rows.push(( + "Frame update".into(), + format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), + )); + rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); + if let Some(f) = lightcraft_engine::gpu::last_fallback() { + rows.push(("Last GPU fallback".into(), f)); + } + let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>()); + if p.get("open").and_then(Value::as_bool).unwrap_or(true) { + app.ui.dialog = Some(Dialog::SystemInfo { rows }); + } + Ok(r) + } + "app.shortcuts" => { + app.ui.dialog = Some(Dialog::Shortcuts); + Ok(Value::Null) + } + "app.setShortcut" => crate::shortcuts::set_shortcut(app, p), + "app.resetShortcuts" => { + app.ui.settings.keymap.clear(); + Ok(Value::Null) + } + "library.browse" if !cfg!(target_arch = "wasm32") => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before browsing folders".into())); + } + // listed and read in the background (see `import::browse`) + let path = p.get("path").and_then(Value::as_str)?; + crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) + } + "file.addPhotos" => { + if crate::lightroom_import::is_running(app) { + return Some(Err("finish Lightroom catalog import before adding photos".into())); + } + let paths = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + // review first: the import dialog lists what was found + crate::import::open(app, paths) + } + "file.importLightroom" => { + let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { + Some(path) => Some(path), + None => { + let req = + PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => app.run("library.importLightroom", json!({"path":path})), + None => Ok(Value::Null), + } + } + "app.quit" => { + app.ui.quit = true; + Ok(Value::Null) + } + "dialog.saveMetadataPreset" => { + // from the active photo's copyright, creator and place + crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); + Ok(Value::Null) + } + "file.findMissing" => { + let folder = match p.get("folder").and_then(Value::as_str) { + Some(f) => Some(f.to_string()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + let Some(folder) = folder else { return Some(Ok(Value::Null)) }; + // the search (checking every photo's file, walking the folder) runs on a worker thread; + // the relinking happens back here, as one undo step + const LABEL: &str = "Find Missing Photos"; + if app.tasks.is_running(LABEL) { + return Some(Err("Find Missing Photos is already searching".into())); + } + let candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); + let work = move || lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder); + let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result<lightcraft_engine::cmd::missing::FindPlan, String>| { + // relinked under the session as it is now: photos relinked meanwhile and files + // now in use are skipped + let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); + match r { + Ok(v) => { + let n = v["found"].as_array().map_or(0, Vec::len); + let left = v["missing"].as_u64().unwrap_or(0); + let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); + let unsure = if unsure > 0 { + crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) + } else { + String::new() + }; + app.toast( + ctx, + crate::i18n::tr_format!( + "Found {n} missing photo{}; {left} still missing{unsure}", + if n == 1 { "" } else { "s" }, + left = left, + n = n, + unsure = unsure + ), + ); + app.ui.last_find_missing = Some(v); + } + Err(e) => app.toast(ctx, e), + } + }; + app.ui.last_find_missing = None; + if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { + return Some(Err(e)); + } + if p.get("wait").and_then(Value::as_bool).unwrap_or(false) { + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + crate::tasks::wait(app, &ctx, std::time::Duration::from_secs(600)); + return Some(Ok(app.ui.last_find_missing.clone().unwrap_or(Value::Null))); + } + Ok(json!({"background": true})) + } + "photo.tagFromTracklog" => { + // a GPX file → GPS for the selected photos by capture time (one undo step) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = p.as_object().cloned().unwrap_or_default(); + params.insert("path".into(), json!(path)); + let ask_zone = !params.contains_key("offset"); + let params = Value::Object(params); + if ask_zone { + // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here + crate::panels::dialogs::prompt( + app, + "Auto-Tag from Tracklog", + "Camera time zone, e.g. -07:00 (empty: UTC)", + "", + "photo.tagFromTracklog", + params, + "offset", + ); + return Some(Ok(Value::Null)); + } + let r = app.run("photo.autoTagTracklog", params); + if let Ok(v) = &r { + let n = v["tagged"].as_u64().unwrap_or(0); + let sk = &v["skipped"]; + let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); + let outside = sk["outside"].as_u64().unwrap_or(0); + if outside > 0 { + msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); + } + let kept = sk["hasGps"].as_u64().unwrap_or(0); + if kept > 0 { + msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); + } + let ctx = app.tasks.repaint.clone().unwrap_or_default(); + app.toast(&ctx, msg); + } + r + } + "photo.locate" => { + let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = + PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; + match path { + Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), + None => Ok(Value::Null), + } + } + "file.addFromDevice" => { + // a camera / card: review its DCIM folder, copying into the library by default + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => match lightcraft_engine::devices::devices_now().into_iter().next() { + Some(d) => d.path, + None => return Some(Err("no camera or memory card found".into())), + }, + }; + let r = crate::import::open(app, vec![path]); + // only the scan just started (an error means another scan is running) + if r.is_ok() + && let Some(t) = &mut app.scan + { + t.copy = true; + } + r + } + "file.addFolder" => { + // a folder (searched recursively) into the import review + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, + }; + match path { + Some(path) => crate::import::open(app, vec![path]), + None => Ok(Value::Null), + } + } + "file.importPresets" => { + let paths = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Presets").into()), + crate::i18n::tr("Presets & Profiles"), + crate::pick::PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + // .cube LUTs (and those inside zips / folders) become profiles + let lut_paths: Vec<&String> = paths + .iter() + .filter(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() + }) + .collect(); + let profiles = if lut_paths.is_empty() { + 0 + } else { + app.session + .execute("profile.import", &json!({"paths": lut_paths})) + .ok() + .and_then(|v| v["imported"].as_array().map(Vec::len)) + .unwrap_or(0) + }; + let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); + let r = if preset_paths.is_empty() { + Ok(json!({"imported": [], "failed": [], "skipped": 0})) + } else { + app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) + }; + if profiles > 0 { + app.toast( + &ctx, + crate::i18n::tr_format!( + "Imported {profiles} profile{} (Profile browser ▸ their groups)", + if profiles == 1 { "" } else { "s" }, + profiles = profiles + ), + ); + } + if let Ok(v) = &r { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let mut msg = match (n, failed) { + (0, 0) => "No new presets".to_string(), + (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n), + (n, f) => crate::i18n::tr_format!( + "Imported {n} preset{}, {f} file{} not readable", + if n == 1 { "" } else { "s" }, + if f == 1 { "" } else { "s" }, + f = f, + n = n + ), + }; + // settings with no counterpart here (the other editor's profiles, masks…) + let mut skipped: Vec<&str> = v["imported"] + .as_array() + .into_iter() + .flatten() + .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) + .filter_map(Value::as_str) + .collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + let names: Vec<&str> = skipped.iter().take(3).copied().collect(); + msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); + } + app.toast(&ctx, msg); + if n > 0 { + app.ui.presets = true; + } + } + return Some(r); + } + "file.importKeywords" => { + // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); + match &r { + Ok(v) => { + let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); + let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); + if updated > 0 { + msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); + } + app.toast(&ctx, msg); + } + Err(e) => app.toast(&ctx, e.clone()), + } + return Some(r); + } + "file.exportKeywords" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => { + let name = "Keywords.txt"; + let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { + Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); + if let Err(e) = &r { + app.toast(&ctx, e.clone()); + } + if let Ok(v) = &r { + let n = v["keywords"].as_u64().unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); + // Capture One's importer refuses ; , < > in a list: say which to rename first + let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !refuses.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — Capture One won't import a list with ; , < or > in a keyword: {names}", + names = refuses.iter().take(5).copied().collect::<Vec<_>>().join(" · ") + )); + } + // what the format can't hold (a name in brackets, a line break…) isn't in the file + let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + if !left.is_empty() { + msg.push_str(&crate::i18n::tr_format!( + " — left out, as a keyword list can't hold them: {names}", + // escaped: a line break in a name would break the toast + names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::<Vec<_>>().join(" · ") + )); + } + app.toast(&ctx, msg); + } + return Some(r); + } + "file.exportPresets" => { + let group = p.get("group").and_then(Value::as_str).map(str::to_string); + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); + let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone()); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + if let Some(g) = group { + params["group"] = json!(g); + } + if let Some(ids) = p.get("ids") { + params["ids"] = ids.clone(); + } + let r = app.session.execute("preset.export", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + return Some(r); + } + "file.importCurvePresets" => { + let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + if paths.is_empty() { + return Some(Ok(Value::Null)); + } + let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + let n = v["imported"].as_array().map_or(0, Vec::len); + let failed = v["failed"].as_array().map_or(0, Vec::len); + let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); + if failed > 0 { + msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); + } + app.toast(&ctx, msg); + } + return Some(r); + } + "file.exportCurvePresets" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + if let Some(n) = p.get("names") { + params["names"] = n.clone(); + } + let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); + } + return Some(r); + } + "app.export" => crate::control::export_active(app, p), + "app.showInFinder" => show_in_finder(app), + "app.openLogFolder" => open_log_folder(app), + "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { + let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); + crate::links::open(app, url) + } + "app.exportPrevious" => match app.session.last_export.clone() { + Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), + None => Err("nothing exported yet — use Export…".into()), + }, + _ => return None, + }; + Some(r) +} + +pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { + match id { + "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", + "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { + app.session.active().is_some() + } + "photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(), + "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), + "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), + "app.showInFinder" => { + app.services.reveal.is_some() + && app + .session + .active() + .and_then(|id| app.session.catalog.photo(id)) + .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + } + "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), + "view.compare" => app.session.catalog.len() > 1, + "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, + "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), + "file.backupLibrary" => app.services.backup_library.is_some(), + "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), + "file.restoreLibrary" => app.services.restore_library.is_some(), + "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, + s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { + app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() + } + _ => true, + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct MenuEntry { + pub id: String, + pub label: String, + pub menu: Vec<String>, + pub shortcut: Option<String>, + pub enabled: bool, +} + +/// The flattened menu model (UI commands + engine commands with menu paths). +pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> { + let mut v: Vec<MenuEntry> = ui_commands() + .filter(|c| !c.3.is_empty()) + .map(|(id, label, sc, m)| MenuEntry { + id: id.to_string(), + label: label.to_string(), + menu: m.split('>').map(str::to_string).collect(), + shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string), + enabled: ui_enabled(app, id), + }) + .collect(); + for c in app.session.commands() { + if !c.menu.is_empty() { + v.push(MenuEntry { + id: c.id.into(), + label: c.label.into(), + menu: c.menu.iter().map(|s| s.to_string()).collect(), + shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string), + enabled: c.enabled, + }); + } + } + v +} + +/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead +/// of deleting; true when it did (the dialog's OK runs `photo.delete`). +pub fn confirm_delete(app: &mut LightcraftApp) -> bool { + if !app.ui.settings.confirm_delete { + return false; + } + let count = app.session.targets(&json!({})).len(); + if count == 0 { + return false; + } + app.ui.dialog = Some(Dialog::ConfirmDelete { count }); + true +} + +/// Platform-appropriate label for revealing a file in the system file manager. +pub fn reveal_label() -> &'static str { + if cfg!(target_os = "macos") { + "Show in Finder" + } else if cfg!(target_os = "windows") { + "Show in Explorer" + } else { + "Show in File Manager" + } +} + +/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be +/// attached to a report without hunting for the settings folder (#260). +fn open_log_folder(app: &mut LightcraftApp) -> Result<Value, String> { + let path = app.services.log_file.clone().ok_or("this session keeps no log file")?; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} + +/// Reveal the active photo's original in the system file manager. +fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> { + let id = app.session.active().ok_or("no photo selected")?; + let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { + Some(lightcraft_engine::catalog::Source::File { path }) => path, + _ => return Err("this photo has no file (demo scene)".into()), + }; + let reveal = app.services.reveal.as_mut().ok_or("not available here")?; + reveal(&path)?; + Ok(json!({"path": path})) +} From a6d048be8d1bf7ac453f4f1fd376f21f9d37c2c7 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 05:43:30 +1100 Subject: [PATCH 024/315] LR-IMP-FORMATS: decode Epson ERF (12-bit 16-byte blocks; WB from the preview's neutrals) Sources: TIFF 6.0 and own black-box analysis of the three CC0 samples; white-balance estimate from the embedded preview: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 5 +- crates/catalog/src/model.rs | 3 +- crates/engine/src/camera_preview.rs | 2 + crates/pipeline/src/spots.rs | 4 +- crates/raw/src/lib.rs | 8 ++ crates/raw/src/preview.rs | 4 +- crates/raw/src/sparse.rs | 6 +- crates/raw/src/tiffraw.rs | 2 +- crates/raw/src/vendor/erf.rs | 197 ++++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/wb_estimate.rs | 146 +++++++++++++++++++++ crates/raw/tests/corpus.rs | 33 +++++ docs/camera-preview-colour.md | 4 +- docs/gaps.md | 7 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 20 +-- docs/raw/coverage.py | 4 +- docs/raw/coverage.tsv | 6 +- docs/raw/evidence.tsv | 3 + docs/target-app-parity.md | 3 +- xtask/src/main.rs | 3 + 21 files changed, 436 insertions(+), 30 deletions(-) create mode 100644 crates/raw/src/vendor/erf.rs create mode 100644 crates/raw/src/wb_estimate.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..c93d48108 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded, 3 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each + file's preview; 3 bodies verified on CC0 files. 238 models verified; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -161,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (238); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad4..bd7792433 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -313,7 +313,7 @@ pub struct Analysis { /// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: /// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { - ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f)) + ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f)) } /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed @@ -564,6 +564,7 @@ mod edited_tests { ("a.cr2", "CR2", true), ("a.pef", "pef", true), ("a.orf", "ORF", true), + ("a.erf", "ERF", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..09b6ae1c6 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -58,6 +58,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { | RawFormat::Pef | RawFormat::Srw | RawFormat::Orf + | RawFormat::Erf ) } @@ -1885,6 +1886,7 @@ mod tests { RawFormat::Pef, RawFormat::Srw, RawFormat::Orf, + RawFormat::Erf, ]; assert!(local.into_iter().all(file_local_look)); // DNG carries its own colour model diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9edb..801ee7f5b 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -46,6 +46,7 @@ pub mod spectral; mod tiffraw; mod unpack; mod vendor; +mod wb_estimate; pub use demosaic::{Method, demosaic}; pub use dngwrite::{DngCompression, DngWriteOptions, write_dng}; @@ -110,6 +111,8 @@ pub enum RawFormat { CfaTiff, /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). KodakDcs, + /// Epson ERF: a CFA sub-IFD of 12-bit samples in 16-byte blocks (`vendor::erf`). + Erf, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -131,6 +134,7 @@ impl RawFormat { | RawFormat::CfaTiff | RawFormat::Srw | RawFormat::KodakDcs + | RawFormat::Erf ) } } @@ -202,6 +206,9 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> { if make.starts_with("SAMSUNG") && samsung_raw { return Some(RawFormat::Srw); } + if vendor::erf::raw_ifd(&t, bytes.len()).is_some() { + return Some(RawFormat::Erf); + } if vendor::kodak::raw_ifd(&t, bytes).is_some() { return Some(RawFormat::KodakDcs); } @@ -370,6 +377,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> { RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), + RawFormat::Erf => vendor::erf::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 4660b737f..675f5e3c4 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -178,8 +178,8 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> { .filter(|s| is_dct_jpeg(s) || is_jxl(s)) .max_by_key(|s| s.len()) .map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() }); - // a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF) - best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes))) + // a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note) + best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))) } /// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index 14fa3c1f8..85e599349 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -227,7 +227,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { return plan; } // (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read) - if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef)) { + if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf)) { return HeaderPlan::Whole("its header probe reads samples"); } let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; @@ -260,7 +260,9 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { holes.insert(off.saturating_add(HEAD), len); } finish(len, loaded, &holes, &meta, || match crate::probe(bytes) { - Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef) => HeaderPlan::Whole("its header probe reads samples"), + Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf) => { + HeaderPlan::Whole("its header probe reads samples") + } Some(f) if f.is_supported() => HeaderPlan::Ready, _ => HeaderPlan::Whole("not a supported raw"), }) diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 676bb6197..c342edae4 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -225,7 +225,7 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: } /// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels). -fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> { +pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> { use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; use zune_core::options::DecoderOptions; diff --git a/crates/raw/src/vendor/erf.rs b/crates/raw/src/vendor/erf.rs new file mode 100644 index 000000000..fae120bcc --- /dev/null +++ b/crates/raw/src/vendor/erf.rs @@ -0,0 +1,197 @@ +//! Epson ERF (R-D1, R-D1s, R-D1x): a big-endian TIFF whose `SubIFDs` child is a 12-bit CFA image in 16-byte blocks. +//! +//! Sources: TIFF 6.0 (container, `CFARepeatPatternDim` / `CFAPattern`) and our own black-box analysis of the three +//! CC0 ERF samples on raw.pixls.us (one per body); every number below was measured on them. +//! +//! - IFD0 is a 160 x 120 RGB thumbnail; its `SubIFDs` child is the raw: 3040 x 2024, 12 bits, `Compression` 32769, +//! photometric CFA, one strip of exactly `width * 8 / 5` bytes per row. Byte 15 of every 16 is zero in all rows: +//! a row is 16-byte blocks, each 15 bytes of ten 12-bit samples, most significant bits first (`b0 << 4 | b1 >> 4`, +//! `(b1 & 15) << 8 | b2`), then a pad byte. Read the other way (least significant first, as the Olympus blocks are) +//! the frame is noise: the mean step between same-colour neighbours is 150..199 codes against 11..18. +//! - `CFAPattern` `00 01 01 02`: RGGB. +//! - The maker note (`EPSON\0\x01\0`, then a big-endian IFD with offsets from the file start) holds four black levels +//! as `u32` in tag `0x0401` (61..65; in Bayer-cell order), above which the darkest 0.01% of samples sit; `0x020b` x +//! `0x020c` is the picture size, 3008 x 2000. Registered against the 640 x 424 preview JPEG (correlation of the +//! green channel over every even offset), that picture starts at column 20, row 12 in all three files. +//! - White: the saturation plateau (4095 on the clipped R-D1x frame), else 4095. +//! - No maker-note tag tracks the as-shot white balance across the three files, so it is measured from the +//! preview's neutral pixels ([`crate::wb_estimate`]); on the R-D1s sample (54,000 neutral preview pixels) that +//! gives red 2.68 and blue 1.31. +//! - The preview is the maker note's tag `0x0280`, a JPEG whose first byte is stored as `ee` (found by the +//! structural scan in `preview::scan_for_jpeg`). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::{Ifd, Tiff, tags as t}; + +/// Epson's private compression code for the 16-byte block layout. +const BLOCKS16: u16 = 32769; +/// Maker-note tags. +const MN_BLACK: u16 = 0x0401; +const MN_WIDTH: u16 = 0x020b; +const MN_HEIGHT: u16 = 0x020c; +/// Where the picture starts in the sensor frame (measured, see the module notes). +const CROP_ORIGIN: (usize, usize) = (20, 12); + +/// The raw IFD of an Epson ERF: CFA, 12 bits, compression 32769, one strip of 16-byte blocks of ten samples. +pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> { + let make = tiff.find(t::MAKE).and_then(|e| e.value.as_str())?; + if !make.to_ascii_uppercase().contains("EPSON") { + return None; + } + tiff.all_ifds().into_iter().find(|i| { + let Ok(info) = i.image() else { return false }; + let (w, h) = (info.width as u64, info.height as u64); + i.u16(t::PHOTOMETRIC) == Some(t::photometric::CFA) + && i.u16(t::COMPRESSION) == Some(BLOCKS16) + && i.u16(t::BITS_PER_SAMPLE) == Some(12) + && w > 0 + && w % 10 == 0 + && matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 8 / 5).checked_mul(h)) + }) +} + +/// The maker-note IFD (`EPSON\0\x01\0` then an IFD in the file's byte order, offsets from the file start). +fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<Ifd> { + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let start = usize::try_from(e.offset).ok()?; + if !bytes.get(start..)?.starts_with(b"EPSON\0") { + return None; + } + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + lightcraft_tiff::parse_ifd_at(bytes, e.offset.checked_add(8)?, tiff.order, 0, false, &opts).ok().map(|(i, _)| i) +} + +/// Unpack a strip of 16-byte blocks (15 bytes of ten MSB-first 12-bit samples, one pad byte). +fn unpack(src: &[u8], out: &mut [u16]) { + let (blocks, _) = src.as_chunks::<16>(); + let (pixels, _) = out.as_chunks_mut::<10>(); + for (b, px) in blocks.iter().zip(pixels) { + crate::unpack::unpack_msb(&b[..15], 12, px); + } +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("Erf TIFF without IFDs".into()))?; + let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Erf raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Erf raw without a 2x2 CFAPattern".into())), + }; + let mn = maker_note(bytes, &tiff); + let black = match mn.as_ref().and_then(|m| m.u64s(MN_BLACK)).as_deref() { + Some(v @ [_, _, _, _]) if v.iter().all(|&b| b < 4095) => { + BlackLevel { repeat_rows: 2, repeat_cols: 2, values: v.iter().map(|&b| b as f32).collect(), delta_h: vec![], delta_v: vec![] } + } + _ => BlackLevel::uniform(0.0), + }; + let size = mn.as_ref().and_then(|m| Some((m.u32(MN_WIDTH)? as usize, m.u32(MN_HEIGHT)? as usize))); + let crop = match size { + Some((cw, ch)) if cw > 0 && ch > 0 && CROP_ORIGIN.0 + cw <= w && CROP_ORIGIN.1 + ch <= h => Rect::new(CROP_ORIGIN.0, CROP_ORIGIN.1, cw, ch), + _ => Rect::new(0, 0, w, h), + }; + // the white balance is measured from the samples, so the header probe reads them too + let chunks = info.chunks(bytes.len() as u64); + let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?; + let mut d = vec![0u16; n]; + unpack(src, &mut d); + let white = super::white_from_data(&d, 12); + let wb = crate::wb_estimate::as_shot(bytes, &crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white }); + let data = if mode == Mode::Full { RawData::U16(d) } else { RawData::U16(Vec::new()) }; + let mut metadata = lightcraft_meta::from_tiff(&tiff); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let img = RawImage { + format: RawFormat::Erf, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black, + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop, + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + + /// Pack ten-sample groups into 16-byte blocks, MSB first, zero pad. + fn pack(px: &[u16]) -> Vec<u8> { + let mut out = Vec::new(); + for g in px.chunks(10) { + for p in g.chunks(2) { + let (a, b) = (p[0], p[1]); + out.extend_from_slice(&[(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]); + } + out.push(0); + } + out + } + + fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![BLOCKS16])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SEIKO EPSON CORP.".into())); + ifd0.set(t::MODEL, Value::Ascii("R-D1".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![1])); + ifd0.add_sub_ifd(raw); + TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn decodes_the_block_layout() { + let (w, h) = (20u32, 6u32); + let px: Vec<u16> = (0..w * h).map(|i| (i * 37 % 4096) as u16).collect(); + let f = sample(w, h, &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Erf)); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.data, RawData::U16(px)); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("RGGB")); + assert!(r.wb_multipliers.is_some()); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + + #[test] + fn malformed_files_never_panic() { + let px: Vec<u16> = (0..200).map(|i| (i * 13 % 4096) as u16).collect(); + let f = sample(20, 10, &px); + for n in (0..f.len()).step_by(3) { + let _ = crate::decode(&f[..n]); + } + for i in 0..f.len() { + for v in [0u8, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index b7bef1471..45fe4549b 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -10,6 +10,7 @@ pub mod cr2; pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; +pub mod erf; pub mod kodak; pub mod nef; pub mod nefc; diff --git a/crates/raw/src/wb_estimate.rs b/crates/raw/src/wb_estimate.rs new file mode 100644 index 000000000..96918a853 --- /dev/null +++ b/crates/raw/src/wb_estimate.rs @@ -0,0 +1,146 @@ +//! As-shot white balance for raws that record none we can read: measured from the file's own embedded preview. +//! +//! Sources: own design. The camera rendered its preview with the as-shot white balance, so pixels the preview shows +//! as neutral grey were neutral for that white balance. The mosaic is averaged per colour over the preview's pixel +//! grid (inside the default crop); over the preview pixels whose channels lie within a few codes of each other +//! (not near black, not near clipping, no clipped sensor sample under them) the median green-over-red and +//! green-over-blue ratios are the gains. A colour matrix maps neutral to neutral, so its absence doesn't bias the +//! result. Without enough neutral pixels the mean of the unclipped mosaic is used instead (grey world). +use crate::{BlackLevel, Cfa, Rect}; + +/// Preview pixels whose largest and smallest channel differ by less than this (8-bit codes) count as neutral. +const NEUTRAL_SPREAD: u8 = 12; +/// At least this many neutral preview pixels are needed. +const MIN_NEUTRAL: usize = 64; +/// Previews larger than this (pixels) are not decoded for the estimate. +const MAX_PREVIEW_PIXELS: usize = 1 << 24; + +/// The raw frame the estimate looks at. +pub(crate) struct Frame<'a> { + pub data: &'a [u16], + pub width: usize, + pub crop: Rect, + pub cfa: &'a Cfa, + pub black: &'a BlackLevel, + pub white: f32, +} + +/// Gains `[R/G, 1, B/G]`: from the neutral pixels of the embedded preview, else grey world. `None` for an empty or +/// degenerate frame. +pub(crate) fn as_shot(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { + from_preview(bytes, f).or_else(|| grey_world(f)) +} + +/// Per preview-grid cell means of R, G, B (black subtracted) and whether a clipped sample lies in the cell. +fn cell_means(f: &Frame, pw: usize, ph: usize) -> Option<Vec<([f64; 3], bool)>> { + let c = f.crop; + if pw == 0 || ph == 0 || c.width < pw || c.height < ph || f.width == 0 { + return None; + } + let mut cells = vec![([0f64; 3], [0u32; 3], false); pw.checked_mul(ph)?]; + for y in c.y..c.y.saturating_add(c.height) { + let row = f.data.get(y.checked_mul(f.width)?..y.checked_mul(f.width)?.checked_add(f.width)?)?; + let cy = (y - c.y) * ph / c.height; + for x in c.x..c.x.saturating_add(c.width).min(f.width) { + let v = f32::from(*row.get(x)?); + let cx = (x - c.x) * pw / c.width; + let col = usize::from(f.cfa.color_at(x, y)).min(2); + let cell = cells.get_mut(cy * pw + cx)?; + if v >= f.white * 0.98 { + cell.2 = true; + } + cell.0[col] += f64::from(v - f.black.at(x, y, 0, 1)); + cell.1[col] += 1; + } + } + Some(cells.into_iter().map(|(s, n, clip)| ([0, 1, 2].map(|k| if n[k] > 0 { s[k] / f64::from(n[k]) } else { 0.0 }), clip)).collect()) +} + +fn median(v: &mut [f64]) -> Option<f64> { + if v.is_empty() { + return None; + } + v.sort_by(f64::total_cmp); + v.get(v.len() / 2).copied() +} + +fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { + let jpeg = crate::preview::embedded_preview(bytes)?; + let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?; + if ch != 3 || pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS { + return None; + } + // the preview must show the crop: aspect ratios within 3% + let (ra, pa) = (f.crop.width as f64 / f.crop.height.max(1) as f64, pw as f64 / ph as f64); + if (ra / pa - 1.0).abs() > 0.03 { + return None; + } + let cells = cell_means(f, pw, ph)?; + let (mut gr, mut gb) = (Vec::new(), Vec::new()); + for (i, (m, clip)) in cells.iter().enumerate() { + let p = px.get(i * 3..i * 3 + 3)?; + let (lo, hi) = (p.iter().min().copied().unwrap_or(0), p.iter().max().copied().unwrap_or(255)); + if *clip || lo < 30 || hi > 240 || hi - lo >= NEUTRAL_SPREAD || m.iter().any(|v| *v <= 1.0) { + continue; + } + gr.push(m[1] / m[0]); + gb.push(m[1] / m[2]); + } + if gr.len() < MIN_NEUTRAL { + return None; + } + gains(median(&mut gr)?, median(&mut gb)?) +} + +fn gains(gr: f64, gb: f64) -> Option<[f32; 3]> { + let ok = |g: f64| g.is_finite() && (0.1..=10.0).contains(&g); + (ok(gr) && ok(gb)).then_some([gr as f32, 1.0, gb as f32]) +} + +fn grey_world(f: &Frame) -> Option<[f32; 3]> { + let c = f.crop; + let (mut s, mut n) = ([0f64; 3], [0u64; 3]); + for y in c.y..c.y.saturating_add(c.height) { + for x in c.x..c.x.saturating_add(c.width).min(f.width) { + let Some(&v) = f.data.get(y.checked_mul(f.width)?.checked_add(x)?) else { continue }; + let v = f32::from(v); + if v >= f.white * 0.98 { + continue; + } + let k = usize::from(f.cfa.color_at(x, y)).min(2); + s[k] += f64::from((v - f.black.at(x, y, 0, 1)).max(0.0)); + n[k] += 1; + } + } + if n.contains(&0) { + return None; + } + let m = [0, 1, 2].map(|k| s[k] / n[k] as f64); + if m.iter().any(|v| *v <= 0.0) { + return None; + } + gains(m[1] / m[0], m[1] / m[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grey_world_balances_a_tinted_flat_frame() { + // RGGB, R at 400, G at 800, B at 200 above a black of 100 + let (w, h) = (8, 8); + let d: Vec<u16> = (0..w * h).map(|i| [500, 900, 900, 300][(i / w % 2) * 2 + i % 2]).collect(); + let cfa = Cfa::bayer_static("RGGB"); + let black = BlackLevel::uniform(100.0); + let f = Frame { data: &d, width: w, crop: Rect::new(0, 0, w, h), cfa: &cfa, black: &black, white: 4095.0 }; + assert_eq!(as_shot(b"not a raw", &f), Some([2.0, 1.0, 4.0])); + // degenerate: everything clipped + let d = vec![4095u16; w * h]; + let f = Frame { data: &d, ..f }; + assert_eq!(as_shot(b"", &f), None); + // a crop outside the data never panics + let f = Frame { crop: Rect::new(4, 4, 100, 100), ..f }; + let _ = as_shot(b"", &f); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..6d43700b2 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1133,3 +1133,36 @@ fn fuji_embedded_lens_tables_follow_the_validated_models() { } eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains"); } + +/// Epson ERF (`crates/raw/src/vendor/erf.rs`): the 16-byte block strip decodes to a smooth 3040 x 2024 RGGB +/// mosaic with the maker note's black levels, the 3008 x 2000 crop and a white balance measured from the +/// preview's neutral pixels. Skips files that are absent. +#[test] +fn corpus_epson_erf() { + let root = corpus_root().join("raw"); + let mut checked = 0; + for name in ["erf-epson-rd1.erf", "erf-epson-rd1s.erf", "erf-epson-rd1x.erf"] { + let Ok(bytes) = std::fs::read(root.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Erf), "{name}"); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((img.width, img.height, img.bits), (3040, 2024, 12), "{name}"); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (20, 12, 3008, 2000), "{name}"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"), "{name}"); + assert!(img.black.values.len() == 4 && img.black.values.iter().all(|b| (55.0..=70.0).contains(b)), "{name}: {:?}", img.black.values); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no white balance")); + eprintln!("{name}: wb {wb:?}"); + if name == "erf-epson-rd1s.erf" { + assert!((wb[0] - 2.68).abs() < 0.1 && (wb[2] - 1.31).abs() < 0.1, "{name}: {wb:?}"); + } + // same-colour neighbours differ little: the bit order is right + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let step: f64 = d.chunks(3040).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 3038.0); + assert!(step < 40.0, "{name}: rough {step}"); + assert!(embedded_preview(&bytes).is_some(), "{name}: no preview"); + checked += 1; + } + eprintln!("ERF: {checked} corpus files checked"); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..b30fb4c2b 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ -# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW and Olympus ORF starting look +# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF and Epson ERF starting look -ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. +ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF and ERF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..1c34f2f1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 20: Epson ERF decodes; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 238 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -34,7 +34,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | -| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | +| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, KDC, DCR, MEF, GPR, old FinePix (ERF decodes since 2026-10-12) | raw-parity.md | small user share each | 43–83 | [raw-parity](raw-parity.md) | | 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** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 235 → 238 | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..61abd6c2e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS: Epson ERF decodes; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Epson ERF (R-D1, R-D1s, R-D1x: a CFA sub-IFD of 12-bit samples in 16-byte blocks, ten MSB-first samples and a pad byte; maker-note black levels and picture size, the crop registered against the preview; no white-balance tag was found, so the as-shot gains are measured from the preview's neutral pixels, grey world without them, `crates/raw/src/wb_estimate.rs`; file-local look fitted to the preview; `crates/raw/src/vendor/erf.rs`, 3 CC0 files), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`) and Leaf MOS (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | | 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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..18d087a04 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded: 3 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,9 +14,9 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoded from sensor data and **verified** on a real file | **238** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | | Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | +| **Preview-only** (embedded JPEG) | **230** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | @@ -41,7 +41,7 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 104 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue @@ -80,8 +80,8 @@ committed table is stale. | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 3 | 14 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (ERF decodes) | +| **All** | **1,446** | **238 (16%)** | **942 (65%)** | **230 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -97,7 +97,7 @@ committed table is stale. | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | +| Medium format and others | 0.5% | 24% | 2% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually @@ -144,7 +144,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | GoPro GPR (VC-5) | ⬜ | refused | | 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | | 3FR / FFF | Hasselblad compression | ⬜ | preview | -| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview | +| ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | +| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview | | DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | ### Compressed ORF: a clean-room starting point @@ -251,7 +252,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | | 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | | 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | -| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, ERF, DCR, MEF, GPR | +0.6 together | 45–85 | ~0.01 | samples; low use | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, MEF, GPR (ERF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | Beyond decoding: @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Epson ERF decoded (`vendor/erf.rs`): R-D1, R-D1s, R-D1x verified; 238 verified, 230 preview | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index c635c993a..d129c2bf8 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -83,7 +83,9 @@ def classify(maker, camera, ext_field): return "preview", "SRW", "compressed SRW is not decoded" return "unverified", "SRW", "uncompressed / packed SRW" e = es[0] if es else "" - if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "ERF", "KDC", "MRW", "X3F", "RAW", "ORI"): + if e == "ERF": + return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)" + if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "MRW", "X3F", "RAW", "ORI"): return "preview", e, f"{e} is opened from its embedded JPEG only" return "unsupported", e, f"{e or 'unknown'} is not imported" diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..1a0f57c3d 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -296,9 +296,9 @@ DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only -Epson R-D1s ERF 2005 preview ERF is opened from its embedded JPEG only -Epson R-D1x ERF 2008 preview ERF is opened from its embedded JPEG only +Epson R-D1 ERF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1s ERF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1x ERF 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 50R RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 50S II RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..e4f66d78c 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -248,3 +248,6 @@ Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 10 Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..f15a6e911 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded: 3 models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Epson ERF decoded (raw-parity.md): 238 models verified, 230 preview-only; shares and rounded numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..b900136d1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -885,6 +885,9 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"), + ("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"), + ("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From b764715dc56e5b2dc43e7867eac87933c119d53b Mon Sep 17 00:00:00 2001 From: neronek22 <aantoszek@gmail.com> Date: Sun, 11 Oct 2026 21:00:58 +0200 Subject: [PATCH 025/315] Remove accidentally duplicated localization files --- .../crates/ui-egui/locales/pl-formats.json | 232 -- packaging/crates/ui-egui/locales/pl.json | 2043 ----------------- packaging/crates/ui-egui/src/i18n.rs | 1354 ----------- packaging/crates/ui-egui/src/menus.rs | 1715 -------------- 4 files changed, 5344 deletions(-) delete mode 100644 packaging/crates/ui-egui/locales/pl-formats.json delete mode 100644 packaging/crates/ui-egui/locales/pl.json delete mode 100644 packaging/crates/ui-egui/src/i18n.rs delete mode 100644 packaging/crates/ui-egui/src/menus.rs diff --git a/packaging/crates/ui-egui/locales/pl-formats.json b/packaging/crates/ui-egui/locales/pl-formats.json deleted file mode 100644 index 8bdf25420..000000000 --- a/packaging/crates/ui-egui/locales/pl-formats.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - " ({unsure} with several look-alike files: use Locate)": " ({unsure} wśród kilku podobnych plików: użyj polecenia Zlokalizuj)", - " ({} in Recently Deleted)": " ({} w Ostatnio usuniętych)", - " · {:.0}% clipped": " · {:.0}% prześwietlonych obszarów", - " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", - " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", - " · {} duplicate{} skipped": " · pominięto {} duplikatów{}", - " · {} failed: {}": " · {} nieudanych: {}", - " · {} moved": " · {} przeniesionych", - " · {} not readable": " · {} nie można odczytać", - " · {} original{} left at the source": " · {} oryginalnych{} pozostawiono w źródle", - " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", - " — not carried over: {}{}": " — nie przeniesiono: {}{}", - "+{} more": "+{} więcej", - ", {failed} file{} not readable": ", nie można odczytać {failed} plików{}", - "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", - "; {outside} outside its time range": "; {outside} poza zakresem dat", - "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", - "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", - "Add {} Photo{}": "Dodaj {} zdjęć{}", - "Add {} Photo{}, restore {}": "Dodaj {} zdjęć{}, przywróć {}", - "Add “{}” to Favorites": "Dodaj „{}” do ulubionych", - "Added {n} photo{} to My Photos": "Dodano {n} zdjęć{} do Moich zdjęć", - "Added {n} photo{} to “{}”": "Dodano {n} zdjęć{} do „{}”", - "Added {what} to {name}": "Dodano {what} do {name}", - "Adding photos… {} of {}": "Dodawanie zdjęć… {} z {}", - "All {n} selected photos move by the same amount.": "Wszystkie {n} zaznaczone zdjęcia zostaną przesunięte o tę samą wartość.", - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Już w bibliotece, w Ostatnio usuniętych: zaznaczono {} zdjęć{} · kliknij prawym przyciskiem ▸ Przywróć albo Usuń trwale, aby zaimportować ponownie", - "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece: zaznaczono {} zdjęć{} we Wszystkich zdjęciach", - "Auto ({})": "Automatycznie ({})", - "Auto Import: added {} photo{}": "Automatyczny import: dodano {} zdjęć{}", - "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", - "Automatic ({} MB)": "Automatycznie ({} MB)", - "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", - "Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})", - "Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})", - "Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…", - "Click {}:1": "Kliknij {}:1", - "Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}", - "Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych", - "Creates {} stacks from {} of {scope}": "Tworzy {} stosów z {} w zakresie: {scope}", - "Decoder: {reason}": "Dekoder: {reason}", - "Delete \"{label}\"": "Usuń „{label}”", - "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", - "Delete {n} Photos": "Usuń {n} zdjęć", - "Delete “{current}”": "Usuń „{current}”", - "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", - "Done ({what}): {done} photo{plural}": "Gotowe ({what}): {done} zdjęć{plural}", - "Duplicate \"{label}\"": "Duplikuj „{label}”", - "Example: {example}": "Przykład: {example}", - "Exported {n} photo{}": "Wyeksportowano {n} zdjęć{}", - "Exported {ok} of {total} photo{}": "Wyeksportowano {ok} z {total} zdjęć{}", - "Exported {} point curve preset{}": "Wyeksportowano {} ustawień krzywej punktowej{}", - "Exported {} preset{}": "Wyeksportowano {} ustawień wstępnych{}", - "Exporting {} of {total}": "Eksportowanie {} z {total}", - "Filter bar — {active} active filter{}": "Pasek filtrów — {active} aktywnych filtrów{}", - "Focus {:.0}": "Ostrość {:.0}", - "Folder “{}” (and its subfolders)": "Folder „{}” (wraz z podfolderami)", - "Found {n} missing photo{}; {left} still missing{unsure}": "Znaleziono {n} brakujących zdjęć{}; nadal brakuje {left}{unsure}", - "Healed {n} dust spot{}": "Usunięto {n} plamek kurzu{}", - "Import cancelled · {} photo{} added": "Import anulowany · dodano {} zdjęć{}", - "Import failed: {e}": "Import nieudany: {e}", - "Imported {n} point curve preset{}": "Zaimportowano {n} ustawień krzywej punktowej{}", - "Imported {n} preset{}": "Zaimportowano {n} ustawień wstępnych{}", - "Imported {n} preset{}, {f} file{} not readable": "Zaimportowano {n} ustawień wstępnych{}, nie można odczytać {f} plików{}", - "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Zaimportowano {profiles} profili{} (Przeglądarka profili ▸ odpowiednie grupy)", - "Imported {} photo{}": "Zaimportowano {} zdjęć{}", - "Insert at the cursor (also written {})": "Wstaw w miejscu kursora (zostanie też zapisane {})", - "Label Set: {name}": "Zestaw etykiet: {name}", - "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", - "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}", - "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących", - "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć", - "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}", - "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}", - "Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}", - "Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}", - "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.", - "Matches: {}": "Dopasowania: {}", - "Merging… {stage} {:.0}%": "Scalanie… {stage} {:.0}%", - "Move {what} to Recently Deleted?": "Przenieść {what} do Ostatnio usuniętych?", - "Named ({named_n})": "Nazwane ({named_n})", - "Painting “{k}”: click photos to add or remove it · Esc stops": "Przypisywanie „{k}”: klikaj zdjęcia, aby dodać lub usunąć słowo kluczowe · Esc kończy", - "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do {n} zdjęć{} — wklejone zostaną tylko skopiowane ustawienia", - "Preset: {name}": "Ustawienie wstępne: {name}", - "Preview only — editing the camera's embedded JPEG ({})": "Tylko podgląd — edycja pliku JPEG osadzonego przez aparat ({})", - "Preview only — {}": "Tylko podgląd — {}", - "Previews ready for {done} photo{plural}": "Podglądy gotowe dla {done} zdjęć{plural}", - "Profile: {}": "Profil: {}", - "Proof Copy ({})": "Kopia próbna ({})", - "Proof Preview · {}": "Podgląd próbny · {}", - "Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć", - "Quick Develop: {name}": "Szybka obróbka: {name}", - "Rated {}": "Ocena: {}", - "Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}", - "Reading photos… {done} of {total}": "Odczytywanie zdjęć… {done} z {total}", - "Reading photos… {} of {}": "Odczytywanie zdjęć… {} z {}", - "Redo {}": "Ponów {}", - "Remove “{name}” and its {count} photo{} from the library?": "Usunąć „{name}” i jego {count} zdjęć{} z biblioteki?", - "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", - "Removed from {}": "Usunięto z {}", - "Removed {what} from {name}": "Usunięto {what} z {name}", - "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", - "Rename “{name}”": "Zmień nazwę „{name}”", - "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na {n} zdjęciach{} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", - "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", - "Replace {} with:": "Zamień {} na:", - "Reset {name}": "Wyzeruj {name}", - "Reset {} Channel": "Wyzeruj kanał {}", - "Restore {}": "Przywróć {}", - "Restore {} Photo{}": "Przywróć {} zdjęć{}", - "Saved export preset “{}”": "Zapisano ustawienie eksportu „{}”", - "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Ocenia ostrość i ekspozycję zdjęć z zakresu {scope} oraz znajduje podobne ujęcia wykonane w odstępie kilku sekund (serie).", - "Show {hidden} hidden location{}": "Pokaż {hidden} ukrytych lokalizacji{}", - "Showing {n} of {}": "Wyświetlono {n} z {}", - "Smart album: {}": "Album inteligentny: {}", - "Tagged {n} photo{} from the tracklog": "Dodano geotagi do {n} zdjęć{} na podstawie śladu GPS", - "The SAM 3 download failed: {e}": "Nie udało się pobrać SAM 3: {e}", - "Undo {}": "Cofnij {}", - "Unknown tag{s} {} stay{} as typed (see Tags)": "Nieznane znaczniki: {s} {} pozostaną{} w wpisanej postaci (zobacz Znaczniki)", - "Updated “{name}”": "Zaktualizowano „{name}”", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", - "Version: {}": "Wersja: {}", - "Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}", - "Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…", - "none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}", - "the {n} selected photos": "{n} zaznaczonych zdjęć", - "the {} photos in view": "{} zdjęć w bieżącym widoku", - "these {count} photos": "te {count} zdjęcia", - "{count} smart preview{} · {:.1} MB": "{count} podglądów inteligentnych{} · {:.1} MB", - "{f} folders (and their subfolders)": "{f} folderów (wraz z podfolderami)", - "{label} Grading": "Gradacja: {label}", - "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", - "{label} {index} of {n}": "{label} {index} z {n}", - "{label}, {n} photos": "{label}, {n} zdjęć", - "{matching} of {t} photos": "{matching} z {t} zdjęć", - "{matching} photos": "{matching} pasujących zdjęć", - "{mode} spot {} of {}": "{mode}: plamka {} z {}", - "{month}": "{month}", - "{month} {year}": "{month} {year}", - "{msg} ({failed} failed)": "{msg} (nieudanych: {failed})", - "{name} Label": "Etykieta {name}", - "{name} Off": "{name}: wyłączone", - "{name} On": "{name}: włączone", - "{name} opened for editing; it is stacked with the original": "{name} otwarto do edycji; kopia znajduje się w stosie z oryginałem", - "{name} — double-click to reset it": "{name} — kliknij dwukrotnie, aby wyzerować", - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nie można zapisać {n} zmian{} na dysku: {e}\nJeśli teraz zamkniesz program, utracisz {}.", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu oznacza utratę {}.", - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e} — LightCraft ponowi próbę", - "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", - "{n} photos": "{n} zdjęć", - "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", - "{n} photo{}": "{n} zdjęć{}", - "{n} photo{} match · updates automatically as photos change": "{n} pasujących zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", - "{n} photo{} now · updates automatically as photos change": "Obecnie {n} zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", - "{pid} (missing)": "{pid} (brak)", - "{sel_n} selected · {counted}": "Zaznaczono {sel_n} · {counted}", - "{stage}… {:.0}%": "{stage}… {:.0}%", - "{verb} {n} Photo{}": "{verb} {n} zdjęć{}", - "{weekday}, {day}": "{weekday}, {day}", - "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", - "{what} merge added": "Dodano po scaleniu: {what}", - "{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}", - "{year}": "{year}", - "{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}", - "{} and higher": "{} i nowsze", - "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", - "{} failed": "{} nieudanych", - "{} files": "{} plików", - "{} files and {f} folder{}": "{} plików i {f} folderów{}", - "{} item{}": "{} elementów{}", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", - "{} photos": "{} zdjęć", - "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", - "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", - "{} photo{} not readable": "Nie można odczytać {} zdjęć{}", - "{} sample{}": "{} próbek{}", - "{} sec": "{} s", - "{} spot(s) on this photo": "{} plamek na tym zdjęciu", - "{} — show their photos": "{} — pokaż ich zdjęcia", - "· {dups} already in the library": "· {dups} już w bibliotece", - "· {n} photo{}": "· {n} zdjęć{}", - "… and {more} more": "… i jeszcze {more}", - "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", - "Filtered: {label}": "Filtrowanie: {label}", - "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} podobnych zdjęć{} · Widok ▸ Wyczyść filtry, aby zobaczyć wszystkie", - "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", - "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", - ", {} star{}": ", {} gwiazdek{}", - ", {} label": ", {} etykieta", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Pobieranie z {host}. Po pobraniu i weryfikacji LightCraft zainstaluje plik, {after}.", - "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Działa na procesorze: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Przechowywane w folderze „denoise” biblioteki; po osiągnięciu limitu najpierw usuwane są najstarsze pliki nieużywane przez żadne zdjęcie.", - "Denoising {name}: {done} of {total} tiles{}": "Odszumianie {name}: {done} z {total} kafelków{}", - "{size} GB": "{size} GB", - "{} of {} from {host}": "{} z {} z serwera {host}", - "{} KB": "{} KB", - "{} MB": "{} MB", - "Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%", - "{name} · {terms}": "{name} · {terms}", - "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Wytrenowano na: {provenance}", - " · {queued} waiting": " · {queued} oczekuje", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.", - "In line for AI Denoise ({ahead} ahead)…": "W kolejce do odszumiania AI ({ahead} przed tym zadaniem)…", - "Running on {}.": "Działa na {}.", - "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}", - "Put inside “{parent}”": "Umieść wewnątrz „{parent}”", - "Delete the keyword “{name}”?": "Usunąć słowo kluczowe „{name}”?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Zostanie usunięte z {count} zdjęć{}, razem z podrzędnymi słowami kluczowymi. Możesz to cofnąć.", - "Create Keyword Tag Inside “{name}”…": "Utwórz podrzędne słowo kluczowe w „{name}”…", - "“{parent}” has a “{name}” already. Merge the two?": "„{parent}” zawiera już „{name}”. Scalić oba?", - "There is a “{name}” at the top level already. Merge the two?": "Słowo kluczowe „{name}” istnieje już na najwyższym poziomie. Scalić oba?", - "Added “{keyword}” to {n} photo{}": "Dodano „{keyword}” do {n} zdjęć{}", - "On {have} of {of} selected photos": "Na {have} z {of} zaznaczonych zdjęć", - "Remove “{name}”": "Usuń „{name}”", - "There is a keyword set “{name}” already": "Istnieje już zestaw słów kluczowych „{name}”", - "Added {n} keyword{}": "Dodano {n} słów kluczowych{}", - "; {n} gained synonyms": "; {n} otrzymało synonimy", - "Exported {n} keyword{}": "Wyeksportowano {n} słów kluczowych{}", - " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One nie zaimportuje listy zawierającej ; , < lub > w słowie kluczowym: {names}", - " — left out, as a keyword list can't hold them: {names}": " — pominięto, ponieważ lista słów kluczowych ich nie obsługuje: {names}", - "{done} of {total}": "{done} z {total}", - "{done} of {total} MB": "{done} z {total} MB", - "{pct} %": "{pct} %", - "+{n} more": "+{n} więcej", - "1 task is still running: {names}. Quitting stops it.": "Wciąż trwa 1 zadanie: {names}. Zamknięcie programu je zatrzyma.", - "{n} tasks are still running: {names}. Quitting stops them.": "Wciąż trwają {n} zadania: {names}. Zamknięcie programu je zatrzyma.", - "{what} merge cancelled": "Scalanie anulowane: {what}" -} diff --git a/packaging/crates/ui-egui/locales/pl.json b/packaging/crates/ui-egui/locales/pl.json deleted file mode 100644 index f69e22643..000000000 --- a/packaging/crates/ui-egui/locales/pl.json +++ /dev/null @@ -1,2043 +0,0 @@ -{ - " (library default)": " (domyślne ustawienie biblioteki)", - " (inverted)": " (odwrócona)", - " · best of its burst": " · najlepsze zdjęcie z serii", - " · in a burst": " · w serii zdjęć", - "% of all commits": "% wszystkich zatwierdzonych zmian", - "+ Group": "+ Grupa", - "+ Rule": "+ Reguła", - "0: nothing is rejected.": "0: żadne zdjęcie nie jest odrzucane.", - "1 day": "1 dzień", - "= exactly": "= dokładnie", - "A blank template keeps the original names.": "Pusty szablon zachowuje oryginalne nazwy.", - "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Filtr ukrywa zdjęcia w tym widoku: zmień go lub wyczyść (Widok → Wyczyść filtry)", - "A one-time download of about": "Jednorazowe pobranie około", - "AI Mask Model Status": "Stan modelu masek AI", - "About": "O programie", - "About LightCraft": "O LightCraft", - "Active": "Aktywny", - "Activity": "Aktywność", - "Actual size": "Rzeczywisty rozmiar", - "Add": "Dodaj", - "Add Catchlight": "Dodaj błysk w oku", - "Add Folder to Local": "Dodaj folder do lokalnych", - "Add Mask": "Dodaj maskę", - "Add Object Click": "Dodaj zaznaczenie obiektu kliknięciem", - "Add Point Color Sample": "Dodaj próbkę koloru punktowego", - "Add Red Eye Correction": "Dodaj korektę czerwonych oczu", - "Add Remove Spot": "Dodaj usuwanie plamki", - "Add Selected Photos": "Dodaj zaznaczone zdjęcia", - "Add a rule (⌥-click: a group)": "Dodaj regułę (⌥ + kliknięcie: grupa)", - "Add in place": "Dodaj bez kopiowania plików", - "Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.", - "Add keyword": "Dodaj słowo kluczowe", - "Add number": "Dodaj numer", - "Add to": "Dodaj do", - "Add to Album": "Dodaj do albumu", - "Add to Favorites": "Dodaj do ulubionych", - "Add to Local": "Dodaj do lokalnych", - "Add to My Photos": "Dodaj do Moich zdjęć", - "Add to Selected Photos": "Dodaj do zaznaczonych zdjęć", - "Add to Target Album": "Dodaj do albumu docelowego", - "Add to this mask": "Dodaj do tej maski", - "Add/Subtract/Intersect Mask": "Dodaj/Odejmij/Przetnij maskę", - "Added to photos you import that don't already have it.": "Dodawane do importowanych zdjęć, które jeszcze tego nie mają.", - "Adding": "Dodawanie", - "Adding Lightroom photos…": "Dodawanie zdjęć z Lightrooma…", - "Advanced search…": "Wyszukiwanie zaawansowane…", - "After": "Po", - "After 30 days": "Po 30 dniach", - "After 7 days": "Po 7 dniach", - "After 90 days": "Po 90 dniach", - "After a year": "Po roku", - "Album": "Album", - "Albums": "Albumy", - "All": "Wszystkie", - "All Metadata": "Wszystkie metadane", - "All Metadata…": "Wszystkie metadane…", - "All Photos": "Wszystkie zdjęcia", - "All rights reserved": "Wszelkie prawa zastrzeżone", - "All the photos imported from this disk are included.": "Uwzględniono wszystkie zdjęcia zaimportowane z tego dysku.", - "All types": "Wszystkie typy", - "Alt Text": "Tekst alternatywny", - "Always": "Zawsze", - "Amount": "Ilość", - "Analyzing the photo for AI masks…": "Analizowanie zdjęcia pod kątem masek AI…", - "Any Searchable Text": "Dowolny tekst do wyszukania", - "Aperture": "Przysłona", - "Application": "Aplikacja", - "Apply": "Zastosuj", - "Apply Color Label Set": "Zastosuj zestaw etykiet kolorów", - "Apply Filter Preset": "Zastosuj ustawienia wstępne filtra", - "Apply Metadata Preset": "Zastosuj ustawienia wstępne metadanych", - "Apply Point Curve Preset": "Zastosuj ustawienia wstępne krzywej punktowej", - "Apply Preset": "Zastosuj ustawienie wstępne", - "Apply Settings JSON": "Zastosuj ustawienia JSON", - "Approved": "Zatwierdzone", - "Aqua": "Turkusowy", - "Aqua Hue": "Odcień turkusowego", - "Aqua Luminance": "Jasność turkusowego", - "Aqua Saturation": "Nasycenie turkusowego", - "Arrange Panel": "Rozmieść panel", - "ArtCraft Website": "Witryna ArtCraft", - "ArtCraft — more creative apps": "ArtCraft — więcej aplikacji kreatywnych", - "As Shot": "Jak na zdjęciu", - "As shot": "Jak zrobiono zdjęcie", - "Ascending": "Rosnąco", - "Aspect": "Proporcje", - "Aspect Ratio": "Proporcje obrazu", - "Assisted Culling": "Wspomagana selekcja", - "Assisted Culling…": "Wspomagana selekcja…", - "Auto": "Automatycznie", - "Auto Advance": "Automatyczne przechodzenie dalej", - "Auto Advance Off": "Automatyczne przechodzenie dalej wyłączone", - "Auto Advance On": "Automatyczne przechodzenie dalej włączone", - "Auto Advance after rating or flagging": "Przechodź dalej po ocenie lub oznaczeniu", - "Auto Advance: move to the next photo after rating or flagging": "Automatyczne przejście: przejdź do następnego zdjęcia po nadaniu oceny lub oznaczeniu flagą", - "Auto Align": "Automatyczne wyrównanie", - "Auto B&W Mix": "Automatyczne mieszanie czerni i bieli", - "Auto Crop": "Automatyczne kadrowanie", - "Auto Import": "Automatyczny import", - "Auto Import Now": "Importuj automatycznie teraz", - "Auto Import Settings": "Automatyczne ustawienia importu", - "Auto Mask": "Automatyczna maska", - "Auto Select": "Automatyczny wybór", - "Auto Settings": "Ustawienia automatyczne", - "Auto Straighten": "Automatyczne prostowanie", - "Auto Sync": "Automatyczna synchronizacja", - "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatyczna synchronizacja: zmiany obejmują wszystkie zaznaczone zdjęcia. Automatyczne mieszanie czerni i bieli. Automatyczne wersje.", - "Auto settings applied": "Zastosowano ustawienia automatyczne", - "Auto-Stack by Capture Time": "Automatycznie grupuj według czasu wykonania", - "Auto-Stack by Capture Time…": "Automatycznie grupuj według czasu wykonania…", - "Auto-Tag Photos from Tracklog": "Automatycznie oznacz zdjęcia na podstawie śladu GPS", - "Auto-Tag from Tracklog": "Automatycznie oznacz na podstawie śladu GPS", - "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatyczne tagowanie ze śladu: przypisuje zdjęciom pozycje GPS ze śladu GPX na podstawie czasu wykonania.", - "Auto-Tag from Tracklog…": "Automatycznie oznacz na podstawie śladu GPS…", - "Automatic": "Automatycznie", - "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Tryb automatyczny renderuje widok szczegółów w wyświetlanym rozmiarze, nie przekraczając rozdzielczości zdjęcia. Wybierz mniejszy limit, aby przyspieszyć działanie.", - "Automatically Write Changes into XMP": "Automatycznie zapisuj zmiany w XMP", - "Automatically write changes into XMP sidecars": "Automatycznie zapisuj zmiany w plikach pomocniczych XMP", - "Autumn Gold": "Jesienne złoto", - "B&W": "Czarno-białe", - "B&W Mix": "Mieszanie czerni i bieli", - "B&W Mixer": "Mikser czerni i bieli", - "Back": "Wstecz", - "Back Up Library…": "Utwórz kopię zapasową biblioteki…", - "Back to Edit": "Wróć do edycji", - "Back to Edit (E)": "Wróć do edycji (E)", - "Back to Grid": "Wróć do siatki", - "Background": "Tło", - "Balance": "Balans", - "Basic": "Podstawowe", - "Before": "Przed", - "Before/After (Y)": "Przed/Po (Y)", - "Before/After Settings": "Ustawienia porównania przed/po", - "Before/After Split": "Podział przed/po", - "Before/After Split Top/Bottom": "Podział przed/po góra/dół", - "Before/After Top/Bottom": "Przed/Po góra/dół", - "Begin Interaction": "Rozpocznij interakcję", - "Best of Similar Shots": "Najlepsze spośród podobnych ujęć", - "Binary assets added": "Dodano zasoby binarne", - "Binary assets removed": "Usunięto zasoby binarne", - "Bit depth": "Głębia bitowa", - "Black & White": "Czarno-białe", - "Blacks": "Czernie", - "Bleach Bypass": "Pominięcie wybielania", - "Bleached": "Wyblakły", - "Blending": "Mieszanie", - "Blue": "Niebieski", - "Blue Hour Boost": "Wzmocnienie błękitnej godziny", - "Blue Hue": "Odcień niebieskiego", - "Blue Luminance": "Luminancja niebieskiego", - "Blue Primary": "Podstawowy niebieski", - "Blue Saturation": "Nasycenie niebieskiego", - "Blue channel": "Kanał niebieski", - "Blue label": "Niebieska etykieta", - "Blue/Yellow Fringe": "Niebiesko-żółte obwódki", - "Blurry photos below the score are flagged as rejects.": "Nieostre zdjęcia z oceną poniżej progu są oznaczane jako odrzucone.", - "Boundary Warp": "Dopasowanie granic", - "Bright & Airy": "Jasne i lekkie", - "Bring All to Front": "Przenieś wszystko na wierzch", - "Browse Folder": "Przeglądaj folder", - "Browse Folder…": "Przeglądaj folder…", - "Browse Profiles": "Przeglądaj profile", - "Browse…": "Przeglądaj…", - "Brush": "Pędzel", - "Brush Settings": "Ustawienia pędzla", - "Brush Stroke": "Pociągnięcie pędzla", - "Build Previews": "Utwórz podglądy", - "Build Smart Previews": "Utwórz podglądy inteligentne", - "By Date": "Według daty", - "By day (YYYY/YYYY-MM-DD)": "Według dnia (RRRR/RRRR-MM-DD)", - "By month (YYYY/YYYY-MM)": "Według miesiąca (RRRR/RRRR-MM)", - "CPU threads": "Wątki procesora", - "Calibration": "Kalibracja", - "Camera": "Aparat", - "Camera make and model": "Marka i model aparatu", - "Cameras and Cards": "Aparaty i karty pamięci", - "Cancel": "Anuluj", - "Cancel AI Mask Model Download": "Anuluj pobieranie modelu masek AI", - "Cancel Download": "Anuluj pobieranie", - "Cancel Interaction": "Anuluj interakcję", - "Cancel Preview Build": "Anuluj tworzenie podglądu", - "Candidate": "Kandydat", - "Caption": "Podpis", - "Capture Date": "Data wykonania zdjęcia", - "Capture date and time in your own format (directives below)": "Data i godzina wykonania zdjęcia we własnym formacie (dyrektywy poniżej)", - "Capture date as YYYYMMDD": "Data wykonania jako RRRRMMDD", - "Captured": "Wykonano", - "Catalog Statistics": "Statystyki katalogu", - "Catchlight": "Błysk w oku", - "Change": "Zmień", - "Change Component Mode": "Zmień tryb składnika", - "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Znaki niedozwolone w nazwach plików (/ \\ : * ? \" < > |) są zastępowane myślnikiem; jeśli nazwa już istnieje, dodawane są przyrostki -1, -2….", - "Check All": "Zaznacz wszystko", - "Check Output Path": "Sprawdź ścieżkę wyjściową", - "Check the files of this type": "Sprawdź pliki tego typu", - "Checking names…": "Sprawdzanie nazw…", - "Choose Another Library…": "Wybierz inną bibliotekę…", - "Choose Edit Settings to Copy": "Wybierz ustawienia edycji do skopiowania", - "Choose Edit Settings to Copy…": "Wybierz ustawienia edycji do skopiowania…", - "Choose Folder…": "Wybierz folder…", - "Choose a photo in the grid or filmstrip": "Wybierz zdjęcie w siatce lub na pasku zdjęć", - "Choose…": "Wybierz…", - "Cinematic": "Filmowy", - "City": "Miasto", - "Clarity": "Przejrzystość", - "Clean Studio": "Czyste studio", - "Clear": "Wyczyść", - "Clear Cache": "Wyczyść pamięć podręczną", - "Clear Filters": "Wyczyść filtry", - "Clear Guides": "Wyczyść prowadnice", - "Clear History": "Wyczyść historię", - "Clear Preview Cache": "Wyczyść pamięć podręczną podglądów", - "Clear Quick Collection": "Wyczyść szybką kolekcję", - "Clear Reference": "Wyczyść zdjęcie referencyjne", - "Clear all": "Wyczyść wszystko", - "Clear form": "Wyczyść formularz", - "Clear rating": "Usuń ocenę", - "Clear rating and advance": "Usuń ocenę i przejdź dalej", - "Cleared History": "Wyczyszczono historię", - "Click Zoom Ratio": "Współczynnik powiększenia po kliknięciu", - "Click a shortcut, then press the new keys (Esc cancels).": "Kliknij skrót, a następnie naciśnij nowe klawisze (Esc anuluje).", - "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Kliknij znacznik, aby go wstawić. Przykłady dotyczą pliku IMG_0042.CR3 z 14 stycznia 2026 r., godz. 05:58:48.", - "Click photos in the grid to toggle the keyword; Esc stops": "Klikaj zdjęcia w siatce, aby przełączać słowo kluczowe; Esc kończy", - "Click the object to select it · ⌥-click leaves a part out": "Kliknij obiekt, aby go zaznaczyć · ⌥-kliknięcie wyklucza fragment", - "Click the photo to pick a colour · ⇧-click adds more": "Kliknij zdjęcie, aby pobrać kolor · ⇧-kliknięcie dodaje kolejne", - "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknij zdjęcie, aby pobrać kolor; ⇧-kliknięcie dodaje kolejne (maks. 5)", - "Click to clear these filters": "Kliknij, aby wyczyścić te filtry", - "Click to restore · double-click to rename": "Kliknij, aby przywrócić · kliknij dwukrotnie, aby zmienić nazwę", - "Clone": "Klonuj", - "Close": "Zamknij", - "Close Panel": "Zamknij panel", - "Close Window": "Zamknij okno", - "Close all": "Zamknij wszystko", - "Close file": "Zamknij plik", - "Close panel": "Zamknij panel", - "Cloudy": "Pochmurno", - "Collapse": "Zwiń", - "Collapse All Stacks": "Zwiń wszystkie stosy", - "Collapse Stack": "Zwiń stos", - "Color": "Kolor", - "Color Grading": "Gradacja kolorów", - "Color Label": "Etykieta kolorystyczna", - "Color Label Names": "Nazwy etykiet kolorów", - "Color Label Sets": "Zestawy etykiet kolorów", - "Color Mixer": "Mikser kolorów", - "Color Noise Reduction": "Redukcja szumu koloru", - "Color Priority": "Priorytet kolorów", - "Color Range": "Zakres kolorów", - "Color label cleared": "Usunięto etykietę koloru", - "Color space": "Przestrzeń barw", - "Color space: sRGB (AVIF)": "Przestrzeń kolorów: sRGB (AVIF)", - "Colors": "Kolory", - "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtrowanie według kilku etykiet kolorystycznych jednocześnie; rozwijane drzewo folderów w widoku lokalnym.", - "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Maski zakresu barw: kliknij zdjęcie, aby pobrać próbkę. Zakresy luminancji: pasek zakresu, płynność przejścia, mapa luminancji.", - "Combine files…": "Połącz pliki…", - "Command Journal": "Dziennik poleceń", - "Comments panel": "Panel komentarzy", - "Commits": "Zatwierdzone zmiany", - "Company": "Firma", - "Compare": "Porównaj", - "Compare (Shift+C)": "Porównaj (Shift+C)", - "Compare Before and After": "Porównaj przed i po", - "Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć", - "Component options": "Opcje składnika", - "Compression": "Kompresja", - "Concrete Cool": "Chłodny beton", - "Confirm before deleting": "Potwierdzaj przed usunięciem", - "Confirm before moving photos to Recently Deleted": "Potwierdzaj przeniesienie zdjęć do ostatnio usuniętych", - "Constrain Crop": "Ogranicz kadrowanie", - "Continue": "Kontynuuj", - "Continue Without Saving": "Kontynuuj bez zapisywania", - "Contrast": "Kontrast", - "Contributors": "Współtwórcy", - "Convert to DNG": "Konwertuj do DNG", - "Cool Fade": "Chłodne wyblaknięcie", - "Cool Morning": "Chłodny poranek", - "Copy": "Kopiuj", - "Copy After's Settings to Before": "Skopiuj ustawienia „Po” do „Przed”", - "Copy Before to After": "Skopiuj „Przed” do „Po”", - "Copy Before's Settings to After": "Skopiuj ustawienia „Przed” do „Po”", - "Copy Edit Settings": "Kopiuj ustawienia edycji", - "Copy History Step Settings to Before": "Skopiuj ustawienia kroku historii do „Przed”", - "Copy Metadata": "Kopiuj metadane", - "Copy as DNG": "Kopiuj jako DNG", - "Copy into the library (else use the files where they are)": "Kopiuj do biblioteki (w przeciwnym razie używaj plików w ich lokalizacji)", - "Copy pages": "Kopiuj strony", - "Copy the files (into the library's Originals/, or a folder you choose)": "Kopiuj pliki (do folderu Originals/ biblioteki lub wybranego folderu)", - "Copy to": "Kopiuj do", - "Copy to Clipboard": "Kopiuj do schowka", - "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopiuj: pliki zostaną skopiowane do poniższego folderu, a biblioteka będzie używać kopii; oryginały pozostaną bez zmian.", - "Copyright": "Prawa autorskie", - "Copyright Info URL": "Adres URL informacji o prawach autorskich", - "Copyright Status": "Status praw autorskich", - "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Stan praw autorskich, warunki wykorzystania i adres URL informacji o prawach autorskich w informacjach, ustawieniach wstępnych metadanych i eksporcie.", - "Country": "Kraj", - "Create Album": "Utwórz album", - "Create Album…": "Utwórz album…", - "Create Folder": "Utwórz folder", - "Create Folder…": "Utwórz folder…", - "Create New Mask": "Utwórz nową maskę", - "Create PDF from clipboard": "Utwórz PDF ze schowka", - "Create PDF from file…": "Utwórz PDF z pliku…", - "Create PDF from images…": "Utwórz PDF z obrazów…", - "Create Preset": "Utwórz ustawienie wstępne", - "Create Preset…": "Utwórz ustawienie wstępne…", - "Create Proof Copy": "Utwórz kopię próbną", - "Create Smart Album": "Utwórz inteligentny album", - "Create Smart Album from Filter…": "Utwórz album inteligentny z filtra…", - "Create Smart Album…": "Utwórz album inteligentny…", - "Create Stack": "Utwórz stos", - "Create Version": "Utwórz wersję", - "Create Version from Step": "Utwórz wersję z kroku", - "Create Virtual Copy": "Utwórz kopię wirtualną", - "Creator": "Twórca", - "Creator metadata": "Metadane twórcy", - "Crisp Landscape": "Wyrazisty krajobraz", - "Crisp Vista": "Wyrazisty widok", - "Crop": "Kadruj", - "Crop & Rotate": "Kadrowanie i obrót", - "Crop & Rotate (C)": "Kadruj i obracaj (C)", - "Crop Aspect": "Proporcje kadrowania", - "Crop, Rotate, Geometry": "Kadrowanie, obrót, geometria", - "Cross Process": "Obróbka krzyżowa", - "Culling": "Selekcja zdjęć", - "Curve": "Krzywa", - "Custom": "Niestandardowe", - "Custom template…": "Szablon niestandardowy…", - "Custom…": "Niestandardowe…", - "Cut": "Wytnij", - "Cut pages": "Wytnij strony", - "Cycle Crop Overlay": "Przełącz nakładkę kadrowania", - "Cycle Crop Overlay Orientation": "Przełącz orientację nakładki kadrowania", - "Cycle Info Overlay": "Przełącz nakładkę informacji", - "Cycle Mask Overlay Color": "Przełącz kolor nakładki maski", - "Cycle Mask Overlay Mode": "Przełącz tryb nakładki maski", - "Cylindrical": "Cylindryczny", - "Dark & Moody": "Ciemny i nastrojowy", - "Darken": "Przyciemnij", - "Darks": "Ciemne tony", - "Date": "Data", - "Date Groups": "Grupy dat", - "Day": "Dzień", - "Daylight": "Światło dzienne", - "Decrease Brush Feather": "Zmniejsz miękkość pędzla", - "Decrease Brush Size": "Zmniejsz rozmiar pędzla", - "Deep Sky": "Głębokie niebo", - "Defringe": "Usuń obwódki barwne", - "Deghost Amount": "Intensywność usuwania duchów", - "Dehaze": "Usuń zamglenie", - "Delete": "Usuń", - "Delete (⌫)": "Usuń (⌫)", - "Delete Album": "Usuń album", - "Delete All Masks": "Usuń wszystkie maski", - "Delete Color Label Set": "Usuń zestaw etykiet kolorów", - "Delete Component": "Usuń składnik", - "Delete Export Preset": "Usuń ustawienie wstępne eksportu", - "Delete Filter Preset": "Usuń ustawienie wstępne filtra", - "Delete Imported Profile": "Usuń zaimportowany profil", - "Delete Keyword": "Usuń słowo kluczowe", - "Delete Keyword Set": "Usuń zestaw słów kluczowych", - "Delete Mask": "Usuń maskę", - "Delete Metadata Preset": "Usuń ustawienie wstępne metadanych", - "Delete Permanently": "Usuń trwale", - "Delete Photo": "Usuń zdjęcie", - "Delete Photos": "Usuń zdjęcia", - "Delete Point Color": "Usuń kolor punktowy", - "Delete Point Color Sample": "Usuń próbkę koloru punktowego", - "Delete Point Curve Preset": "Usuń ustawienie wstępne krzywej punktowej", - "Delete Preset": "Usuń ustawienie wstępne", - "Delete Red Eye": "Usuń czerwone oczy", - "Delete Red Eye Correction": "Usuń korektę czerwonych oczu", - "Delete Spot": "Usuń plamkę", - "Delete Version": "Usuń wersję", - "Delete all": "Usuń wszystko", - "Delete all spots": "Usuń wszystkie plamki", - "Delete mask": "Usuń maskę", - "Delete them": "Usuń je", - "Density": "Gęstość", - "Depth Range": "Zakres głębi", - "Descending": "Malejąco", - "Describe": "Opisz", - "Describe what to keep": "Opisz, co zachować", - "Describe what to leave out": "Opisz, co wykluczyć", - "Describe what to select": "Opisz, co zaznaczyć", - "Describe…": "Opisz…", - "Deselect": "Odznacz", - "Deselect All": "Odznacz wszystko", - "Desert Heat": "Pustynny żar", - "Desert Warmth": "Pustynne ciepło", - "Desktop": "Pulpit", - "Detail": "Szczegóły", - "Detail (D)": "Szczegóły (D)", - "Details": "Szczegóły", - "Diagonal": "Przekątna", - "Discard Preview Cache": "Odrzuć pamięć podręczną podglądów", - "Display P3": "Display P3", - "Display name": "Nazwa wyświetlana", - "Display theme": "Motyw wyświetlania", - "Distortion": "Zniekształcenie", - "Document properties…": "Właściwości dokumentu…", - "Don't enlarge": "Nie powiększaj", - "Done": "Gotowe", - "Download": "Pobierz", - "Download AI Mask Model": "Pobierz model masek AI", - "Download the SAM 3 Model?": "Pobrać model SAM 3?", - "Downloads": "Pobrane", - "Drag along the horizon; double-click for Auto": "Przeciągnij wzdłuż horyzontu; kliknij dwukrotnie, aby użyć trybu automatycznego", - "Drag over an eye on the photo; the pupil inside is found automatically.": "Przeciągnij po oku na zdjęciu; źrenica zostanie wykryta automatycznie.", - "Draw Guides": "Rysuj prowadnice", - "Duplicate": "Duplikuj", - "Duplicate Component": "Duplikuj składnik", - "Duplicate Mask": "Duplikuj maskę", - "Duplicate and Invert Mask": "Duplikuj i odwróć maskę", - "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Każdy znak / rozpoczyna kolejny poziom folderu; znaczniki są uzupełniane osobno dla każdego zdjęcia (brakujące metadane oznaczają „nieznane”).", - "Edge": "Krawędź", - "Edit": "Edytuj", - "Edit (E)": "Edytuj (E)", - "Edit Capture Time": "Edytuj czas wykonania", - "Edit Capture Time…": "Edytuj czas wykonania…", - "Edit Color Label Names": "Edytuj nazwy etykiet kolorów", - "Edit Color Label Names…": "Edytuj nazwy etykiet kolorów…", - "Edit Copy for External Editor": "Edytuj kopię w edytorze zewnętrznym", - "Edit Date": "Edytuj datę", - "Edit Info": "Edytuj informacje", - "Edit Label Names": "Edytuj nazwy etykiet", - "Edit Label Names…": "Edytuj nazwy etykiet…", - "Edit Mask": "Edytuj maskę", - "Edit Mask Component": "Edytuj składnik maski", - "Edit Sections": "Edytuj sekcje", - "Edit Smart Album": "Edytuj album inteligentny", - "Edit Smart Album…": "Edytuj album inteligentny…", - "Edit Spot": "Edytuj plamkę", - "Edit in External Editor": "Edytuj w edytorze zewnętrznym", - "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Edytuj w zewnętrznym edytorze (⇧⌘E): powstaje 16-bitowa kopia TIFF, dodana do stosu i odświeżana po powrocie.", - "Edit settings copied": "Skopiowano ustawienia edycji", - "Edited": "Edytowane", - "Edited or not": "Edytowane lub nie", - "Editing": "Edycja", - "Effect": "Efekt", - "Effects": "Efekty", - "Empty Recently Deleted": "Opróżnij ostatnio usunięte", - "Enable Lens Corrections": "Włącz korekcję obiektywu", - "Enable Profile Corrections": "Włącz korekcje profilu", - "End Interaction": "Zakończ interakcję", - "English": "Angielski", - "Enter Full Screen": "Przejdź na pełny ekran", - "Enter a date as YYYY-MM-DD HH:MM:SS": "Wprowadź datę jako RRRR-MM-DD GG:MM:SS", - "Erase": "Wymaż", - "Every EXIF, GPS and XMP field in the file": "Każde pole EXIF, GPS i XMP w pliku", - "Existing previews": "Istniejące podglądy", - "Exit": "Zakończ", - "Expand": "Rozwiń", - "Expand All Stacks": "Rozwiń wszystkie stosy", - "Expand Stack": "Rozwiń stos", - "Expand/Collapse Stack": "Rozwiń/Zwiń stos", - "Export": "Eksportuj", - "Export Album with Preset": "Eksportuj album z ustawieniem wstępnym", - "Export Album…": "Eksportuj album…", - "Export As…": "Eksportuj jako…", - "Export Group…": "Eksportuj grupę…", - "Export Now": "Eksportuj teraz", - "Export Point Curve Presets": "Eksportuj ustawienia wstępne krzywej punktowej", - "Export Point Curve Presets…": "Eksportuj ustawienia wstępne krzywej punktowej…", - "Export Presets": "Eksportuj ustawienia wstępne", - "Export Presets…": "Eksportuj ustawienia wstępne…", - "Export User Presets…": "Eksportuj ustawienia wstępne użytkownika…", - "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Nazwy plików przy eksporcie używają znaczników Zmień nazwy zdjęć ({title}, {seq:2}, {date:%Y-%m-%d}…) oraz nowych: {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", - "Export stopped unexpectedly": "Eksport został nieoczekiwanie przerwany", - "Export with Preset": "Eksportuj z ustawieniem wstępnym", - "Export with Previous": "Eksportuj z poprzednimi ustawieniami", - "Exporting": "Eksportowanie", - "Export…": "Eksportuj…", - "Exposure": "Ekspozycja", - "Exposure (shutter · aperture · ISO)": "Ekspozycja (migawka · przysłona · ISO)", - "Extended Description": "Opis rozszerzony", - "External Editor": "Edytor zewnętrzny", - "Face Boxes": "Ramki twarzy", - "Face boxes": "Ramki twarzy", - "Face names written to XMP by Lightroom and other apps show up here.": "Imiona osób zapisane w XMP przez Lightrooma i inne aplikacje pojawiają się tutaj.", - "Faded Matte": "Wyblakły mat", - "Faded Slide": "Wyblakły slajd", - "Faded Urban": "Wyblakłe miasto", - "Favorite Preset": "Ulubione ustawienie wstępne", - "Favorite Profile": "Ulubiony profil", - "Favorites": "Ulubione", - "Feather": "Miękkość krawędzi", - "File": "Plik", - "File & date": "Plik i data", - "File Format": "Format pliku", - "File Name": "Nazwa pliku", - "File Path": "Ścieżka pliku", - "File Size": "Rozmiar pliku", - "File Type": "Typ pliku", - "File name": "Nazwa pliku", - "File types:": "Typy plików:", - "Filename": "Nazwa pliku", - "Fill": "Wypełnij", - "Fill Edges": "Wypełnij krawędzie", - "Film": "Film", - "Filmstrip": "Pasek zdjęć", - "Filmstrip (/)": "Pasek zdjęć (/)", - "Filter": "Filtr", - "Filter Bar": "Pasek filtrów", - "Filter Presets": "Ustawienia wstępne filtrów", - "Filter bar": "Pasek filtrów", - "Filter fields": "Pola filtrów", - "Find Dust Spots": "Znajdź drobiny kurzu", - "Find Missing Photos": "Znajdź brakujące zdjęcia", - "Find Missing Photos…": "Znajdź brakujące zdjęcia…", - "Find Similar Photos": "Znajdź podobne zdjęcia", - "Find tools and commands…": "Znajdź narzędzia i polecenia…", - "Find…": "Znajdź…", - "First": "Pierwszy", - "First commit": "Pierwszy commit", - "Fit": "Dopasuj", - "Fit (F)": "Dopasuj (F)", - "Fit on Screen": "Dopasuj do ekranu", - "Fit to width": "Dopasuj do szerokości", - "Fit visible": "Dopasuj widoczne", - "Five stars": "Pięć gwiazdek", - "Fixed keys": "Stałe klawisze", - "Flag": "Oznacz", - "Flag as Pick": "Oznacz jako wybrane", - "Flag as Reject": "Oznacz jako odrzucone", - "Flagged as Pick": "Oznaczone jako wybrane", - "Flagged as Reject": "Oznaczone jako odrzucone", - "Flash": "Lampa błyskowa", - "Flip H": "Odbij w poziomie", - "Flip Horizontal": "Odbij poziomo", - "Flip Overlay (⇧O)": "Odwróć nakładkę (⇧O)", - "Flip V": "Odbij w pionie", - "Flip Vertical": "Odbij pionowo", - "Float panel": "Odłącz panel", - "Flow": "Przepływ", - "Fluorescent": "Światło fluorescencyjne", - "Focal Length": "Ogniskowa", - "Focal length": "Ogniskowa", - "Focus (assisted culling)": "Ostrość (wspomagana selekcja)", - "Folder": "Folder", - "Folder name (renamed on disk; its photos follow)": "Nazwa folderu (zmieniana na dysku wraz ze zdjęciami)", - "Folders": "Foldery", - "Folders and {date} use the capture time; a photo without one uses today's date.": "Foldery i {date} używają daty wykonania; gdy zdjęcie jej nie ma, używana jest dzisiejsza data.", - "Food": "Jedzenie", - "Forget Unchanged Local Photos": "Zapomnij o niezmienionych zdjęciach lokalnych", - "Forget unchanged photos": "Zapomnij o niezmienionych zdjęciach", - "Form fields panel": "Panel pól formularza", - "Format": "Format", - "Forward": "Dalej", - "Frame time": "Czas klatki", - "Frame update": "Aktualizacja klatki", - "Free": "Wolne", - "Fresh & Bright": "Świeżo i jasno", - "Friday": "Piątek", - "Full": "Pełny", - "Full Screen Preview": "Podgląd pełnoekranowy", - "Full screen mode": "Tryb pełnoekranowy", - "Full size": "Pełny rozmiar", - "GPS Track Log": "Ślad GPS", - "GPU Rendering": "Renderowanie GPU", - "GPU rendering": "Renderowanie na GPU", - "General": "Ogólne", - "Generated demo photo": "Wygenerowane zdjęcie demonstracyjne", - "Geometry": "Geometria", - "Get Develop Settings": "Pobierz ustawienia wywoływania", - "Global Hue": "Globalny odcień", - "Global Luminance": "Globalna luminancja", - "Global Saturation": "Globalne nasycenie", - "Glossy": "Błyszczący", - "Go to History Step": "Przejdź do kroku historii", - "Golden": "Złocisty", - "Golden Glow": "Złota poświata", - "Golden Hour": "Złota godzina", - "Grab bag": "Zbiór różnych elementów", - "Grain": "Ziarno", - "Graphic": "Graficzny", - "Green": "Zielony", - "Green Amount": "Ilość zieleni", - "Green Hue": "Odcień zieleni", - "Green Hue High": "Górny zakres odcienia zieleni", - "Green Hue Low": "Dolny zakres odcienia zieleni", - "Green Luminance": "Luminancja zieleni", - "Green Primary": "Zieleń podstawowa", - "Green Saturation": "Nasycenie zieleni", - "Green channel": "Kanał zielony", - "Green label": "Zielona etykieta", - "Grid": "Siatka", - "Grid Info": "Informacje w siatce", - "Gritty Street": "Surowa ulica", - "Group": "Grupa", - "Group by Date: Automatic": "Grupowanie według daty: automatycznie", - "Group by Day": "Grupuj według dnia", - "Group by Month": "Grupuj według miesiąca", - "Group by Year": "Grupuj według roku", - "Group by date": "Grupuj według daty", - "Group into Stack": "Zgrupuj w stos", - "Group name": "Nazwa grupy", - "Guided": "Z prowadnicami", - "Guided Upright": "Korekcja perspektywy z prowadnicami", - "Guided Upright Guides": "Prowadnice korekcji perspektywy", - "HDR Merge": "Scalanie HDR", - "HDR Merge Preview": "Podgląd scalania HDR", - "HDR Panorama Merge": "Scal panoramę HDR", - "HDR Panorama Merge Preview": "Podgląd scalania panoramy HDR", - "HDR Panorama with Last Settings": "Panorama HDR z ostatnimi ustawieniami", - "HDR Panoramas": "Panoramy HDR", - "HDR Panorama…": "Panorama HDR…", - "HDR panorama": "Panorama HDR", - "HDR panoramas": "Panoramy HDR", - "HDR with Last Settings": "HDR z ostatnimi ustawieniami", - "HDR…": "HDR…", - "Has Edits": "Ma edycje", - "Has GPS": "Ma dane GPS", - "Heal": "Korekta", - "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Usuń retuszem niewielkie, miękkie ciemne plamki pozostawione przez kurz na matrycy", - "Height": "Wysokość", - "Height (px)": "Wysokość (px)", - "Help": "Pomoc", - "Hide LightCraft": "Ukryj LightCraft", - "Hide Mask": "Ukryj maskę", - "Hide Others": "Ukryj pozostałe", - "Hides this shortcut only; the folder and its photos stay as they are": "Ukrywa tylko ten skrót; folder i zdjęcia pozostają bez zmian", - "High": "Wysoki", - "High Contrast B&W": "Czarno-biały o wysokim kontraście", - "Highlight": "Światła", - "Highlights": "Światła", - "Highlights Hue": "Odcień świateł", - "Highlights Luminance": "Luminancja świateł", - "Highlights Saturation": "Nasycenie świateł", - "Highlights split": "Podział świateł", - "Histogram": "Histogram", - "History": "Historia", - "History & Activity (Y)": "Historia i aktywność (Y)", - "Home": "Strona główna", - "Horizontal": "Poziomo", - "Horizontal text": "Tekst poziomy", - "Hours": "Godziny", - "Hue": "Odcień", - "Hue Range": "Zakres odcieni", - "ISO Speed": "Czułość ISO", - "ISO speed": "Czułość ISO", - "If file exists": "Jeśli plik istnieje", - "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Jeśli nie można otworzyć biblioteki przy uruchamianiu (jest używana w innym programie, nieczytelna lub znajduje się na odłączonym dysku,", - "Image": "Obraz", - "Import": "Importuj", - "Import Date": "Data importu", - "Import Lightroom Catalog": "Importuj katalog Lightrooma", - "Import Lightroom Catalog…": "Importuj katalog Lightrooma…", - "Import Photos": "Importuj zdjęcia", - "Import Photos…": "Importuj zdjęcia…", - "Import Point Curve Presets": "Importuj ustawienia krzywej punktowej", - "Import Point Curve Presets…": "Importuj ustawienia krzywej punktowej…", - "Import Presets": "Importuj ustawienia wstępne", - "Import Presets…": "Importuj ustawienia wstępne…", - "Import Profiles": "Importuj profile", - "Import Profiles & Presets…": "Importuj profile i ustawienia wstępne…", - "Import as new": "Importuj jako nowe", - "Import from Device": "Importuj z urządzenia", - "Import from Folder…": "Importuj z folderu…", - "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importuj zdjęcia przez Plik → Importuj zdjęcia… (Cmd+Shift+I) lub przeciągnij je tutaj", - "Imported": "Zaimportowane", - "Imported Photos": "Zaimportowane zdjęcia", - "Importing": "Importowanie", - "Importing Lightroom Catalog": "Importowanie katalogu Lightrooma", - "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Importowanie wyszukuje zdjęcia w źródle; nie dodaje folderu do sekcji Lokalne (użyj Lokalne → Przeglądaj folder…, aby pracować w folderze bez importowania).", - "In Photo Grid and Square Grid: Flag as Pick and advance": "W siatce zdjęć i siatce kwadratowej: oznacz jako wybrane i przejdź dalej", - "In Photo Grid and Square Grid: expand or collapse the stack": "W siatce zdjęć i siatce kwadratowej: rozwiń lub zwiń stos", - "In Recently Deleted": "W ostatnio usuniętych", - "In library": "W bibliotece", - "In memory — nothing is saved": "W pamięci — nic nie zapisano", - "In use": "W użyciu", - "In-memory session: these settings last until LightCraft quits.": "Sesja w pamięci: te ustawienia obowiązują do zamknięcia LightCraft.", - "Include subfolders": "Uwzględnij podfoldery", - "Increase Brush Feather": "Zwiększ miękkość pędzla", - "Increase Brush Size": "Zwiększ rozmiar pędzla", - "Info": "Informacje", - "Info (I)": "Informacje (I)", - "Info Overlay Off": "Wyłącz nakładkę informacyjną", - "Info Overlay: Exposure": "Nakładka informacyjna: ekspozycja", - "Info Overlay: File": "Nakładka informacyjna: plik", - "Info overlay": "Nakładka informacyjna", - "Insert at the cursor": "Wstaw w miejscu kursora", - "Inspect Lightroom Catalog": "Sprawdź katalog Lightrooma", - "Inspect Photo": "Sprawdź zdjęcie", - "Inspecting Lightroom Catalog": "Sprawdzanie katalogu Lightrooma", - "Instant ’70s": "Lata 70. od ręki", - "Interface": "Interfejs", - "Interface language": "Język interfejsu", - "Intersect": "Część wspólna", - "Intersect with": "Przetnij z", - "Into one folder": "Do jednego folderu", - "Invert": "Odwróć", - "Invert Component": "Odwróć składnik", - "Invert Mask": "Odwróć maskę", - "JPEG (Large)": "JPEG (duży)", - "JPEG (Small)": "JPEG (mały)", - "Join the ArtCraft Discord": "Dołącz do Discorda ArtCraft", - "Join the ArtCraft Discord…": "Dołącz do Discorda ArtCraft…", - "Join the ArtCraft community on Discord": "Dołącz do społeczności ArtCraft na Discordzie", - "Keep in Local": "Zachowaj w lokalnych", - "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Przechowuj ją na dysku z wolnym miejscem: podglądy inteligentne zajmują około 1 MB na zdjęcie. Pamięć podręczna miniatur pozostaje w bibliotece.", - "Keyboard Shortcuts": "Skróty klawiaturowe", - "Keyboard shortcuts": "Skróty klawiaturowe", - "Keyword": "Słowo kluczowe", - "Keyword Painter": "Pędzel słów kluczowych", - "Keyword Set": "Zestaw słów kluczowych", - "Keyword Sets": "Zestawy słów kluczowych", - "Keyword Suggestions": "Sugestie słów kluczowych", - "Keyword to paint": "Słowo kluczowe do nanoszenia", - "Keywords": "Słowa kluczowe", - "Keywords (K)": "Słowa kluczowe (K)", - "Keywords you add appear here; ⌥1–⌥9 apply them.": "Dodane słowa kluczowe pojawiają się tutaj; skróty ⌥1–⌥9 przypisują je do zdjęć.", - "Label": "Etykieta", - "Landscape": "Krajobraz", - "Language": "Język", - "Last": "Ostatni", - "Last GPU fallback": "Ostatnie przełączenie awaryjne GPU", - "Last commit": "Ostatni zapis zmian", - "Last loupe render": "Ostatnie renderowanie lupy", - "Last view": "Ostatni widok", - "Lateral Chromatic Aberration": "Poprzeczna aberracja chromatyczna", - "Layer": "Warstwa", - "Layers": "Warstwy", - "Leave them": "Pozostaw je", - "Lens": "Obiektyw", - "Lens Blur": "Rozmycie obiektywu", - "Lens Corrections": "Korekcje obiektywu", - "Level": "Poziom", - "Library": "Biblioteka", - "Library Info": "Informacje o bibliotece", - "Library Originals": "Oryginały biblioteki", - "Library Preferences": "Preferencje biblioteki", - "Library State": "Stan biblioteki", - "Library saved": "Biblioteka zapisana", - "Licence:": "Licencja:", - "Light": "Światło", - "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 i starsze wersje odrzucają ten plik („nieobsługiwany format … v2”).", - "LightCraft Default": "Domyślne ustawienia LightCraft", - "LightCraft Help": "Pomoc LightCraft", - "LightCraft Preset": "Ustawienie wstępne LightCraft", - "LightCraft Presets": "Ustawienia wstępne LightCraft", - "LightCraft Website": "Strona LightCraft", - "LightCraft could not start": "Nie można uruchomić LightCraft", - "LightCraft couldn't find where to keep your library.": "LightCraft nie znalazł miejsca na przechowywanie biblioteki.", - "LightCraft couldn't open the library at": "LightCraft nie mógł otworzyć biblioteki w lokalizacji", - "LightCraft on GitHub": "LightCraft w serwisie GitHub", - "LightCraft website": "Strona internetowa LightCraft", - "LightCraft — Second Window": "LightCraft — drugie okno", - "Lightroom Classic Catalog": "Katalog Lightroom Classic", - "Lightroom import cancelled": "Anulowano import z Lightrooma", - "Lightroom inspection cancelled": "Anulowano sprawdzanie Lightrooma", - "Lights": "Światła", - "Limit file size (KB, 0 = off)": "Limit rozmiaru pliku (KB, 0 = wyłączone)", - "Line delta": "Zmiana liczby wierszy", - "Linear": "Liniowy", - "Linear Gradient": "Gradient liniowy", - "Lines +/−": "Wiersze +/−", - "Lines added": "Dodane wiersze", - "Lines deleted": "Usunięte wiersze", - "List Albums": "Wyświetl albumy", - "List Develop Controls": "Wyświetl ustawienia wywoływania", - "List History": "Wyświetl historię", - "List Presets": "Wyświetl ustawienia predefiniowane", - "List Profiles": "Wyświetl profile", - "List this folder in Local from now on": "Od teraz pokazuj ten folder w widoku lokalnym", - "Local": "Lokalne", - "Local folders": "Foldery lokalne", - "Local library — no cloud account needed": "Biblioteka lokalna — konto w chmurze nie jest potrzebne", - "Locate Missing File…": "Znajdź brakujący plik…", - "Locate Photo": "Znajdź zdjęcie", - "Location": "Lokalizacja", - "Lock": "Zablokuj", - "Lock Crop Aspect": "Zablokuj proporcje kadrowania", - "Locked": "Zablokowane", - "Long Edge": "Dłuższy bok", - "Looking for photos…": "Wyszukiwanie zdjęć…", - "Lossless": "Bezstratny", - "Lossy DNG files and Smart Previews open as raw photos.": "Stratne pliki DNG i podglądy inteligentne otwierają się jako zdjęcia RAW.", - "Low": "Niski", - "Luminance": "Luminancja", - "Luminance Range": "Zakres luminancji", - "Lush Greens": "Soczysta zieleń", - "Magenta": "Purpurowy", - "Magenta Hue": "Odcień purpury", - "Magenta Luminance": "Luminancja purpury", - "Magenta Saturation": "Nasycenie purpury", - "Make Candidate the Select": "Ustaw zdjęcie kandydujące jako wybrane", - "Manual": "Ręcznie", - "Marquee zoom": "Powiększanie zaznaczonego obszaru", - "Mask Adjustment": "Korekta maski", - "Masking": "Maskowanie", - "Masking (M)": "Maskowanie (M)", - "Masks": "Maski", - "Match": "Dopasuj", - "Match Total Exposures": "Wyrównaj całkowitą ekspozycję", - "Matte": "Matowy", - "Matte Soft": "Delikatny mat", - "Med": "Śr.", - "Medium Contrast": "Średni kontrast", - "Megapixels": "Megapiksele", - "Memory Budget": "Limit pamięci", - "Memory Usage": "Wykorzystanie pamięci", - "Memory budget": "Limit pamięci", - "Memory for caches": "Pamięć na pamięć podręczną", - "Menu": "Menu", - "Merge": "Scal", - "Merge Keywords": "Połącz słowa kluczowe", - "Merge into…": "Scal z…", - "Merged": "Scalone", - "Merged PRs": "Scalone zgłoszenia PR", - "Merging…": "Scalanie…", - "Metadata": "Metadane", - "Metadata Preset": "Zestaw metadanych", - "Metadata Presets": "Zestawy metadanych", - "Metadata preset": "Zestaw metadanych", - "Midpoint": "Punkt środkowy", - "Midtones": "Tony średnie", - "Midtones Hue": "Odcień tonów średnich", - "Midtones Luminance": "Luminancja tonów średnich", - "Midtones Saturation": "Nasycenie tonów średnich", - "Midtones split": "Podział tonów średnich", - "Minimize": "Minimalizuj", - "Missing Photos": "Brakujące zdjęcia", - "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Brakujące metadane ({camera}, {title}…) pozostawiają puste miejsce; jeśli wynikowa nazwa jest pusta, zachowywana jest nazwa oryginalna.", - "Misty Morning": "Mglisty poranek", - "Mode": "Tryb", - "Model": "Model", - "Models": "Modele", - "Modified Date": "Data modyfikacji", - "Monday": "Poniedziałek", - "Mono Red Filter": "Monochromatyczny filtr czerwony", - "Mono Rich": "Bogata monochromia", - "Mono Soft": "Delikatna monochromia", - "Monochrome": "Monochromatyczny", - "Month": "Miesiąc", - "Moody": "Nastrojowy", - "Moody Portrait": "Nastrojowy portret", - "More": "Więcej", - "More preset options": "Więcej opcji ustawienia predefiniowanego", - "Move": "Przenieś", - "Move Album": "Przenieś album", - "Move Down": "Przenieś w dół", - "Move Down in Stack": "Przenieś niżej w stosie", - "Move Folder": "Przenieś folder", - "Move Folder To…": "Przenieś folder do…", - "Move Mask": "Przenieś maskę", - "Move Preset to Group": "Przenieś ustawienie predefiniowane do grupy", - "Move Preset to New Group": "Przenieś ustawienie predefiniowane do nowej grupy", - "Move Up": "Przenieś w górę", - "Move Up in Stack": "Przenieś wyżej w stosie", - "Move in Stack": "Przenieś w stosie", - "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Przenieś pliki (do folderu Originals/ w bibliotece lub do wybranego folderu), usuwając je z lokalizacji źródłowej", - "Move them": "Przenieś je", - "Move to": "Przenieś do", - "Move to Group": "Przenieś do grupy", - "Move to group": "Przenieś do grupy", - "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Przenosi wszystkie zdjęcia zaimportowane z tego dysku do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", - "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Przenosi zdjęcia zaimportowane z tego folderu do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", - "Muted": "Stonowany", - "Muted Film": "Stonowany film", - "My Photos": "Moje zdjęcia", - "My Photos Panel": "Panel Moje zdjęcia", - "Name": "Nazwa", - "Name (A–Z)": "Nazwa (A–Z)", - "Name of the folder the original is in": "Nazwa folderu z oryginałem", - "Named": "Nazwane", - "Named People": "Osoby z przypisanymi imionami", - "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Nazwy pojawiają się w menu etykiet, pasku filtrów i panelu informacji oraz są zapisywane w XMP. Puste pole oznacza nazwę koloru.", - "Navigator": "Nawigator", - "Needs Work": "Wymaga dopracowania", - "Neon Dusk": "Neonowy zmierzch", - "Neon Night": "Neonowa noc", - "Nested group": "Grupa zagnieżdżona", - "Neutral": "Neutralny", - "Never": "Nigdy", - "New": "Nowy", - "New Album": "Nowy album", - "New Album…": "Nowy album…", - "New Folder…": "Nowy folder…", - "New Group…": "Nowa grupa…", - "New Smart Album": "Nowy album inteligentny", - "New Smart Album from Filter…": "Nowy album inteligentny z filtra…", - "New Smart Album…": "Nowy album inteligentny…", - "New album": "Nowy album", - "New album…": "Nowy album…", - "New blank PDF": "Nowy pusty PDF", - "New name": "Nowa nazwa", - "New time": "Nowy czas", - "New…": "Nowy…", - "Next Photo": "Następne zdjęcie", - "Night Blue": "Nocny błękit", - "No": "Nie", - "No Camera or Card Found": "Nie znaleziono aparatu ani karty pamięci", - "No Date Groups": "Brak grup według dat", - "No Stars": "Brak gwiazdek", - "No Tool": "Brak narzędzia", - "No automatic versions.": "Brak automatycznych wersji.", - "No camera": "Brak aparatu", - "No contributor data was built into this copy.": "W tej wersji nie zapisano danych współtwórców.", - "No date": "Brak daty", - "No dust spots found": "Nie znaleziono plamek kurzu", - "No edits yet.": "Nie wprowadzono jeszcze zmian.", - "No exposure information": "Brak informacji o ekspozycji", - "No filter presets yet": "Brak zapisanych zestawów filtrów", - "No lens data embedded in this file (DNG lens opcodes only).": "Ten plik nie zawiera danych obiektywu (dostępne są jedynie instrukcje obiektywu DNG).", - "No masks yet. Choose a mask type above.": "Nie ma jeszcze masek. Wybierz typ maski powyżej.", - "No matching commands": "Brak pasujących poleceń", - "No matching photos": "Brak pasujących zdjęć", - "No metadata found": "Nie znaleziono metadanych", - "No model credits were built into this copy.": "W tej wersji nie zapisano informacji o autorach modeli.", - "No named people in these photos": "Na tych zdjęciach nie ma osób z przypisanymi imionami", - "No named people yet": "Nie nazwano jeszcze żadnych osób", - "No new presets": "Brak nowych ustawień predefiniowanych", - "No photo selected": "Nie wybrano zdjęcia", - "No photos": "Brak zdjęć", - "No photos found": "Nie znaleziono zdjęć", - "No photos in this folder": "W tym folderze nie ma zdjęć", - "No photos match the active filters": "Żadne zdjęcia nie spełniają aktywnych filtrów", - "No photos match the filters (View → Clear Filters)": "Żadne zdjęcia nie spełniają filtrów (Widok → Wyczyść filtry)", - "No raw photos yet: cameras appear here once their photos are in the library.": "Brak zdjęć RAW. Aparaty pojawią się tutaj po dodaniu ich zdjęć do biblioteki.", - "No reference photo": "Brak zdjęcia referencyjnego", - "No usable GPU found: rendering on the CPU": "Nie znaleziono obsługiwanej karty graficznej: renderowanie na procesorze", - "No versions yet. Create one to keep this look.": "Nie ma jeszcze wersji. Utwórz wersję, aby zachować ten wygląd.", - "Noise": "Szum", - "Noise Reduction": "Redukcja szumu", - "Non-raw photos": "Zdjęcia inne niż RAW", - "None": "Brak", - "Not Now": "Nie teraz", - "Nothing to compare": "Nie ma czego porównywać", - "Nothing to survey": "Nie ma czego przeglądać", - "Nudge Develop Value": "Zmień nieznacznie wartość wywoływania", - "OK": "OK", - "Object": "Obiekt", - "Object Mask": "Maska obiektu", - "October 2026": "Październik 2026", - "Off": "Wyłączone", - "Offset X": "Przesunięcie X", - "Offset Y": "Przesunięcie Y", - "Opacity": "Krycie", - "Opacity (%)": "Krycie (%)", - "Open Library": "Otwórz bibliotekę", - "Open Library…": "Otwórz bibliotekę…", - "Open in": "Otwórz w", - "Open in Detail": "Otwórz w widoku szczegółowym", - "Open the place in OpenStreetMap": "Otwórz lokalizację w OpenStreetMap", - "Open…": "Otwórz…", - "Optics": "Optyka", - "Optimize Library": "Optymalizuj bibliotekę", - "Optional name": "Nazwa (opcjonalnie)", - "Options": "Opcje", - "Orange": "Pomarańczowy", - "Orange Hue": "Odcień pomarańczowego", - "Orange Luminance": "Luminancja pomarańczowego", - "Orange Saturation": "Nasycenie pomarańczowego", - "Orientation": "Orientacja", - "Original": "Oryginał", - "Original + Settings": "Oryginał + ustawienia", - "Original extension without the dot": "Oryginalne rozszerzenie bez kropki", - "Original file name without its extension": "Oryginalna nazwa pliku bez rozszerzenia", - "Original files": "Pliki oryginalne", - "Original offline · editing the smart preview": "Oryginał niedostępny · edycja podglądu inteligentnego", - "Original offline · no smart preview": "Oryginał niedostępny · brak podglądu inteligentnego", - "Originals": "Oryginały", - "Other images (JPEG, PNG, TIFF, HEIC…)": "Inne obrazy (JPEG, PNG, TIFF, HEIC…)", - "Overlay": "Nakładka", - "Overwrite": "Nadpisz", - "Pages": "Strony", - "Paint": "Malowanie", - "Paint only areas like the one under the brush": "Maluj tylko obszary podobne do tego pod pędzlem", - "Paint over a distraction on the photo to remove it.": "Zamaluj niepożądany element na zdjęciu, aby go usunąć.", - "Paint overlay": "Nakładka malowania", - "Panels": "Panele", - "Panorama": "Panorama", - "Panorama Merge": "Łączenie panoramy", - "Panorama Merge Preview": "Podgląd łączenia panoramy", - "Panorama with Last Settings": "Panorama z ostatnimi ustawieniami", - "Panoramas": "Panoramy", - "Panorama…": "Panorama…", - "Parametric curve": "Krzywa parametryczna", - "Paste": "Wklej", - "Paste Edit Settings": "Wklej ustawienia edycji", - "Paste Metadata": "Wklej metadane", - "Paste Selected Settings": "Wklej wybrane ustawienia", - "Paste Selected Settings…": "Wklej wybrane ustawienia…", - "Paste Settings": "Wklej ustawienia", - "Paste Settings from Previous": "Wklej ustawienia z poprzedniego", - "Paste from Previous": "Wklej z poprzedniego", - "Paste pages": "Wklej strony", - "Pastel Haze": "Pastelowa mgiełka", - "People": "Osoby", - "Percent": "Procent", - "Percentage": "Wartość procentowa", - "Performance": "Wydajność", - "Person": "Osoba", - "Perspective": "Perspektywa", - "Pet Eye": "Oczy zwierząt", - "Pet Eye Catchlight": "Blik w oczach zwierząt", - "Photo": "Zdjęcie", - "Photo Grid": "Siatka zdjęć", - "Photo Grid (G)": "Siatka zdjęć (G)", - "Photo Merge": "Łączenie zdjęć", - "Photos": "Zdjęcia", - "Photos per bracket (0 = auto)": "Zdjęć w serii ekspozycji (0 = automatycznie)", - "Pick": "Wybierz", - "Pick Flag": "Flaga wyboru", - "Pick a colour on the photo with the eyedropper.": "Wybierz kolor ze zdjęcia za pomocą pipety.", - "Pick the sharpest photo of each burst": "Wybierz najostrzejsze zdjęcie z każdej serii", - "Picked": "Wybrane", - "Picks": "Wybrane zdjęcia", - "Pictures": "Obrazy", - "Pixels": "Piksele", - "Point Color": "Kolor punktowy", - "Point Curve": "Krzywa punktowa", - "Point Curve Presets": "Ustawienia wstępne krzywej punktowej", - "Point curve": "Krzywa punktowa", - "Portrait": "Portret", - "Position": "Położenie", - "Preferences": "Preferencje", - "Preferences…": "Preferencje…", - "Prepare AI Masks": "Przygotuj maski AI", - "Preset": "Ustawienie wstępne", - "Preset name": "Nazwa ustawienia wstępnego", - "Presets": "Ustawienia wstępne", - "Presets & Profiles": "Ustawienia wstępne i profile", - "Presets (Shift+P)": "Ustawienia wstępne (Shift+P)", - "Presets and profiles": "Ustawienia wstępne i profile", - "Presets ▾": "Ustawienia wstępne ▾", - "Press keys…": "Naciśnij klawisze…", - "Preview": "Podgląd", - "Preview Build Progress": "Postęp tworzenia podglądów", - "Preview only": "Tylko podgląd", - "Preview size": "Rozmiar podglądu", - "Previews": "Podglądy", - "Previous Photo": "Poprzednie zdjęcie", - "Print": "Drukuj", - "Print…": "Drukuj…", - "Profile": "Profil", - "Profile Browser": "Przeglądarka profili", - "Profile Distortion": "Dystorsja profilu", - "Profile Menu": "Menu profili", - "Profile Vignetting": "Winietowanie profilu", - "Profile:": "Profil:", - "Profiles": "Profile", - "Projection": "Odwzorowanie", - "Proof copy created": "Utworzono kopię próbną", - "Properties": "Właściwości", - "Public domain": "Domena publiczna", - "Pupil Size": "Rozmiar źrenicy", - "Purple": "Purpurowy", - "Purple Amount": "Ilość purpury", - "Purple Hue": "Odcień purpury", - "Purple Hue High": "Górna granica odcienia purpury", - "Purple Hue Low": "Dolna granica odcienia purpury", - "Purple Luminance": "Luminancja purpury", - "Purple Saturation": "Nasycenie purpury", - "Put the locations you removed from Local back (no files change)": "Przywróć lokalizacje usunięte z widoku Lokalne (bez zmiany plików)", - "Quality": "Jakość", - "Query Photos": "Wyszukaj zdjęcia", - "Quick Collection": "Szybka kolekcja", - "Quick Develop": "Szybka obróbka", - "Quiet Green": "Spokojna zieleń", - "Quit": "Zakończ", - "Quit Anyway": "Zamknij mimo to", - "Quit LightCraft": "Zamknij LightCraft", - "Quit with unsaved changes?": "Zamknąć z niezapisanymi zmianami?", - "RAW decoding": "Dekodowanie RAW", - "Radial": "Promieniowy", - "Radial Gradient": "Gradient promieniowy", - "Radius": "Promień", - "Random": "Losowo", - "Range": "Zakres", - "Rate ★": "Oceń ★", - "Rate ★ and advance": "Oceń ★ i przejdź dalej", - "Rate ★★": "Oceń ★★", - "Rate ★★ and advance": "Oceń ★★ i przejdź dalej", - "Rate ★★★": "Oceń ★★★", - "Rate ★★★ and advance": "Oceń ★★★ i przejdź dalej", - "Rate ★★★★": "Oceń ★★★★", - "Rate ★★★★ and advance": "Oceń ★★★★ i przejdź dalej", - "Rate ★★★★★": "Oceń ★★★★★", - "Rate ★★★★★ and advance": "Oceń ★★★★★ i przejdź dalej", - "Rating": "Ocena", - "Rating cleared": "Usunięto ocenę", - "Ratings & flags": "Oceny i flagi", - "Raw": "RAW", - "Raw default": "Domyślne ustawienia RAW", - "Raw defaults": "Domyślne ustawienia RAW", - "Raw files": "Pliki RAW", - "Raw photos": "Zdjęcia RAW", - "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Zdjęcia RAW jako DNG z osadzonymi zmianami. Opcje rozmiaru, koloru i wyjścia nie mają zastosowania.", - "Read Metadata from File": "Wczytaj metadane z pliku", - "Read mode": "Tryb odczytu", - "Read the SAM License": "Przeczytaj licencję SAM", - "Reading": "Wczytywanie", - "Reading Lightroom catalog…": "Wczytywanie katalogu Lightroom…", - "Reading folder…": "Wczytywanie folderu…", - "Real name": "Imię i nazwisko", - "Recent": "Ostatnie", - "Recent Keywords": "Ostatnie słowa kluczowe", - "Recently Added": "Ostatnio dodane", - "Recently Deleted": "Ostatnio usunięte", - "Red": "Czerwony", - "Red Eye": "Czerwone oczy", - "Red Hue": "Odcień czerwieni", - "Red Luminance": "Luminancja czerwieni", - "Red Primary": "Czerwony podstawowy", - "Red Saturation": "Nasycenie czerwieni", - "Red channel": "Kanał czerwony", - "Red label": "Czerwona etykieta", - "Red/Cyan Fringe": "Czerwono-turkusowa obwódka", - "Redo": "Ponów", - "Reference": "Odniesienie", - "Reference View": "Widok referencyjny", - "Reference the files where they are": "Pozostaw pliki w bieżącym położeniu", - "Refine": "Dopracuj", - "Refine AI Mask Detail": "Dopracuj szczegóły maski AI", - "Refine Edges": "Dopracuj krawędzie", - "Refine Mask Edges": "Dopracuj krawędzie maski", - "Refine Saturation": "Dopracuj nasycenie", - "Refining the mask's detail…": "Dopracowywanie szczegółów maski…", - "Refresh Source": "Odśwież źródło", - "Refresh Source (/)": "Odśwież źródło (/)", - "Reject": "Odrzuć", - "Reject below focus": "Odrzuć poniżej progu ostrości", - "Rejected": "Odrzucone", - "Rejects": "Odrzucone zdjęcia", - "Reliability": "Niezawodność", - "Relink Photo": "Ponownie połącz zdjęcie", - "Reload Changed Files": "Wczytaj ponownie zmienione pliki", - "Reload from Disk": "Wczytaj ponownie z dysku", - "Remove": "Usuń", - "Remove (H)": "Usuń (H)", - "Remove CA": "Usuń aberrację chromatyczną", - "Remove Chromatic Aberration": "Usuń aberrację chromatyczną", - "Camera local tone mapping": "Lokalne mapowanie tonów aparatu", - "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Zastosuj lokalne mapowanie tonów zapisane w pliku RAW (Apple ProRAW), tak jak robi to aparat", - "Remove Disk from Library": "Usuń dysk z biblioteki", - "Remove Disk from Library…": "Usuń dysk z biblioteki…", - "Remove Face Box": "Usuń ramkę twarzy", - "Remove Folder from Library": "Usuń folder z biblioteki", - "Remove a filter above, or choose Clear all": "Usuń powyższy filtr lub wybierz Wyczyść wszystko", - "Remove from Album": "Usuń z albumu", - "Remove from Favorites": "Usuń z ulubionych", - "Remove from Library…": "Usuń z biblioteki…", - "Remove from Local": "Usuń z lokalnych", - "Remove from Photo": "Usuń ze zdjęcia", - "Remove from Selected Photos": "Usuń z zaznaczonych zdjęć", - "Remove from Stack": "Usuń ze stosu", - "Remove from Target Album": "Usuń z albumu docelowego", - "Remove from survey": "Usuń z widoku przeglądu", - "Remove location info": "Usuń informacje o lokalizacji", - "Remove shortcut": "Usuń skrót klawiszowy", - "Remove this face box (undo with Edit ▸ Undo)": "Usuń tę ramkę twarzy (cofnij przez Edycja ▸ Cofnij)", - "Remove this filter": "Usuń ten filtr", - "Remove this group": "Usuń tę grupę", - "Remove this rule": "Usuń tę regułę", - "Rename": "Zmień nazwę", - "Rename Album": "Zmień nazwę albumu", - "Rename Component": "Zmień nazwę elementu", - "Rename Folder": "Zmień nazwę folderu", - "Rename Folder…": "Zmień nazwę folderu…", - "Rename Keyword": "Zmień nazwę słowa kluczowego", - "Rename Keyword…": "Zmień nazwę słowa kluczowego…", - "Rename Mask": "Zmień nazwę maski", - "Rename Photos": "Zmień nazwy zdjęć", - "Rename Photos…": "Zmień nazwy zdjęć…", - "Rename Preset": "Zmień nazwę ustawienia wstępnego", - "Rename Preview": "Zmień nazwę podglądu", - "Rename Template Tags": "Zmień nazwy znaczników szablonu", - "Rename Version": "Zmień nazwę wersji", - "Rename…": "Zmień nazwę…", - "Rendering": "Renderowanie", - "Rendering…": "Renderowanie…", - "Reorder Masks": "Zmień kolejność masek", - "Reset": "Resetuj", - "Reset All": "Zresetuj wszystko", - "Reset All Curves": "Zresetuj wszystkie krzywe", - "Reset All Keyboard Shortcuts": "Przywróć wszystkie skróty klawiszowe", - "Reset Before to Import State": "Przywróć stan przed edycją do stanu po imporcie", - "Reset Control": "Zresetuj element sterujący", - "Reset Crop": "Zresetuj kadrowanie", - "Reset Edits": "Zresetuj edycje", - "Reset Panel Layout": "Przywróć układ paneli", - "Reset Parametric Curve": "Zresetuj krzywą parametryczną", - "Reset Section": "Zresetuj sekcję", - "Reset Tone Curve": "Zresetuj krzywą tonalną", - "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Zresetuj wszystkie krzywe: punktowe (wszystkie kanały) i parametryczną (kliknij dwukrotnie kanał, aby zresetować tylko jego krzywą)", - "Reset all edits (Cmd+Shift+R)": "Zresetuj wszystkie edycje (Cmd+Shift+R)", - "Reshuffle": "Wylosuj ponownie", - "Resize Face Box": "Zmień rozmiar ramki twarzy", - "Resize panel window": "Zmień rozmiar okna panelu", - "Resize panels": "Zmień rozmiary paneli", - "Resize to": "Zmień rozmiar do", - "Resolution (ppi)": "Rozdzielczość (ppi)", - "Restore": "Przywróć", - "Restore (no shortcut)": "Przywróć (bez skrótu)", - "Restore Library from Backup…": "Przywróć bibliotekę z kopii zapasowej…", - "Restore Version": "Przywróć wersję", - "Restore keeps their edits; Import as new deletes the old record first.": "Przywracanie zachowuje edycje; import jako nowe zdjęcie najpierw usuwa stary wpis.", - "Restore them": "Przywróć je", - "Retouch": "Retusz", - "Reverse the order": "Odwróć kolejność", - "Revert": "Cofnij zmiany", - "Review": "Przegląd", - "Review Import": "Sprawdź import", - "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem ▸ Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)", - "Right-click a preset to delete it": "Kliknij ustawienie wstępne prawym przyciskiem, aby je usunąć", - "Rights Usage Terms": "Warunki korzystania z praw", - "Rotate": "Obróć", - "Rotate Crop": "Obróć kadr", - "Rotate Crop Aspect": "Obróć proporcje kadru", - "Rotate Left": "Obróć w lewo", - "Rotate Right": "Obróć w prawo", - "Roughness": "Chropowatość", - "Roundness": "Zaokrąglenie", - "Rules": "Reguły", - "SAM 3 download stopped: it resumes where it left off next time.": "Pobieranie SAM 3 zatrzymano; zostanie wznowione przy następnej próbie.", - "Same as raw default": "Tak jak domyślne ustawienia RAW", - "Sample Mask Color": "Pobierz próbkę koloru maski", - "Sample a colour on the photo": "Wybierz próbkę koloru ze zdjęcia", - "Saturation": "Nasycenie", - "Saturation Range": "Zakres nasycenia", - "Saturday": "Sobota", - "Save": "Zapisz", - "Save As…": "Zapisz jako…", - "Save Color Label Set": "Zapisz zestaw etykiet kolorystycznych", - "Save Current Filter as Preset…": "Zapisz bieżący filtr jako ustawienie wstępne…", - "Save Current Keywords as Set…": "Zapisz bieżące słowa kluczowe jako zestaw…", - "Save Export Preset": "Zapisz ustawienie wstępne eksportu", - "Save Filter Preset": "Zapisz ustawienie wstępne filtra", - "Save Keyword Set": "Zapisz zestaw słów kluczowych", - "Save Metadata Preset": "Zapisz ustawienie wstępne metadanych", - "Save Metadata Preset…": "Zapisz ustawienie wstępne metadanych…", - "Save Metadata to File": "Zapisz metadane do pliku", - "Save Point Curve Preset": "Zapisz ustawienie wstępne krzywej punktowej", - "Save Point Curve…": "Zapisz krzywą punktową…", - "Save as Preset": "Zapisz jako ustawienie wstępne", - "Save as Set…": "Zapisz jako zestaw…", - "Save as Smart Album…": "Zapisz jako album inteligentny…", - "Save as set": "Zapisz jako zestaw", - "Save as…": "Zapisz jako…", - "Save preset": "Zapisz ustawienie wstępne", - "Saving Lightroom import index…": "Zapisywanie indeksu importu Lightroom…", - "Scale": "Skala", - "Scan failed": "Skanowanie nie powiodło się", - "Scanning": "Skanowanie", - "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Oceny pozostają przypisane do zdjęć: możesz filtrować według ostrości i najlepszych podobnych ujęć lub tworzyć albumy inteligentne.", - "Screen": "Ekran", - "Scroll menu down": "Przewiń menu w dół", - "Scroll menu up": "Przewiń menu w górę", - "Search": "Szukaj", - "Search Photos": "Szukaj zdjęć", - "Search commands or keys": "Szukaj poleceń lub klawiszy", - "Search…": "Szukaj…", - "Seasons": "Pory roku", - "Second Window": "Drugie okno", - "Select": "Zaznacz", - "Select All": "Zaznacz wszystko", - "Select Luminance Range": "Zaznacz zakres luminancji", - "Select Mask": "Wybierz maskę", - "Select Photos": "Wybierz zdjęcia", - "Select Spot": "Wybierz punkt", - "Select a photo to edit": "Wybierz zdjęcie do edycji", - "Select by": "Wybierz według", - "Select by Flag, Rating or Label": "Wybierz według flagi, oceny lub etykiety", - "Select photos, then choose View → Survey (N)": "Wybierz zdjęcia, a następnie Widok → Przegląd (N)", - "Select two photos, then choose View → Compare (Shift+C)": "Wybierz dwa zdjęcia, a następnie Widok → Porównaj (Shift+C)", - "Selected": "Zaznaczone", - "Selecting…": "Wybieranie…", - "Selenium Tone": "Tonowanie selenowe", - "Send Feedback…": "Wyślij opinię…", - "Sepia Tone": "Tonowanie sepią", - "Sequence number zero-padded to N digits (1–9)": "Numer kolejny dopełniony zerami do N cyfr (1–9)", - "Sequence number, counting from the start number": "Numer kolejny liczony od numeru początkowego", - "Services": "Usługi", - "Set": "Zestaw", - "Set Album Cover": "Ustaw okładkę albumu", - "Set Before": "Ustaw stan „Przed”", - "Set Color Label": "Ustaw etykietę kolorystyczną", - "Set Crop": "Ustaw kadr", - "Set Develop Values": "Ustaw wartości wywoływania", - "Set Flag": "Ustaw flagę", - "Set Image Zoom and Pan": "Ustaw powiększenie i przesunięcie obrazu", - "Set Keyboard Shortcut": "Ustaw skrót klawiszowy", - "Set Mask Adjustments": "Ustaw korekty maski", - "Set Point Curve": "Ustaw krzywą punktową", - "Set Profile": "Ustaw profil", - "Set Rating": "Ustaw ocenę", - "Set Top of Stack": "Przenieś na wierzch stosu", - "Set as Album Cover": "Ustaw jako okładkę albumu", - "Set as Before": "Ustaw jako stan „Przed”", - "Set as Reference Photo": "Ustaw jako zdjęcie referencyjne", - "Set as Target Album": "Ustaw jako album docelowy", - "Set as Target Album (B adds to it)": "Ustaw jako album docelowy (B dodaje do niego zdjęcia)", - "Set as Top of Stack": "Przenieś na wierzch stosu", - "Set date & time": "Ustaw datę i godzinę", - "Set name": "Ustaw nazwę", - "Set the mix from the photo's colours": "Ustal proporcje na podstawie kolorów zdjęcia", - "Settings": "Ustawienia", - "Settings file problem": "Problem z plikiem ustawień", - "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Ustawienia początkowe nowych zdjęć RAW. Zmiana nie wpływa na zdjęcia już znajdujące się w bibliotece.", - "Settings pasted": "Wklejono ustawienia", - "Settings to include": "Ustawienia do uwzględnienia", - "Settings…": "Ustawienia…", - "Shade": "Cień", - "Shadow": "Cień", - "Shadows": "Cienie", - "Shadows Hue": "Odcień cieni", - "Shadows Luminance": "Luminancja cieni", - "Shadows Saturation": "Nasycenie cieni", - "Shadows Tint": "Zabarwienie cieni", - "Shadows split": "Podział cieni", - "Sharpen": "Wyostrz", - "Sharpening": "Wyostrzanie", - "Sharpness": "Wyostrzenie", - "Shift": "Przesunięcie", - "Shift by": "Przesuń o", - "Short Edge": "Krótsza krawędź", - "Show": "Pokaż", - "Show All": "Pokaż wszystko", - "Show Clipping": "Pokaż prześwietlenia i niedoświetlenia", - "Show Deghost Overlay": "Pokaż nakładkę usuwania duchów", - "Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne", - "Show Luminance Map": "Pokaż mapę luminancji", - "Show Mask": "Pokaż maskę", - "Show Mask Overlay": "Pokaż nakładkę maski", - "Show Mask Pins": "Pokaż znaczniki masek", - "Show Original": "Pokaż oryginał", - "Show Photo Counts": "Pokaż liczbę zdjęć", - "Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym", - "Show Pins": "Pokaż znaczniki", - "Show Source": "Pokaż źródło", - "Show Thumbnails": "Pokaż miniatury", - "Show commands without a shortcut": "Pokaż polecenia bez skrótu", - "Show destination gamut warning": "Pokaż ostrzeżenie o gamucie docelowym", - "Show display gamut warning": "Pokaż ostrzeżenie o gamucie wyświetlacza", - "Show file names": "Pokaż nazwy plików", - "Show in Explorer": "Pokaż w Eksploratorze", - "Show in File Manager": "Pokaż w menedżerze plików", - "Show in Finder": "Pokaż w Finderze", - "Show on Map": "Pokaż na mapie", - "Show overlay (O)": "Pokaż nakładkę (O)", - "Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji", - "Show the Navigator while zoomed in": "Pokaż Nawigator podczas powiększenia", - "Show the template tags; click one to insert it": "Pokaż znaczniki szablonu; kliknij znacznik, aby go wstawić", - "Show/Hide Mask": "Pokaż/ukryj maskę", - "Show/hide My Photos panel": "Pokaż/ukryj panel Moje zdjęcia", - "Shutter speed": "Czas naświetlania", - "Side panels": "Panele boczne", - "Sidecar names": "Nazwy plików pomocniczych", - "Size": "Rozmiar", - "Size (% of short edge)": "Rozmiar (% krótszej krawędzi)", - "Size limit": "Limit rozmiaru", - "Skip": "Pomiń", - "Sky": "Niebo", - "Slideshow": "Pokaz slajdów", - "Slideshow paused": "Pokaz slajdów wstrzymany", - "Slideshow resumed": "Pokaz slajdów wznowiony", - "Slideshow · Space pauses · Esc ends": "Pokaz slajdów · Spacja: pauza · Esc: koniec", - "Slideshow, second window, All Metadata, System Info.": "Pokaz slajdów, drugie okno, wszystkie metadane, informacje o systemie.", - "Smart Album": "Inteligentny album", - "Smart Album Rule Fields": "Pola reguł albumu inteligentnego", - "Smart Preview Status": "Stan podglądu inteligentnego", - "Smart Previews Location": "Lokalizacja podglądów inteligentnych", - "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Albumy inteligentne z edytorem reguł: dopasuj wszystkie / dowolne / żadne, grupy zagnieżdżone, 26 pól.", - "Smart preview available": "Dostępny podgląd inteligentny", - "Smart previews": "Podglądy inteligentne", - "Smart previews folder changed": "Zmieniono folder podglądów inteligentnych", - "Smoothness": "Wygładzenie", - "Soft B&W": "Delikatna czerń i biel", - "Soft Pastel": "Delikatne pastele", - "Soft Proofing": "Próba koloru", - "Soft Skin": "Wygładzanie skóry", - "Sort": "Sortuj", - "Sort by": "Sortuj według", - "Source": "Źródło", - "Source code on GitHub": "Kod źródłowy na GitHubie", - "Spherical": "Sferyczny", - "Spiral": "Spirala", - "Split Stack": "Rozdziel stos", - "Spring Fresh": "Wiosenna świeżość", - "Square Grid": "Siatka kwadratowa", - "Square Grid (G toggles)": "Siatka kwadratowa (G przełącza)", - "Square Grid: show file names and formats": "Siatka kwadratowa: pokaż nazwy i formaty plików", - "Stack": "Stos", - "Stack photos taken within this time of each other:": "Umieszczaj na jednym stosie zdjęcia wykonane w odstępie nie większym niż:", - "Stacks": "Stosy", - "Standard": "Standardowy", - "Star rating (0–5)": "Ocena gwiazdkowa (0–5)", - "Start at": "Zacznij od", - "Start number": "Numer początkowy", - "Starting": "Uruchamianie", - "Startup": "Uruchamianie programu", - "State / Province": "Stan / województwo", - "Stop": "Zatrzymaj", - "Stop Building Previews": "Zatrzymaj tworzenie podglądów", - "Stop Using as Target Album": "Przestań używać jako albumu docelowego", - "Stopping…": "Zatrzymywanie…", - "Straighten": "Wyprostuj", - "Straighten Tool": "Narzędzie prostowania", - "Strong Contrast": "Silny kontrast", - "Style": "Styl", - "Subfolder": "Podfolder", - "Subfolder name": "Nazwa podfolderu", - "Subject": "Obiekt", - "Subtract": "Odejmij", - "Subtract from this mask": "Odejmij od tej maski", - "Suggestions": "Sugestie", - "Summer Haze": "Letnia mgiełka", - "Sunday": "Niedziela", - "Survey": "Przegląd", - "Survey (N)": "Przegląd (N)", - "Swap": "Zamień", - "Swap Aspect (X)": "Zamień proporcje (X)", - "Swap Before and After": "Zamień „Przed” i „Po”", - "Swap Before and After Settings": "Zamień ustawienia „Przed” i „Po”", - "Swap Compare Photos": "Zamień porównywane zdjęcia", - "Switch light / dark theme": "Przełącz jasny/ciemny motyw", - "Sync Settings": "Synchronizuj ustawienia", - "System": "System", - "System Info": "Informacje o systemie", - "System Info…": "Informacje o systemie…", - "System default": "Domyślne ustawienie systemowe", - "Table": "Tabela", - "Tags": "Tagi", - "Take a snapshot": "Utwórz migawkę", - "Targeted Adjustment": "Korekta punktowa", - "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdujące się tam kolory", - "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdującą się tam krzywą", - "Teal & Amber": "Turkus i bursztyn", - "Teal & Orange": "Turkus i pomarańcz", - "Temp": "Temperatura", - "Temperature": "Temperatura", - "Template": "Szablon", - "Temporary session — your library isn't open, and nothing you do here is saved.": "Sesja tymczasowa — biblioteka nie jest otwarta, a żadne zmiany nie zostaną zapisane.", - "Text": "Tekst", - "Text direction": "Kierunek tekstu", - "Texture": "Tekstura", - "That includes the photos in the folders inside it.": "Dotyczy to również zdjęć w znajdujących się wewnątrz folderach.", - "That key can't be used as a shortcut": "Tego klawisza nie można użyć jako skrótu", - "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 jest zainstalowany: maski Obiekt i Opis są gotowe.", - "The download didn't work:": "Pobieranie nie powiodło się:", - "The number at the end of the original name (IMG_0042 → 0042)": "Liczba na końcu oryginalnej nazwy (IMG_0042 → 0042)", - "The original extension is always added; {ext} only puts it inside the name as well.": "Oryginalne rozszerzenie jest zawsze dodawane; {ext} umieszcza je również wewnątrz nazwy.", - "The original files, unchanged, each with an XMP sidecar holding its edits.": "Oryginalne, niezmienione pliki, każdy z plikiem pomocniczym XMP zawierającym zapisane edycje.", - "The photo has these settings": "Zdjęcie ma następujące ustawienia", - "Theme": "Motyw", - "They can be restored from Recently Deleted until it is emptied.": "Można je przywrócić z Ostatnio usuniętych, dopóki ten folder nie zostanie opróżniony.", - "They move to Recently Deleted and can be restored; no file on disk is touched.": "Trafią do Ostatnio usuniętych i będzie można je przywrócić; żadne pliki na dysku nie zostaną zmienione.", - "Thirds": "Trójpodział", - "This Computer": "Ten komputer", - "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Ten folder jest niedostępny (odłączony dysk?). Podglądy inteligentne nie będą tworzone ani używane, dopóki folder nie wróci lub nie wybierzesz innego.", - "This photo can't be opened": "Nie można otworzyć tego zdjęcia", - "Threshold": "Próg", - "Thumbnail cache": "Pamięć podręczna miniatur", - "Thursday": "Czwartek", - "Time zone": "Strefa czasowa", - "Tint": "Zabarwienie", - "Title": "Tytuł", - "Title metadata": "Metadane tytułu", - "Toggle Keyword from Set": "Przełącz słowo kluczowe z zestawu", - "Toggle Mask": "Przełącz maskę", - "Toggle Section": "Przełącz sekcję", - "Toggle Zoom": "Przełącz powiększenie", - "Tone Curve": "Krzywa tonalna", - "Tools": "Narzędzia", - "Top Level": "Poziom główny", - "Transfer": "Przenieś", - "Treatment": "Sposób obróbki", - "Triangle": "Trójkąt", - "Try Again": "Spróbuj ponownie", - "Try Saving Again": "Spróbuj ponownie zapisać", - "Tuesday": "Wtorek", - "Tungsten": "Światło żarowe", - "Turn Off": "Wyłącz", - "Turn on Include subfolders, or pick another folder under Local": "Włącz „Uwzględnij podfoldery” lub wybierz inny folder w obszarze Lokalne", - "Type": "Typ", - "Uncheck All": "Odznacz wszystko", - "Uncheck the files of this type": "Odznacz pliki tego typu", - "Undo": "Cofnij", - "Unedited": "Bez edycji", - "Unedited raws": "Nieedytowane pliki RAW", - "Unflag": "Usuń flagę", - "Unflagged": "Bez flagi", - "Ungroup Stack": "Rozgrupuj stos", - "Unknown Date": "Nieznana data", - "Unknown tags stay as typed — check the preview for a {typo}.": "Nieznane znaczniki pozostają bez zmian — sprawdź podgląd pod kątem {typo}.", - "Unrated": "Bez oceny", - "Unreadable": "Nieczytelny", - "Update": "Aktualizuj", - "Update Mask Shape": "Zaktualizuj kształt maski", - "Update Preset with Current Settings": "Zaktualizuj ustawienie wstępne bieżącymi ustawieniami", - "Update Rules from Current Filter": "Zaktualizuj reguły według bieżącego filtra", - "Update Version": "Zaktualizuj wersję", - "Update Version with Current Settings": "Zaktualizuj wersję bieżącymi ustawieniami", - "Update with Current Settings": "Zaktualizuj bieżącymi ustawieniami", - "Updated the edits saved in the external editor": "Zaktualizowano edycje zapisane w edytorze zewnętrznym", - "Upright": "Korekcja perspektywy", - "Urban": "Miejski", - "Use Keyword Set": "Użyj zestawu słów kluczowych", - "Use Library Folder": "Użyj folderu biblioteki", - "Use camera-specific defaults": "Użyj domyślnych ustawień aparatu", - "Use the GPU for rendering": "Użyj procesora graficznego do renderowania", - "User Presets": "Ustawienia wstępne użytkownika", - "Username": "Nazwa użytkownika", - "Value": "Wartość", - "Variance": "Wariancja", - "Version": "Wersja", - "Version name": "Nazwa wersji", - "Versions": "Wersje", - "Versions (Shift+V)": "Wersje (Shift+V)", - "Vertical": "Pionowo", - "Vertical text": "Tekst pionowy", - "Vibrance": "Dynamika", - "Videos": "Wideo", - "View": "Widok", - "Viewing and sharing": "Wyświetlanie i udostępnianie", - "Vignette": "Winieta", - "Vignetting": "Winietowanie", - "Vintage": "Styl retro", - "Virtual Copy": "Kopia wirtualna", - "Visible": "Widoczne", - "Visualize Spots": "Wizualizuj plamki", - "Visualize Spots (A)": "Wizualizuj plamki (A)", - "Visualize range": "Pokaż zakres", - "Vivid": "Żywe kolory", - "Vivid Pop": "Wyraziste kolory", - "Warm Film": "Ciepły film", - "Warm Glow": "Ciepła poświata", - "Warm Print": "Ciepły wydruk", - "Warm Table": "Ciepła paleta", - "Watched folder": "Folder obserwowany", - "Watermark": "Znak wodny", - "Wednesday": "Środa", - "What's New": "Co nowego", - "When rated or hovered": "Po ocenieniu lub wskazaniu kursorem", - "White Balance": "Balans bieli", - "White Balance Selector": "Próbnik balansu bieli", - "White Balance Selector (W)": "Próbnik balansu bieli (W)", - "Whites": "Biele", - "Width": "Szerokość", - "Width (% of photo)": "Szerokość (% zdjęcia)", - "Width (px)": "Szerokość (px)", - "Width × Height": "Szerokość × wysokość", - "Will be replaced": "Zostanie zastąpiony", - "Will be restored": "Zostanie przywrócony", - "Window": "Okno", - "Winter Blue": "Zimowy błękit", - "XMP Sidecar Preferences": "Preferencje plików pomocniczych XMP", - "XMP sidecars": "Pliki pomocnicze XMP", - "Year": "Rok", - "Yellow": "Żółty", - "Yellow Hue": "Odcień żółci", - "Yellow Luminance": "Luminancja żółci", - "Yellow Saturation": "Nasycenie żółci", - "Yellow label": "Żółta etykieta", - "Yes": "Tak", - "You can close this: the download continues, and resumes if interrupted.": "Możesz zamknąć to okno: pobieranie będzie kontynuowane i wznowi się po przerwaniu.", - "Your Name": "Twoje imię i nazwisko", - "Your library couldn't be opened": "Nie można otworzyć biblioteki", - "Zoom": "Powiększenie", - "Zoom 100%": "Powiększenie 100%", - "Zoom In": "Powiększ", - "Zoom Out": "Pomniejsz", - "Zoom to Fit": "Dopasuj powiększenie", - "Zoom to page level": "Dopasuj powiększenie do strony", - "a literal %": "Dosłowny znak %", - "a removed photo": "Usunięte zdjęcie", - "all": "wszystkie", - "and": "i", - "any": "dowolne", - "are empty": "Są puste", - "aren't empty": "Nie są puste", - "blue": "Niebieski", - "comma, separated": "Rozdzielone przecinkami", - "contains": "Zawiera", - "copyrighted": "Chronione prawem autorskim", - "day 01–31": "Dzień 01–31", - "days": "dni", - "discarding smart previews": "Odrzucanie podglądów inteligentnych", - "doesn't contain": "Nie zawiera", - "e.g. sky · the red car · car, road": "np. niebo · czerwony samochód · samochód, droga", - "ends with": "Kończy się na", - "false": "fałsz", - "green": "zielony", - "hour 00–23": "Godzina 00–23", - "hours": "godziny", - "image": "Obraz", - "includes the folders inside it": "Uwzględnia foldery wewnątrz", - "is": "jest", - "is <": "Jest <", - "is >": "Jest >", - "is after": "Jest po", - "is before": "Jest przed", - "is between": "Jest pomiędzy", - "is empty": "jest puste", - "is in the last": "Jest w ciągu ostatnich", - "is unknown": "Jest nieznany", - "is ≤": "Jest ≤", - "is ≥": "Jest ≥", - "isn't": "Nie jest", - "isn't empty": "nie jest puste", - "isn't in the last": "Nie występuje w ciągu ostatnich", - "keep names — or e.g. {date}_{seq:3}": "Zachowaj nazwy — lub np. {date}_{seq:3}", - "latitude, longitude": "Szerokość i długość geograficzna", - "library locked: the lock is the operating system's and goes away with the program.": "Biblioteka zablokowana: blokadę nakłada system operacyjny i znika ona po zamknięciu programu.", - "memory only": "Tylko w pamięci", - "minute": "minuta", - "month 01–12": "Miesiąc 01–12", - "months": "miesiące", - "none": "brak", - "none (CPU rendering)": "Brak (renderowanie przez CPU)", - "none of": "Żaden z", - "not available here": "Niedostępne tutaj", - "of the following rules:": "Następujących reguł:", - "or": "lub", - "pick": "Wybierz", - "previews": "Podglądy", - "publicDomain": "Domena publiczna", - "purple": "Fioletowy", - "raw": "RAW", - "red": "czerwony", - "saved in": "Zapisano w", - "second": "Sekunda", - "smart previews": "Podglądy inteligentne", - "the photos in view": "Zdjęcia w widoku", - "the selected photos": "Zaznaczone zdjęcia", - "this photo": "To zdjęcie", - "true": "prawda", - "unknown": "nieznany", - "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "Zdjęcie", - "weeks": "tygodnie", - "year, 2 digits": "Rok, 2 cyfry", - "year, 4 digits": "Rok, 4 cyfry", - "years": "lata", - "yellow": "żółty", - "{date} is the capture time; a photo without one uses the time it was imported.": "{date} oznacza datę wykonania; gdy jej brak, używana jest data importu.", - "© 2026 Your Name": "© 2026 Twoje imię i nazwisko", - "© Your Name": "© Twoje imię i nazwisko", - "− harder border · + softer border": "− ostrzejsza krawędź · + bardziej miękka krawędź", - "≤ at most": "≤ co najwyżej", - "≥ at least": "≥ co najmniej", - "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘ i przeciągnij, aby wyprostować, ⇧G: korekcja perspektywy z liniami pomocniczymi; podczas przekształcania wyświetlana jest siatka.", - "Any camera": "Dowolny aparat", - "Any lens": "Dowolny obiektyw", - "Any keyword": "Dowolne słowo kluczowe", - "Label Set": "Zestaw etykiet", - ", picked": ", wybrano", - ", rejected": ", odrzucono", - "Preview only: editing the camera's embedded JPEG": "Tylko podgląd: edycja JPEG-a osadzonego przez aparat", - "Captured from": "Wykonane od", - "Captured until": "Wykonane do", - "Only": "Tylko", - "How to install the model": "Jak zainstalować model", - "Install the SAM 3 Model": "Zainstaluj model SAM 3", - "Open Log Folder": "Otwórz folder dzienników", - "Back to this person": "Wróć do tej osoby", - "AI Denoise": "Odszumianie AI", - "AI Denoise Model": "Model odszumiania AI", - "AI Denoise is off: press Use on a model.": "Odszumianie AI jest wyłączone: kliknij „Użyj” przy modelu.", - "AI Denoise needs a model. Install one in Settings.": "Odszumianie AI wymaga modelu. Zainstaluj go w Ustawieniach.", - "Accept & Download": "Akceptuj i pobierz", - "Already installed. Remove it before installing a replacement.": "Już zainstalowano. Usuń obecny model przed zainstalowaniem nowego.", - "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Tryb automatyczny używa karty graficznej, jeśli jest szybsza od procesora. Przy pierwszym przetwarzaniu zdjęcia LightCraft mierzy wydajność obu urządzeń.", - "Cache": "Pamięć podręczna", - "Checking and installing the model…": "Sprawdzanie i instalowanie modelu…", - "Checking…": "Sprawdzanie…", - "Clear cache": "Wyczyść pamięć podręczną", - "Denoise Models": "Modele odszumiania", - "Denoise Settings": "Ustawienia odszumiania", - "Denoise all photos with an Amount": "Odszum wszystkie zdjęcia z ustawioną intensywnością", - "Downloaded and checked. Installing…": "Pobrano i sprawdzono. Instalowanie…", - "Failed its last test": "Nie przeszedł ostatniego testu", - "Graphics card": "Karta graficzna", - "I have read these terms and accept them for my own use": "Przeczytałem warunki i akceptuję je na własny użytek", - "Inspect Denoise Model": "Sprawdź model odszumiania", - "Install Denoise Model": "Zainstaluj model odszumiania", - "Install from file…": "Zainstaluj z pliku…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Zainstaluj własny model ONNX z plikiem denoise-model.json w tym samym katalogu. W tej kompilacji nie można pobierać modeli.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Nie utworzono jeszcze odszumionego obrazu, a obrazy nie są przetwarzane automatycznie (Ustawienia, Odszumianie AI).", - "LightCraft does not know this model, so it assumed:": "LightCraft nie rozpoznaje tego modelu, dlatego przyjęto następujące założenia:", - "Limit": "Limit", - "Make it now": "Utwórz teraz", - "Make the denoised picture of the photos I am looking at": "Utwórz odszumione wersje oglądanych zdjęć", - "Next up for AI Denoise…": "Następne w kolejce do odszumiania AI…", - "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nic nie zostanie pobrane bez Twojej zgody. Model jest przechowywany tylko na tym komputerze; LightCraft go nie przesyła ani nie udostępnia.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Przetwarzane są tylko zdjęcia z intensywnością odszumiania AI większą od zera. Podczas eksportu brakujące wersje są zawsze tworzone.", - "Open page": "Otwórz stronę", - "Open the model's page": "Otwórz stronę modelu", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Otwiera stronę modelu w przeglądarce, gdzie można przeczytać o nim lub samodzielnie pobrać plik.", - "Processor": "Procesor", - "Set up AI Denoise…": "Skonfiguruj odszumianie AI…", - "Speed": "Szybkość", - "Starting…": "Uruchamianie…", - "Tested and works on this computer": "Przetestowano — działa na tym komputerze", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model odszumiania jest zainstalowany i używany: suwak intensywności odszumiania AI w panelu Szczegóły jest teraz aktywny", - "The graphics card is set up when the next photo is made.": "Karta graficzna zostanie skonfigurowana podczas przetwarzania następnego zdjęcia.", - "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model jest przechowywany wyłącznie na tym komputerze. LightCraft nigdy go nie przesyła ani nie udostępnia.", - "The processor does the work.": "Przetwarzanie wykonuje procesor.", - "There is no model file to install": "Brak pliku modelu do zainstalowania", - "This build cannot run denoise models.": "Ta kompilacja nie obsługuje modeli odszumiania.", - "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Ta kompilacja nie ma miejsca na przechowywanie modeli odszumiania, więc odszumianie AI jest niedostępne (dostępne w aplikacji komputerowej).", - "This file cannot be used yet": "Nie można jeszcze użyć tego pliku", - "Tick the box to accept the model's terms first": "Najpierw zaznacz pole akceptacji warunków modelu", - "Try again": "Spróbuj ponownie", - "Try the graphics card again": "Spróbuj ponownie użyć karty graficznej", - "Turn off": "Wyłącz", - "Unknown licence": "Nieznana licencja", - "Use": "Użyj", - "Waiting to start…": "Oczekiwanie na uruchomienie…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.", - "cached picture": "obraz w pamięci podręcznej", - "cached pictures": "obrazy w pamięci podręcznej", - "commercial use allowed": "dozwolony użytek komercyjny", - "in use": "w użyciu", - "installed": "zainstalowano", - "makes AI Denoise available": "włącza odszumianie AI", - "non-commercial use only": "wyłącznie do użytku niekomercyjnego", - "photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.", - "photo waiting": "zdjęcie oczekuje", - "photos have their denoised picture.": "zdjęcia mają już odszumione wersje.", - "photos waiting": "zdjęć oczekuje", - "starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI", - "terms unclear": "niejasne warunki", - "the graphics card": "karta graficzna", - "the graphics card is not used": "karta graficzna nie jest używana", - "Cancel Denoise": "Anuluj odszumianie", - "Cancel Denoise Model Download": "Anuluj pobieranie modelu odszumiania", - "Choose Denoise Model": "Wybierz model odszumiania", - "Clear Denoise Cache": "Wyczyść pamięć podręczną odszumiania", - "Denoise Model Downloads": "Pobieranie modeli odszumiania", - "Denoise Photos": "Odszum zdjęcia", - "Denoise Pump": "Kolejka odszumiania", - "Denoise Status": "Stan odszumiania", - "Download Denoise Model": "Pobierz model odszumiania", - "Remove Denoise Model": "Usuń model odszumiania", - "Test Denoise Model": "Przetestuj model odszumiania", - "AI Denoise is enabled on the requesting photo": "Odszumianie AI włączono dla wybranego zdjęcia", - "After installation, AI Denoise will turn on for the requesting photo.": "Po instalacji odszumianie AI zostanie włączone dla wybranego zdjęcia.", - "Cancel pending action": "Anuluj oczekującą operację", - "Install a denoise model to enable AI Denoise on this photo": "Zainstaluj model odszumiania, aby włączyć odszumianie AI dla tego zdjęcia", - "Pending photo action cancelled": "Anulowano oczekującą operację", - "Pending photo action cancelled because its library or photo changed": "Anulowano oczekującą operację, ponieważ zmieniła się biblioteka lub zdjęcie", - "ONNX model": "Model ONNX", - "commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL", - "Decrease Rating": "Zmniejsz ocenę", - "Increase Rating": "Zwiększ ocenę", - "Click to type a value": "Kliknij, aby wpisać wartość", - "Angle": "Kąt", - "Video": "Wideo", - "Copyrighted": "Chronione prawem autorskim", - "Public Domain": "Domena publiczna", - "Unknown": "Nieznany", - "Landscape (wide)": "Poziomo (szeroko)", - "Portrait (tall)": "Pionowo (wysoko)", - "Square": "Kwadrat", - "In Color": "Kolorowe", - "No Label": "Bez etykiety", - "Choose an album…": "Wybierz album…", - "No albums yet": "Brak albumów", - "Fix the marked rules to save this album.": "Popraw oznaczone reguły, aby zapisać album.", - "Some rules need fixing (Edit Smart Album…):": "Niektóre reguły wymagają poprawienia (Edytuj album inteligentny…):", - "isn't a field rules know": "nie jest obsługiwanym polem reguły", - "can't be tested that way": "nie można sprawdzić w ten sposób", - "is yes or no": "wymaga odpowiedzi tak lub nie", - "needs a number": "wymaga liczby", - "needs a time like 1/250 or 2": "wymaga czasu, np. 1/250 lub 2", - "needs a date like 2026, 2026-08 or 2026-08-14": "wymaga daty, np. 2026, 2026-08 lub 2026-08-14", - "has the first date after the second": "pierwsza data jest późniejsza od drugiej", - "needs two values": "wymaga dwóch wartości", - "needs a number more than 0": "wymaga liczby większej od zera", - "needs hours, days, weeks, months or years": "wymaga godzin, dni, tygodni, miesięcy lub lat", - "isn't one of the choices": "nie jest jedną z dostępnych opcji", - "needs something to look for (or use “is empty”)": "wymaga tekstu do wyszukania (lub opcji „jest puste”)", - "needs something to look for (or use “isn't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie jest puste”)", - "needs text": "wymaga tekstu", - "can't match any rating from 0 to 5": "nie może odpowiadać żadnej ocenie od 0 do 5", - "needs an album": "wymaga albumu", - "names an album that no longer exists": "wskazuje album, który już nie istnieje", - "can't test a folder; choose an album in it": "nie można sprawdzić folderu; wybierz album, który się w nim znajduje", - "This group is empty: add a rule or remove it.": "Ta grupa jest pusta: dodaj regułę albo usuń grupę.", - "needs something to look for (or use “are empty”)": "wymaga tekstu do wyszukania (lub opcji „są puste”)", - "needs something to look for (or use “aren't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie są puste”)", - "Mo": "Pn", - "Tu": "Wt", - "We": "Śr", - "Th": "Cz", - "Fr": "Pt", - "Sa": "So", - "Su": "Nd", - "Pick a date": "Wybierz datę", - "would make this album include itself through other smart albums": "spowodowałoby włączenie tego albumu do niego samego przez inne albumy inteligentne", - "Search albums": "Wyszukaj albumy", - "No albums match": "Brak pasujących albumów", - "This is the album you're editing": "To album, który edytujesz", - "It tests this album, so testing it back would loop": "Sprawdza ten album, więc odwrotne sprawdzenie utworzyłoby pętlę", - "Copy Name": "Kopiuj nazwę", - "In a Stack": "W stosie", - "File Extension": "Rozszerzenie pliku", - "Video Duration": "Czas trwania filmu", - "Keyword Count": "Liczba słów kluczowych", - "People Count": "Liczba osób", - "Shutter Speed": "Czas migawki", - "Cropped": "Przycięte", - "Keywords & People": "Słowa kluczowe i osoby", - "Description": "Opis", - "Camera Info": "Informacje o aparacie", - "Develop": "Wywoływanie", - "Keyword List": "Lista słów kluczowych", - "Filter Keywords": "Filtruj słowa kluczowe", - "No keywords yet": "Brak słów kluczowych", - "No keywords match": "Brak pasujących słów kluczowych", - "Keyword Name": "Nazwa słowa kluczowego", - "Synonyms": "Synonimy", - "Separated by commas": "Oddzielone przecinkami", - "Keyword Tag Options": "Opcje etykiety słowa kluczowego", - "Include on Export": "Uwzględnij przy eksporcie", - "Export Containing Keywords": "Eksportuj nadrzędne słowa kluczowe", - "Export Synonyms": "Eksportuj synonimy", - "Create Keyword Tag": "Utwórz słowo kluczowe", - "Edit Keyword Tag": "Edytuj słowo kluczowe", - "Create": "Utwórz", - "No photo has it; the keywords below it go too.": "Żadne zdjęcie go nie używa; podrzędne słowa kluczowe również zostaną usunięte.", - "Edit Keyword Tag…": "Edytuj słowo kluczowe…", - "Put New Keywords Inside This Keyword": "Umieszczaj nowe słowa kluczowe wewnątrz tego", - "Purge Unused Keywords": "Usuń nieużywane słowa kluczowe", - "Delete Keyword…": "Usuń słowo kluczowe…", - "Drop here for the top level": "Upuść tutaj, aby umieścić na najwyższym poziomie", - "Their photos and the keywords below them come together under one keyword.": "Zdjęcia i podrzędne słowa kluczowe zostaną zebrane pod jednym słowem kluczowym.", - "Select photos to give them keywords.": "Wybierz zdjęcia, którym chcesz przypisać słowa kluczowe.", - "Add to All Selected Photos": "Dodaj do wszystkich wybranych zdjęć", - "Will Export": "Zostanie wyeksportowane", - "No keywords are exported": "Nie zostaną wyeksportowane żadne słowa kluczowe", - "& Containing": "i nadrzędne", - "Edit Set…": "Edytuj zestaw…", - "Edit Keyword Set": "Edytuj zestaw słów kluczowych", - "Keywords (⌥1–⌥9)": "Słowa kluczowe (⌥1–⌥9)", - "A keyword set needs a name": "Zestaw słów kluczowych musi mieć nazwę", - "Save as a new set": "Zapisz jako nowy zestaw", - "Recent Keywords is built in: choose another name": "Ostatnie słowa kluczowe to zestaw wbudowany: wybierz inną nazwę", - "Import Keywords…": "Importuj słowa kluczowe…", - "Export Keywords…": "Eksportuj słowa kluczowe…", - "Import Keywords": "Importuj słowa kluczowe", - "Export Keywords": "Eksportuj słowa kluczowe", - "Keyword Lists": "Listy słów kluczowych", - "Synchronize Folder…": "Synchronizuj folder…", - "Synchronize Folder": "Synchronizuj folder", - "Synchronize": "Synchronizuj", - "Scanning…": "Skanowanie…", - "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Znajdź zdjęcia dodane do folderu lub brakujące na dysku oraz pliki XMP zmienione przez inne aplikacje", - "Bring the library up to date with this folder and the folders inside it:": "Zaktualizuj bibliotekę na podstawie tego folderu i wszystkich jego podfolderów:", - "Import new photos": "Importuj nowe zdjęcia", - "Remove missing photos from the library": "Usuń brakujące zdjęcia z biblioteki", - "Read metadata updates from XMP sidecars": "Odczytaj aktualizacje metadanych z plików XMP", - "The library is up to date with this folder.": "Biblioteka jest aktualna względem tego folderu.", - "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Brakujące zdjęcia trafiają do Ostatnio usuniętych i można je przywrócić; pliki na dysku pozostają bez zmian.", - "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Odczyt pliku XMP zastępuje ocenę, etykiety, metadane i edycje zdjęcia jego zawartością.", - "Skipped: files already in the library elsewhere": "Pominięto: pliki znajdujące się już w bibliotece w innym miejscu", - "Skipped: files that can't be read": "Pominięto: pliki, których nie można odczytać", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Ten folder nie istnieje: przeniesiono go, zmieniono nazwę albo odłączono dysk.", - "Relink photos whose file was renamed or moved": "Połącz ponownie zdjęcia, których pliki przeniesiono lub przemianowano", - "A folder is already being synchronized": "Trwa już synchronizacja folderu", - "Synchronizing…": "Synchronizowanie…", - "Relinked": "Połączono ponownie", - "Removed": "Usunięto", - "Metadata read": "Odczytano metadane", - "Failed": "Niepowodzenie", - "Stopped": "Zatrzymano", - "Stopped by an error (see the log)": "Zatrzymano z powodu błędu (zobacz dziennik)", - "Not saved": "Nie zapisano", - "A folder is being synchronized": "Trwa synchronizacja folderu", - "Wait for the import to finish": "Poczekaj na zakończenie importu", - "Show less": "Pokaż mniej", - "Quit while tasks are running?": "Zakończyć, gdy trwają zadania?", - "Exporting contact sheet": "Eksportowanie arkusza stykowego", - "Reading folder": "Odczytywanie folderu", - "Scanning folder": "Skanowanie folderu", - "Building previews": "Tworzenie podglądów", - "Building smart previews": "Tworzenie podglądów inteligentnych", - "Discarding smart previews": "Odrzucanie podglądów inteligentnych", - "Importing Lightroom catalog": "Importowanie katalogu Lightroom", - "Reading Lightroom catalog": "Odczytywanie katalogu Lightroom", - "Merging": "Scalanie", - "Downloading model": "Pobieranie modelu", - "Finding faces": "Wyszukiwanie twarzy", - "Synchronizing folder": "Synchronizowanie folderu", - "Looking for changes": "Wyszukiwanie zmian", - "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„zapisano w pamięci, ale nie na dysku”, na górnym pasku pojawia się ostrzeżenie, a LightCraft ponawia próby, dopóki", - "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): plik DNG jest porównywany z danymi RAW, zapisywany do pliku tymczasowego, synchronizowany i odczytywany ponownie, zanim", - "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Pomoc ▸ Informacje o systemie i Ustawienia ▸ Wydajność wyjaśniają, dlaczego procesor graficzny nie jest", - "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(np. D750, D780, D850, D7500, Z 50) nie wyświetlają się już niemal czarne ani z utraconymi szczegółami w cieniach: ich poziom czerni był", - "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(zgłoszenie #94): żądanie HTTP ze strony internetowej nie może już przekazać polecenia w treści. Długość wierszy ograniczono do", - "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 na studio-mac”) zamiast pozwalać obu procesom zapisywać i po cichu gubić wzajemne zmiany. Awaria nigdy nie pozostawia", - "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "podgląd 1620 × 1080 i są importowane wraz z metadanymi: datą wykonania, aparatem, obiektywem, ekspozycją, GPS i XMP. Ich dane RAW", - "2026-04 or 2026-04-12": "2026-04 lub 2026-04-12", - "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nowe wbudowane ustawienia predefiniowane: Portret, Krajobraz, Miasto, Jedzenie, Pory roku, Vintage oraz tonowanie czarno-białe.", - "4 MiB and connections at 16.": "4 MiB, a liczbę połączeń do 16.", - "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630): w systemie Windows okno i renderowanie GPU korzystają wyłącznie z DirectX 12 i nigdy nie ładują sterownika Vulkan, chyba że", - "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Zmiana, której nie można zapisać na dysku (pełny lub odłączony nośnik), nie pozostaje już bez sygnału: polecenie zgłasza", - "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Jedna biblioteka może być otwarta tylko w jednym programie naraz (zgłoszenie #99): próba otwarcia biblioteki używanej przez LightCraft lub `lightcraft-cli`", - "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Zapis przerwany w połowie (pełny dysk, zerwane połączenie z udziałem sieciowym) nie sprawia już wrażenia uszkodzonego katalogu", - "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Uśpiony NAS, niedostępny udział sieciowy albo uruchamiający się dysk USB nie blokują już okna (zgłoszenie #104):", - "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Maski AI wykorzystujące SAM 3 (Obiekt i Opis w panelu Maskowanie): kliknij obiekt, aby go zaznaczyć (kliknięcie z ⌥ pozostawia", - "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Wersja przeglądarkowa (eksperymentalna), ochrona biblioteki (zgłoszenie #107): Plik ▸ Utwórz kopię zapasową biblioteki… pobiera katalog", - "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Zdjęcia Canon CR2 z aparatów EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II i 1D Mark IV (oraz innych", - "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Pliki Canon CR3 pokazują pełnowymiarowy podgląd JPEG osadzony w pliku (np. 6960 × 4640 w EOS R6 Mark III), zamiast", - "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R zachowuje zdjęcie, ale usuwa jego edycje).", - "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Skompresowane pliki Nikon NEF (kompresja bezstratna i stratna, 12 i 14 bitów — np. D3200, D5100, D7000, D750, D850, Z 50)", - "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Konwersja do DNG, Kopiuj jako DNG, Łączenie zdjęć i podglądy inteligentne nie zapisują już bezpośrednio do pliku docelowego (zgłoszenie", - "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ustawienie krawędzi i uzyskaj ostrzejszy podgląd po powiększeniu, obliczany w tle. Model działa wewnątrz LightCraft w czystym Ruście", - "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Wyeksportowane pliki zapisuje się do pliku tymczasowego, a następnie zmienia ich nazwę na docelową, dzięki czemu pełny lub odłączony dysk", - "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Eksport i renderowanie nigdy nie nadpisują oryginału zdjęcia (zgłoszenie #93): eksport do folderu ze zdjęciem przy", - "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Eksporty nie są już czarne z powodu GPU (zgłoszenie #78): renderowanie GPU, któremu zabraknie pamięci urządzenia", - "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Szybszy eksport i import z kart, przy zachowaniu ochrony danych nieodtwarzalnych (zgłoszenie #134): eksporty,", - "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importuj ustawienia predefiniowane z innych edytorów: ustawienia XMP, klasyczne pliki `.lrtemplate`, „ustawienia DNG” z aplikacji mobilnych i archiwa `.zip`", - "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import ▸ Kopiuj weryfikuje każdą kopię, podobnie jak Przenieś (zgłoszenie #96): każdy plik jest zapisywany jako nowy, synchronizowany z dyskiem i", - "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import ▸ Przenieś: zdjęcia trafiają do folderu docelowego (z takim samym układem folderów i zmianą nazw jak przy kopiowaniu, np.", - "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: kopiowanie do dowolnego folderu, podział według dni lub miesięcy albo do jednego folderu, zmiana nazw przy imporcie, zestaw metadanych, kopiowanie jako DNG.", - "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "Plik JPEG o tej samej nazwie (`IMG_0001.CR3` i `IMG_0001.JPG`) nie współdzieli już jednego pliku XMP: RAW zachowuje `IMG_0001.xmp`,", - "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft nie zawiesza się już podczas uruchamiania na komputerach Windows z uszkodzonym sterownikiem Vulkan (zgłoszenie #136, np. niektóre Intel UHD", - "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Style Luminar: pliki `.lmp` i kolekcje `.mplumpack` są importowane jako ustawienia predefiniowane (grupowane według kolekcji); suwaki", - "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Warunki licencji firmy Meta, a nie LightCraft. Pobranie oznacza ich akceptację.", - "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Przenieś: pliki są przenoszone do folderu poniżej; oryginał (wraz z plikiem XMP) jest usuwany ze źródła dopiero po weryfikacji kopii. Duplikaty i pliki z błędami pozostają na miejscu.", - "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Pliki Nikon NEF otrzymują wstępne kolory i tonalność dopasowane do JPEG z aparatu, podobnie jak Sony ARW, zamiast wyblakłego,", - "Nikon splits into two differently compressed halves still use the preview for now.)": "Pliki Nikon podzielone na dwie części o różnych rodzajach kompresji nadal korzystają na razie z podglądu).", - "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Maski Obiekt i Opis korzystają z modelu segmentacji SAM 3 firmy Meta. Nie jest on częścią LightCraft; wszystkie pozostałe funkcje działają bez niego.", - "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Pliki RAW Panasonic i Leica (RW2, RWL i starsze RAW, od DMC-LX1 do DC-S1R II) są wywoływane z danych RAW w", - "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Zdjęcie. Pasek zdjęć ma także menu kontekstowe. Ponowne dodanie pliku znajdującego się w Ostatnio usuniętych nie", - "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Zdjęcie ▸ Edytuj w edytorze zewnętrznym (⇧⌘E) tworzy 16-bitową kopię TIFF, grupuje ją z oryginałem i otwiera w wybranym programie (nazwa aplikacji w macOS, ścieżka programu w pozostałych systemach).", - "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Zdjęcia w Ostatnio usuniętych można przywrócić w aplikacji: kliknij prawym przyciskiem ▸ Przywróć (lub Usuń trwale), również w", - "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Zdjęcia widoczne w widoku Lokalnie, lecz nigdy niedodane ani niezmienione, są usuwane z katalogu, jeśli ich folder nie był przeglądany przez określony czas (sprawdzane przy otwieraniu biblioteki). Pliki pozostają na dysku; ponowne przejrzenie folderu znów je pokazuje.", - "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Zdjęcia pojawiające się w tym folderze (z aparatu podłączonego do komputera, skanera lub aplikacji synchronizującej) są dodawane od razu po ukończeniu zapisu.", - "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Zdjęcia); importowanie (także przeciąganiem), Znajdź brakujące zdjęcia, Zbuduj / Odrzuć podglądy inteligentne, podgląd zmiany nazw,", - "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Kolekcja podręczna i album docelowy (B w siatce), zestawy słów kluczowych (⌥1–⌥9), zestawy etykiet kolorów.", - "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Zmień nazwę folderu… i Przenieś folder do… (Lokalnie) można cofnąć i ponowić (zgłoszenie #97): folder wraca na dysku wraz ze", - "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Zmień nazwy zdjęć nigdy nie nadpisuje innego zdjęcia przy zmianie wyłącznie wielkości liter (zgłoszenie #95): w systemach z rozróżnianiem", - "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Zmień nazwy zdjęć zgłasza pliki, których nie udało się przywrócić po błędzie (zgłoszenie #105), np. po zerwaniu połączenia z udziałem sieciowym", - "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Zapis metadanych do pliku XMP utworzonego przez inną aplikację nie zastępuje już jego zawartości (zgłoszenie #92): LightCraft scala swoje", - "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Pliki ustawień nie są już po cichu resetowane (zgłoszenie #103): uszkodzone `prefs.json`, `presets.json` lub `view.json` zachowuje się", - "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Mniejsze i szybsze katalogi: zdjęcia jedynie przeglądane w widoku Lokalnie (niedodane, nieocenione i nieedytowane) są zapominane po", - "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Pliki Sony ARW sprzed około 2017 r. (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II i pokrewne", - "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni RGB,", - "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do", - "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Znaczniki: zobacz Znaczniki obok szablonu. Nazwy plików na dysku (wraz z plikami XMP) są zmieniane; kolizje otrzymują przyrostek -1, -2…", - "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka", - "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON", - "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który", - "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatyczny import z obserwowanego folderu; Konwertuj do DNG; Duplikuj; Zbuduj podglądy standardowe / 1:1 / inteligentne.", - "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Bez zapisywania i Zakończ (zgłoszenie #100). Program nie uruchamia już po cichu sesji demonstracyjnej wyglądającej jak zresetowana biblioteka, przez co", - "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) wraz z ich plikami XMP; każdy oryginał znika z karty dopiero po", - "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; na woluminach nierozróżniających wielkości liter zmiana wielkości liter również przechodzi poprawnie.", - "a full drive) no longer counts as built and Build Smart Previews replaces it.": "pełny dysk) nie jest już uznawany za gotowy, a funkcja Zbuduj podglądy inteligentne zastępuje go.", - "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "potem (zgłoszenie #101): niepełny zapis jest obcinany przed ponowieniem przez LightCraft, więc przy następnym uruchomieniu odtwarzane są wszystkie", - "again. Change the period (or turn it off) in Settings → Performance.": "ponownie. Zmień ten okres (lub wyłącz go) w Ustawienia → Wydajność.", - "against the library's originals no longer scans the whole library when nothing is at that path.": "z oryginałami biblioteki nie skanuje już całej biblioteki, jeśli pod wskazaną ścieżką nie ma żadnych plików.", - "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ma już folder `thumbs/` z innymi zdjęciami — nie tracą one danych przy przycinaniu lub czyszczeniu pamięci podręcznej.", - "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "i sprawdza plik przed użyciem. Maski zachowują swoje zaznaczenie, więc renderowanie i eksport działają także bez modelu.", - "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "i każde importowane zdjęcie w jednym archiwum ZIP, a Plik ▸ Przywróć bibliotekę z kopii zapasowej… przywraca je (bieżąca biblioteka", - "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "oraz nie blokuje okna. Funkcja jest opcjonalna i nie należy do LightCraft (licencja SAM firmy Meta, około 3,4 GB):", - "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "jako `<name>.corrupt-<time>` i informuje o tym; plik, którego nie można odczytać (np. zablokowany przez inny program), pozostaje bez zmian", - "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "gdy o to poprosisz. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (lub `WGPU_BACKEND` biblioteki wgpu, którą renderowanie GPU", - "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "pakiety zawierające dowolne z nich — całe foldery naraz, grupowane według pakietu. Maski zawarte w ustawieniach również są przenoszone.", - "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "zapis aparatu nie jest już na stałe stosowany do obrazu. Zdjęcia wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu. (Pliki, których", - "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "takiej zmiany. Katalogi zawierające już taki fragment wczytują się w całości. Zamykanie programu, gdy dziennika katalogu nie da się zapisać", - "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "zmian nie udało się zapisać, podejmuje jeszcze jedną próbę, a następnie pyta: Spróbuj zapisać ponownie, Zakończ mimo to lub Anuluj.", - "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "przez kanał sterujący lub MCP, ścieżka docelowa łączenia albo `lightcraft-cli render IMG.jpg -o IMG.jpg` są odrzucane z czytelnym", - "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "weryfikowane względem zawartości odczytanej z karty. Nieudaną lub różniącą się kopię usuwa się i zgłasza jako nieudany", - "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "kolory i tonację dopasowane do JPEG z aparatu, podobnie jak w plikach Nikon i Sony RAW, z kadrem zgodnym z proporcjami ustawionymi w", - "copy is verified and in the library. Duplicates and files that fail stay where they were.": "kopia została zweryfikowana i dodana do biblioteki. Duplikaty i pliki z błędami pozostają na swoim miejscu.", - "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dane nie są jeszcze dekodowane, więc pozostają dostępne tylko jako podgląd. Dla plików CR3 zaimportowanych wcześniej funkcja Zdjęcie ▸ Wczytaj ponownie z dysku (teraz w", - "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "usuwane są krawędzie, a „12-bitowe nieskompresowane” pliki nie są już przycinane tonalnie. Seria RX100 wyświetla się znacznie bliżej", - "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "każdy format zapisywany przez aparaty. Większość modeli wcześniej otwierała tylko podgląd: GH1–GH5, serie G, GX, GF, GM, FZ, LX i", - "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "eksport. Katalog, pliki XMP, ustawienia, DNG, połączone zdjęcia, podglądy inteligentne i kopie utworzone podczas importu nadal są", - "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "pola i zachowuje pozostałe — np. ustawienia wywoływania oraz historię edycji z tamtej aplikacji — bajt po bajcie. Plik", - "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "pliku (Sony przechowuje je w tych modelach jedynie w zaszyfrowanych danych producenta), kilka kolumn wyrównania przy prawej", - "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "przy pierwszym użyciu maski AI LightCraft prosi o zgodę na pobranie, pokazuje postęp, pozwala anulować i wznowić,", - "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "na czas sesji, zamiast nadpisywać je wartościami domyślnymi. Ustawienia aplikacji (`ui.json`, które zapamiętują", - "gets -1, -2… instead of being replaced.": "otrzymuje przyrostek -1, -2… zamiast zostać zastąpiony.", - "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "zielonkawego, neutralnego obrazu (zgłoszenie #150); balans bieli korygowany jest względem wyglądu zapisanego przez aparat. Pliki NEF 12-bitowe", - "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "jest otwarta — na tym komputerze lub innym współdzielącym folder — wskazuje kto ją otworzył („już otwarta w LightCraft (proces", - "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "jak je przywrócić albo trwale usunąć i ponownie zaimportować. (Aby zacząć od nowa z danym zdjęciem, użyj Resetuj edycje,", - "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "wcześniej ignorowany) wybiera zaplecze graficzne; `off` renderuje na CPU. GPU uruchamia się teraz po otwarciu okna", - "immutable, and HOSTING.md describes the actual build.": "i niezmienne, a HOSTING.md opisuje rzeczywistą konfigurację kompilacji.", - "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importu — dlatego „import ukończony” oznacza zweryfikowane kopie, zanim sformatujesz kartę — a zajęta nazwa", - "imported as \"preview only\" switch over on Reload.": "wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu.", - "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "w krótkich partiach, aby mechanizmy nadzoru wolnych zintegrowanych GPU nie resetowały urządzenia. `ui.inspect` → `perf`", - "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "niedokończone renderowanie jest powtarzane na CPU — plik wynikowy zawiera ten sam obraz w obu przypadkach. Praca jest wysyłana do GPU", - "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "jest zachowywany). Nieudany zapis (pełny nośnik) wyświetla teraz ostrzeżenie o niezapisanych zmianach i jest ponawiany; zdjęcie, którego nie można zapisać", - "is refused and nothing is overwritten.": "jest odrzucana i nic nie zostaje nadpisane.", - "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "działa, i tylko jeśli w Ustawienia ▸ Wydajność włączono używanie GPU do renderowania; jeśli LightCraft kiedykolwiek zakończy się awaryjnie podczas", - "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "nie zostaje dodana, druga karta pokazuje komunikat zamiast nadpisywać pierwszą, `?reset` najpierw pyta, a strona informuje", - "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "z dala od drugiego pliku (zgłoszenie #92). Znajdź brakujące zdjęcia wyszukuje również pliki o zmienionych nazwach na podstawie zawartości, preferuje zgodność zawartości", - "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ego zdjęcia i pliki XMP, a zdjęcia ponownie na niego wskazują. Jeśli stare miejsce jest już zajęte, cofnięcie", - "layout is read from each file instead of assumed.": "układ jest odczytywany z każdego pliku, zamiast przyjmować go z góry.", - "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "nie pozostawia pliku DNG i zachowuje RAW. Podglądy inteligentne zapisuje się tak samo; uszkodzony podgląd (ucięty przez awarię lub", - "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteki) są zapisywane atomowo i natychmiast po otwarciu innej biblioteki, nie tylko przy wychodzeniu. Zamykanie programu, gdy", - "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "nie ogranicza się już do komunikatu „pominięto duplikat”: otwiera panel boczny Ostatnio usunięte, zaznacza zdjęcie i informuje,", - "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "wszystko zostało utracone przy wyjściu; sesja tymczasowa przez cały czas pokazuje baner i nigdy nie zapisuje w Twojej bibliotece.", - "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "pamięci, przekroczy limity bufora GPU, napotka błąd lub reset sterownika albo zwróci", - "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "komunikatem, a oryginał pozostaje niezmieniony bajt po bajcie. Wcześniejsze zwykłe eksporty nadal są nadpisywane na żądanie.", - "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "w trakcie partii: błąd wymienia pliki (stara → nowa nazwa), a biblioteka wskazuje ich nowe nazwy (częściową zmianę nazw można cofnąć),", - "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modele z matrycą zaczynającą się od rzędu zielono-niebieskiego) nie mają już magentowego zafarbu (zgłoszenie #85): układ filtrów barwnych", - "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nazwę (bez zastępowania pliku), a dopiero potem łączy zdjęcie z plikiem albo usuwa kopię RAW — nieudany zapis", - "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "nigdy nie pozostawia uciętego pliku; plik XMP eksportu „Oryginał” także respektuje ustawienie „Jeśli plik istnieje”.", - "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "są teraz wywoływane z danych RAW, zamiast z JPEG osadzonego przez aparat, dzięki czemu styl czarno-biały lub inny ustawiony w", - "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "na karcie po przeglądzie importu jest zgłaszana, zamiast być importowana z nieaktualnymi danymi. Sprawdzanie ścieżki eksportu", - "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "pojedynczo — można je zawsze wyeksportować ponownie, a na dysku USB lub NAS synchronizacja każdego pliku osobno znacznie wydłużała duże", - "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "zamiast pliku o tej samej nazwie i rozmiarze, ale innej zawartości, a niejednoznaczne zdjęcia pomija (i zgłasza), zamiast zgadywać.", - "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "część na zewnątrz) albo wpisz, co zaznaczyć („niebo”, „czerwony samochód”, „samochód, droga”); obie maski można łączyć z innymi, mają", - "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "z zachowaniem edycji RAW i eksportu w pełnej rozdzielczości, zamiast korzystać z osadzonych podglądów JPEG.", - "read in the wrong units.": "odczytywano w niewłaściwych jednostkach.", - "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "ponowne odczytanie karty po raz trzeci (funkcja Przenieś nadal porównuje dane bajt po bajcie przed usunięciem źródła); plik, który zmienił się", - "reject": "odrzuć", - "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "renderowane obrazy i zrzuty ekranu nadal trafiają najpierw do pliku tymczasowego, potem otrzymują nazwę docelową, ale nie są już wymuszane zapisy na dysk", - "save goes through.": "zapis powiedzie się.", - "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "pokrewne modele) nie mają już jaskrawozielonych zdjęć (zgłoszenie #148): balans bieli i poziom czerni zapisane przez aparat odczytuje się z", - "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "plik XMP, który nie jest prawidłowym XMP, zostaje najpierw skopiowany do `<name>.xmp.bak-<time>`. Przy domyślnym nazewnictwie według podstawy nazwy pliki RAW i", - "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "żaden plik nie jest oznaczany jako brakujący. Zmiana nazwy jednego z pary RAW + JPEG kopiuje ich wspólny `IMG_0001.xmp`, zamiast zabierać go", - "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "podczas uruchamiania GPU następny start odbywa się z wyłączonym renderowaniem GPU i informacją, jak je ponownie włączyć.", - "starts with": "zaczyna się od", - "still saves your queued changes in the closing snapshot.": "nadal zapisuje oczekujące zmiany w migawce zamykającej.", - "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronizowane. Import ▸ Kopiuj sprawdza teraz każdą kopię względem skrótu zawartości obliczonego podczas skanowania karty, zamiast", - "the JPEG uses `IMG_0001.JPG.xmp`.": "a JPEG korzysta z `IMG_0001.JPG.xmp`.", - "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "drzewo folderów Lokalnie i Automatyczny import również odczytują dysk w wątkach roboczych. Okno postępu importu ma przycisk Anuluj.", - "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "menu Zdjęcie i menu kontekstowym zdjęcia) pobiera pełnowymiarowy podgląd i uzupełnia metadane aparatu, których", - "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "gdy aparat; uszkodzone piksele matrycy są wypełniane. Importowane są również pliki `.rwl` i `.raw`. Zdjęcia wcześniej", - "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "tej samej nazwie i wybraniu „Nadpisz” (lub ponownym eksporcie z poprzednimi ustawieniami), dokładna ścieżka wyjściowa z kanału sterującego", - "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "ich folder nie był przeglądany przez 30 dni — pliki i XMP pozostają na dysku, a ponowne wejście do folderu je pokazuje", - "to the camera's own JPEG.": "wyglądu JPEG prosto z aparatu.", - "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "nienaruszone — starsze wersje nie odczytują już fragmentu jako uszkodzonego dziennika. Gdy ta wersja otworzy bibliotekę,", - "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "użyto (np. pominięty adapter programowy llvmpipe) i dlaczego ostatnie renderowanie przełączono na inne rozwiązanie.", - "video": "wideo", - "were missing, without touching anything already set.": "brakowało, bez zmiany już ustawionych wartości.", - "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "gdy przeglądarka może usunąć bibliotekę. Hosting: przykładowe nagłówki pamięci podręcznej nie oznaczają już plików bez skrótów w nazwach jako", - "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "sprawdzanie obecności oryginałów odbywa się w wątku roboczym (miniatury siatki, panel Informacje, menu zdjęcia, Brakujące", - "with a counterpart here come along, the rest is listed.": "z odpowiednikami w tym programie również zostają przeniesione, pozostałe są wypisane.", - "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "zapisana przez nowszą wersję LightCraft zostaje odrzucona z komunikatem „ta biblioteka została zapisana przez nowszą wersję LightCraft” i pozostawiona", - "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "zapisana przez nowszą wersję LightCraft), LightCraft wyjaśnia przyczynę i proponuje Spróbuj ponownie, Wybierz inną bibliotekę… lub Kontynuuj", - "日本語": "日本語", - "繁體中文(台灣)": "繁體中文(台灣)", - "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Ta wersja programu nie może jeszcze pobrać modelu. Aby zainstalować go ręcznie, umieść pliki model.safetensors, vocab.json i merges.txt od Meta (facebook/sam3) w powyższym folderze: funkcje Obiekt i Opis zadziałają, gdy pliki się tam znajdą.", - "denoiseModel.accept": "denoiseModel.accept" -} diff --git a/packaging/crates/ui-egui/src/i18n.rs b/packaging/crates/ui-egui/src/i18n.rs deleted file mode 100644 index 998a1fcbe..000000000 --- a/packaging/crates/ui-egui/src/i18n.rs +++ /dev/null @@ -1,1354 +0,0 @@ -//! Presentation-only localization. Command ids, user names and catalog data remain unchanged. -//! -//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the -//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its -//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry -//! plus its catalogs and the corresponding language command in `menus.rs`. Settings read the -//! locale table, while menus and the control channel share the command mapping. - -use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock}; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// The language table. -/// -/// The source language comes first and alone: it is the text the UI is authored in, which every -/// other language translates. `name` is written in the language itself (an endonym, so a user who -/// cannot read the current UI language can still find their own). -macro_rules! language_table { - ($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => { - /// A UI language. - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] - pub enum Locale { - /// The language the UI is written in. - #[default] - $source, - $($variant,)* - } - - impl Locale { - /// Every language, in menu order (the source language first). - pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*]; - - pub const fn code(self) -> &'static str { - match self { Self::$source => $source_code, $(Self::$variant => $code,)* } - } - - /// The language's own name, as shown in the Language menus. - pub const fn name(self) -> &'static str { - match self { Self::$source => "English", $(Self::$variant => $name,)* } - } - - /// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…): - /// the craft-fonts faces for it are the ones this language needs. - pub const fn script(self) -> &'static str { - match self { Self::$source => "Latn", $(Self::$variant => $script,)* } - } - - /// The language's embedded catalog, parsed once. The source language has none: its - /// lookups return the source text itself. - fn catalog(self) -> &'static BTreeMap<String, String> { - static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new(); - static EMPTY: OnceLock<BTreeMap<String, String>> = OnceLock::new(); - // A malformed catalog logs and comes back empty: a broken translation degrades to - // English text, it never takes the app down. - let parse = |catalog: Option<&'static str>| match catalog { - Some(json) => match serde_json::from_str(json) { - Ok(messages) => messages, - Err(error) => { - log::error!("Invalid message catalog for {}: {error}", self.code()); - BTreeMap::new() - } - }, - None => BTreeMap::new(), - }; - CATALOGS - .get_or_init(|| { - Locale::ALL - .iter() - .map(|language| { - let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None }; - (*language, parse(catalog)) - }) - .collect() - }) - .get(&self) - .unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new)) - } - - pub fn parse(code: &str) -> Option<Self> { - match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None } - } - - /// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a - /// shipped language. A region or script the table does not ship falls back to the - /// language itself, so a user is never left with no language at all. - pub fn parse_tag(tag: &str) -> Option<Self> { - let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-"); - let lower = tag.to_ascii_lowercase(); - if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) { - return Some(*exact); - } - let mut parts = lower.split('-'); - let language = parts.next()?; - // `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language. - let script = parts.next().and_then(|part| match part { - "hans" | "cn" | "sg" => Some("Hans"), - "hant" | "tw" | "hk" | "mo" => Some("Hant"), - _ => None, - }); - Self::ALL - .iter() - .find(|candidate| { - let base = candidate.code().split('-').next().unwrap_or_default(); - base == language && script.is_none_or(|script| candidate.script() == script) - }) - .copied() - } - - pub fn tr(self, source: &str) -> &str { - tr_in(self, source) - } - } - }; -} - -// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded -// catalog (`locales/<code>.json`). -language_table! { - En, "en"; - ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); - ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); - Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); - PtBr, "pt-br", "Português (Brasil)", "Latn", include_str!("../locales/pt-br.json"); - Es, "es", "Español", "Latn", include_str!("../locales/es.json"); - De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); - Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); - Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); - Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); - Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json"); -} - -// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a -// language can be renamed in code without invalidating anyone's settings. -impl Serialize for Locale { - fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(self.code()) - } -} - -impl<'de> Deserialize<'de> for Locale { - fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { - let code = String::deserialize(deserializer)?; - Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}"))) - } -} - -thread_local! { - static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) }; -} - -/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs. -pub fn default_language() -> Locale { - std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En) -} - -pub fn set_language(language: Locale) { - LOCALE.with(|value| value.set(language)); -} - -pub fn language() -> Locale { - LOCALE.with(std::cell::Cell::get) -} - -/// Translate a built-in display label, preserving unknown labels verbatim. -/// Never call this on editable user text, filenames or command identifiers. -pub fn tr(source: &str) -> &str { - tr_in(language(), source) -} - -/// [`tr`] for a message whose translation depends on where it appears: a catalog entry keyed -/// `"<context>|<source>"` wins, else the plain message. A language adds the contextual entry only -/// where its shared translation reads wrongly there (Japanese はい / いいえ as a rule's value). -fn tr_ctx_in<'a>(language: Locale, context: &str, source: &'a str) -> &'a str { - match language.catalog().get(&format!("{context}|{source}")) { - Some(translation) => translation.as_str(), - None => tr_in(language, source), - } -} - -/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed -/// out for as long as the `'static` key it was looked up by. -fn tr_in(language: Locale, source: &str) -> &str { - if language.catalog().is_empty() { - return source; - } - thread_local! { - static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = const { RefCell::new(BTreeMap::new()) }; - } - VERBATIM.with_borrow_mut(|cache| { - let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language)); - verbatim.get(source).copied().unwrap_or(source) - }) -} - -/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow. -fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> { - language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() -} - -// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from -// `locales/*-formats.json`. -include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); - -/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays -/// exactly as its owner wrote it. -/// The title and text of the message box shown when the desktop app's window can't start -/// (issue #260), in the UI language: the error and, when there is one, the log file to look in. -pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, String) { - let text = match log_file { - Some(path) => tr_format!("LightCraft could not open its window: {e}\n\nThe log file has the details: {path}", e = error, path = path), - None => tr_format!("LightCraft could not open its window: {e}", e = error), - }; - (tr("LightCraft could not start").to_string(), text) -} - -pub fn builtin_label(source: &str, builtin: bool) -> &str { - if builtin { tr(source) } else { source } -} - -/// A profile's name for display: a built-in profile's is translated, an imported LUT's is shown as -/// its file named it (it may happen to match a message, like "Vivid"). -pub fn profile_label<'a>(id: &str, name: &'a str) -> &'a str { - builtin_label(name, lightcraft_engine::presets::profile(id).is_some()) -} - -/// An Activity (history) step for display. The step's stored label stays English; generated steps -/// are translated around the names they carry, and a user preset's name is kept verbatim. -pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String { - if let Some(name) = source.strip_prefix("Preset: ") { - // A user preset may share a stock preset's name: then the step could be either, so it - // keeps the name as written. - let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name); - return tr_format!("Preset: {name}", name = builtin_label(name, builtin)); - } - if let Some(name) = source.strip_prefix("Reset ") { - return tr_format!("Reset {name}", name = tr(name)); - } - if let Some(name) = source.strip_prefix("Quick Develop: ") { - return tr_format!("Quick Develop: {name}", name = tr(name)); - } - tr(source).to_string() -} - -/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's -/// name is the user's and stays verbatim. -pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String { - let label = source.label(catalog); - if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() } -} - -/// What the grid is titled for the session's source: [`source_label`], or a library folder's last -/// two names (`photos/travel`; the folder's name is the user's and stays verbatim). -pub fn source_title(session: &lightcraft_engine::Session) -> String { - match (session.source, session.library_folder.as_deref()) { - (lightcraft_engine::LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path), - (source, _) => source_label(source, &session.catalog), - } -} - -/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full -/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the -/// catalog's own wording; every other language formats it with its `*-formats.json` date -/// patterns and weekday names. The grouping keys themselves never change. -pub fn date_group_label(key: &str, short: bool) -> String { - let label = lightcraft_catalog::dates::group_label(key); - if language() == Locale::En { - return if short { - match key.len() { - // "September 2026" → "September" - 7 => label.split(' ').next().unwrap_or(&label).to_string(), - // "Sunday, 20 September 2026" → "Sunday, 20" - 10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(), - _ => label, - } - } else { - label - }; - } - let Some(year) = key.get(..4).and_then(|y| y.parse::<u32>().ok()) else { return tr(&label).to_string() }; - if key.len() == 4 { - return tr_format!("{year}", year = year); - } - let Some(month) = key.get(5..7).and_then(|m| m.parse::<u32>().ok()).filter(|m| (1..=12).contains(m)) else { - return tr(&label).to_string(); - }; - if key.len() == 7 { - return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) }; - } - if key.len() == 10 - && let Some(weekday) = lightcraft_catalog::dates::weekday(key) - && let Some(day) = key.get(8..10).and_then(|d| d.parse::<u32>().ok()) - { - let weekday = tr(weekday); - return if short { - tr_format!("{weekday}, {day}", weekday = weekday, day = day) - } else { - tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year) - }; - } - tr(&label).to_string() -} - -/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM"); -/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is. -pub fn display_time(iso: &str) -> String { - if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() { - return lightcraft_catalog::dates::display_time(iso); - } - let date = date_group_label(iso.get(..10).unwrap_or(iso), false); - match iso.get(11..).filter(|time| !time.is_empty()) { - Some(time) => format!("{date} {time}"), - None => date, - } -} - -/// Built-in colour names are translated; custom label names are user data. -pub fn color_label(catalog: &lightcraft_catalog::Catalog, label: lightcraft_catalog::ColorLabel) -> String { - catalog.custom_label_name(label).map_or_else(|| tr(&catalog.label_name(label)).to_string(), str::to_string) -} - -/// A legacy smart-album filter summary, using the same display labels as filter chips. -pub fn filter_label(filter: &lightcraft_catalog::Filter, catalog: &lightcraft_catalog::Catalog) -> String { - if language() == Locale::En { - filter.describe_with(catalog) - } else { - lightcraft_engine::filter_chips(filter, catalog) - .iter() - .map(|chip| crate::panels::chips::display_label(chip, filter, catalog)) - .collect::<Vec<_>>() - .join(" · ") - } -} - -/// What people read for a smart-album rule's problem, under its row: the field's name and the -/// issue, in the current language ("Title: needs something to look for"). -pub fn problem_text(problem: &lightcraft_catalog::rules::Problem) -> String { - problem_text_in(language(), problem) -} - -/// [`problem_text`] with the rule's position first ("#2.1 Rating: …"), for lists away from the -/// editor (the sidebar's tooltip). -pub fn problem_line(problem: &lightcraft_catalog::rules::Problem) -> String { - problem_line_in(language(), problem) -} - -fn problem_text_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { - let issue = tr_in(language, problem.issue.text()); - // Japanese and Chinese catalogs join a label and its text with a full-width colon - let colon = if matches!(language.script(), "Jpan" | "Hans" | "Hant") { ":" } else { ": " }; - match problem.field.as_deref() { - Some(field) => format!("{}{colon}{issue}", tr_in(language, lightcraft_catalog::rules::field_label(field).unwrap_or(field))), - None => issue.to_string(), - } -} - -fn problem_line_in(language: Locale, problem: &lightcraft_catalog::rules::Problem) -> String { - let at: Vec<String> = problem.path.iter().map(|i| i.saturating_add(1).to_string()).collect(); - // a problem with the album filter around the rules has no rule to point at - if at.is_empty() { - return problem_text_in(language, problem); - } - format!("#{} {}", at.join("."), problem_text_in(language, problem)) -} - -/// What people read for smart-album choice `id` of `field` ("Gemeinfrei" for `publicDomain` in -/// German); an id the field doesn't know, as written. -pub fn choice_text<'a>(field: &str, id: &'a str) -> &'a str { - choice_text_in(language(), field, id) -} - -fn choice_text_in<'a>(language: Locale, field: &str, id: &'a str) -> &'a str { - lightcraft_catalog::rules::choice_label(field, id).map_or(id, |label| tr_in(language, label)) -} - -/// What people read for a smart-album yes/no value ("Ja" / "Nein" in German). -pub fn bool_text(b: bool) -> &'static str { - bool_text_in(language(), b) -} - -/// The context of a smart-album yes/no value ([`tr_ctx_in`]). -const BOOL_CONTEXT: &str = "smart album value"; - -fn bool_text_in(language: Locale, b: bool) -> &'static str { - tr_ctx_in(language, BOOL_CONTEXT, lightcraft_catalog::rules::bool_label(b)) -} - -/// A rule summary for display, keeping free-text rule values verbatim. -pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_catalog::Catalog) -> String { - fn describe(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_catalog::Catalog, depth: usize) -> String { - use lightcraft_catalog::{ - Match, Rule, - rules::{FIELDS, Kind, ops_for}, - }; - if depth >= 16 { - return tr("Nested group").to_string(); - } - let parts: Vec<String> = rules - .rules - .iter() - .map(|rule| match rule { - Rule::Group { group } => format!("({})", describe(group, catalog, depth + 1)), - Rule::Field { field, op, value } => { - let Some((_, label, kind)) = FIELDS.iter().find(|entry| entry.0 == field) else { return field.clone() }; - let operator = ops_for(*kind).iter().find(|entry| entry.0 == op).map_or(op.as_str(), |entry| entry.1); - let text = match kind { - // the album's name, as people know it (a user's name is never translated) - Kind::Album => lightcraft_catalog::rules::album_name(value, Some(catalog)), - Kind::Choice(_) => value.as_str().map_or_else(|| value.to_string(), |id| choice_text(field, id).to_string()), - Kind::Bool => lightcraft_catalog::rules::bool_value(value).map_or_else(|| value.to_string(), |b| bool_text(b).to_string()), - _ if matches!(op.as_str(), "inLast" | "notInLast") => format!( - "{} {}", - value.get("n").unwrap_or(&serde_json::Value::Null), - tr(value.get("unit").and_then(serde_json::Value::as_str).unwrap_or("days")) - ), - _ => value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }), - }; - format!("{} {} {text}", tr(label), tr(operator)).trim().to_string() - } - }) - .collect(); - let join = match rules.mode { - Match::All => tr("and"), - Match::Any | Match::None => tr("or"), - }; - let text = parts.join(&format!(" {join} ")); - if rules.mode == Match::None { format!("{} ({text})", tr("none of")) } else { text } - } - // Preserve the established source-language summary. - if language() == Locale::En { rules.describe_with(catalog) } else { describe(rules, catalog, 0) } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn every_language_has_a_catalog_covering_the_source() { - assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); - for language in Locale::ALL { - assert!(!language.name().is_empty(), "{language:?} has a name"); - assert!(!language.script().is_empty(), "{language:?} has a script"); - assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); - if *language == Locale::En { - continue; - } - let messages = language.catalog(); - assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code()); - for (key, value) in messages { - assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}"); - } - } - } - - /// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover - /// different sets of messages (a message a language lacks shows in English): the gaps are - /// reported, not failed, so a translation can grow at its own pace. - #[test] - fn catalogs_agree_on_placeholders_and_report_gaps() { - let fields = |text: &str| { - let mut out: Vec<String> = Vec::new(); - let mut rest = text; - while let Some(start) = rest.find('{') { - let Some(end) = rest[start..].find('}') else { break }; - out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); - rest = &rest[start + end + 1..]; - } - out.sort(); - out - }; - let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); - let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect(); - for language in &translated { - let catalog = language.catalog(); - for (key, value) in catalog { - assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); - } - let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !missing.is_empty() { - eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len()); - } - } - } - - /// Locale tags are matched leniently, so a system locale reaches a shipped language. - /// The message box for a desktop start that failed (issue #260) names the error and the log - /// file, in the UI language. - #[test] - fn startup_failure_message_names_the_error_and_the_log() { - let (title, text) = startup_failed_message("no adapter", Some("/home/a/.config/lightcraft/logs/lightcraft.log")); - assert_eq!(title, "LightCraft could not start"); - assert!(text.contains("no adapter") && text.ends_with("lightcraft.log"), "{text}"); - assert!(!startup_failed_message("no adapter", None).1.contains("log file")); - } - - #[test] - fn locale_tags_normalize() { - assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); - assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); - assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); - assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); - assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); - assert_eq!(Locale::parse_tag("pt-br"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt-BR"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt_BR.UTF-8"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("pt"), Some(Locale::PtBr)); - assert_eq!(Locale::parse_tag("fr"), Some(Locale::Fr)); - assert_eq!(Locale::parse_tag("fr-FR"), Some(Locale::Fr)); - assert_eq!(Locale::parse_tag("fr_FR.UTF-8"), Some(Locale::Fr)); - for tag in ["de", "de-DE", "de_AT.UTF-8", "de-CH"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::De), "{tag}"); - } - for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "ru-UA"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Ru), "{tag}"); - } - for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] { - assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}"); - } - assert_eq!(Locale::parse_tag("xx"), None); - } - - #[test] - fn switching_language_translates_and_restores() { - set_language(Locale::ZhHans); - assert_eq!(tr("Exposure"), "曝光"); - assert_eq!(tr("Settings"), "设置"); - // Untranslated and data-like text passes through untouched. - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(tr("A string no catalog has"), "A string no catalog has"); - set_language(Locale::Ja); - assert_eq!(tr("Exposure"), "露出"); - set_language(Locale::PtBr); - assert_eq!(tr("Exposure"), "Exposição"); - assert_eq!(tr("Settings"), "Configurações"); - set_language(Locale::En); - assert_eq!(tr("Exposure"), "Exposure"); - } - - /// Format strings carry their values in every language, and a language without a translation - /// falls back to the English format rather than dropping the values. - #[test] - fn formats_render_in_every_language() { - // Every language's format string is checked by `format!` against the call site's values. - set_language(Locale::En); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); - set_language(Locale::ZhHans); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片"); - set_language(Locale::Ja); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); - set_language(Locale::Fr); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 photos importée(s)"); - assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 photo importée(s)"); - // Format specs survive translation: precision, sign, and values a language reorders. - for language in Locale::ALL { - set_language(*language); - let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); - assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); - let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); - assert!(merging.contains("42%"), "{language:?}: {merging}"); - let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); - assert!(delta.contains("+5"), "{language:?}: {delta}"); - let reading = tr_format!("Reading photos… {} of {}", 3, 10); - let (three, ten) = (reading.find('3'), reading.find("10")); - assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); - } - set_language(Locale::En); - assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); - } - - /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation - /// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK - /// catalogs are skipped (the same degradation the Japanese UI has always had). - #[test] - fn catalog_characters_are_paintable() { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - for language in Locale::ALL { - let messages = language.catalog(); - if messages.is_empty() { - continue; - } - // The faces this language's script needs; a language the craft-fonts input does not - // cover (a translation added ahead of its font) is reported, not failed. - let mut faces: Vec<&str> = - lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); - faces.dedup(); - if faces.is_empty() { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); - continue; - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family.clone()); - let mut missing: Vec<char> = messages - .values() - .flat_map(|message| message.chars()) - .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) - .collect(); - missing.sort_unstable(); - missing.dedup(); - assert!( - missing.is_empty(), - "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", - language.code(), - missing.iter().collect::<String>() - ); - } - }); - } - } - - /// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a - /// translation must not carry a symbol no installed font has (egui's defaults cover the rest). - #[test] - fn interface_symbols_are_paintable() { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). - ctx.fonts_mut(|fonts| { - for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { - let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); - assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); - } - }); - } - - #[test] - fn translated_catalogs_do_not_use_missing_menu_triangles() { - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.extension().is_none_or(|ext| ext != "json") { - continue; - } - let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - for (source, text) in messages { - assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display()); - } - } - } - - /// The language menu covers every language, and a language's own command selects it. - #[test] - fn language_commands_cover_every_language() { - let commands = [ - ("app.language.traditionalChinese", Locale::ZhHant), - ("app.language.english", Locale::En), - ("app.language.simplifiedChinese", Locale::ZhHans), - ("app.language.japanese", Locale::Ja), - ("app.language.portuguese", Locale::PtBr), - ("app.language.french", Locale::Fr), - ("app.language.spanish", Locale::Es), - ("app.language.german", Locale::De), - ("app.language.russian", Locale::Ru), - ("app.language.ukrainian", Locale::Uk), - ("app.language.polish", Locale::Pl), - ]; - // One command per language, and every command reachable from the menu table. - assert_eq!(commands.len(), Locale::ALL.len()); - for (id, language) in commands { - assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); - assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); - } - assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); - assert_eq!(crate::menus::language_from_command("view.detail"), None); - } - - #[test] - fn preferences_round_trip_and_old_settings_remain_readable() { - let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); - assert_eq!(old.language, Locale::En); - // Settings written before the language list grew still load. - let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); - assert_eq!(legacy.language, Locale::Ja); - let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; - let saved = serde_json::to_string(&settings).unwrap(); - let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Locale::ZhHans); - } - - #[test] - fn catalogs_contain_core_workflows() { - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { - let value = language.tr(key); - assert!(!value.is_empty() && value != key, "{language:?}: {key}"); - } - } - } - - /// Smart-album choice values read as words in every language (English "Public Domain", German - /// "Gemeinfrei"), never as rule ids like `publicDomain`; an id the field doesn't know shows as - /// written. Unlike other labels these must be translated: an untranslated one would be English - /// in the middle of a translated rule. - #[test] - fn choice_values_read_as_words_in_every_language() { - use lightcraft_catalog::rules::{FIELDS, Kind}; - assert_eq!(choice_text_in(Locale::En, "copyrightStatus", "publicDomain"), "Public Domain"); - assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "publicDomain"), "Gemeinfrei"); - assert_eq!(choice_text_in(Locale::De, "copyrightStatus", "someday"), "someday"); - assert_eq!(choice_text_in(Locale::ZhHans, "treatment", "color"), "彩色", "in colour, not the colour noun"); - assert_eq!(choice_text_in(Locale::Ru, "treatment", "color"), "Цветное"); - assert_eq!(choice_text_in(Locale::Es, "label", "none"), "Sin etiqueta"); - // yes/no fields read Yes / No, never true / false - assert_eq!((bool_text_in(Locale::En, true), bool_text_in(Locale::En, false)), ("Yes", "No")); - assert_eq!(bool_text_in(Locale::De, false), "Nein"); - // Japanese answers はい / いいえ don't read as a rule's value: its own wording, あり / なし, - // through a context, while the shared Yes / No keep their meaning everywhere else - assert_eq!((bool_text_in(Locale::Ja, true), bool_text_in(Locale::Ja, false)), ("あり", "なし")); - assert_eq!((Locale::Ja.tr("Yes"), Locale::Ja.tr("No")), ("はい", "いいえ")); - assert_eq!(tr_ctx_in(Locale::Ja, "nope", "Yes"), "はい", "no contextual entry: the plain message"); - assert_eq!(tr_ctx_in(Locale::En, BOOL_CONTEXT, "Yes"), "Yes"); - // and the yes/no fields are named by plain nouns, so "編集 … なし" doesn't read "edits: yes … none" - for (field, label, kind) in FIELDS { - if *kind != Kind::Bool { - continue; - } - let ja = Locale::Ja.catalog().get(*label).unwrap_or_else(|| panic!("ja lacks the {field} label {label:?}")); - assert!(!ja.contains("あり") && !ja.contains("なし"), "{field}: {ja}"); - } - // a contextual entry names a real value (a typo would silently fall back to はい / いいえ) - let values = [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)]; - for language in Locale::ALL { - for key in language.catalog().keys() { - if let Some(value) = key.strip_prefix(BOOL_CONTEXT).and_then(|k| k.strip_prefix('|')) { - assert!(values.contains(&value), "{}: {key:?} isn't a yes/no value", language.code()); - } - } - } - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - for label in [lightcraft_catalog::rules::bool_label(true), lightcraft_catalog::rules::bool_label(false)] { - assert!(language.catalog().contains_key(label), "{} lacks {label:?}", language.code()); - } - } - for language in Locale::ALL { - for (field, _, kind) in FIELDS { - let Kind::Choice(choices) = kind else { continue }; - for (id, label) in *choices { - let text = choice_text_in(*language, field, id); - assert_ne!(text, *id, "{}: {field} {id}", language.code()); - if *language != Locale::En { - assert!(language.catalog().contains_key(*label), "{} lacks {label:?}", language.code()); - } - } - } - } - } - - /// A rule's problem reads as the field's name and the issue, in the editor's language ("Titel: - /// braucht einen Suchbegriff …"); the sidebar's list puts the rule's position first. Every issue - /// is translated in every language: an English one would stand out in a translated editor. - #[test] - fn problems_read_in_every_language() { - use lightcraft_catalog::rules::Issue; - let cat = lightcraft_catalog::Catalog::new(); - let problems = |rules: serde_json::Value| { - serde_json::from_value::<lightcraft_catalog::RuleSet>(serde_json::json!({"rules": rules})).unwrap().check(&cat) - }; - let p = problems(serde_json::json!([{"field": "rating", "op": "gte", "value": 3}, {"field": "title", "op": "contains", "value": ""}])); - assert_eq!(problem_text_in(Locale::En, &p[0]), "Title: needs something to look for (or use “is empty”)"); - assert_eq!(problem_text_in(Locale::De, &p[0]), "Titel: braucht einen Suchbegriff (oder „ist leer“)"); - assert_eq!(problem_line_in(Locale::En, &p[0]), "#2 Title: needs something to look for (or use “is empty”)"); - // Japanese and Chinese join with a full-width colon, as their catalogs do - assert_eq!(problem_text_in(Locale::Ja, &p[0]), "タイトル:検索する語句が必要です(または「が空」を使用)"); - // a problem with the album filter itself has no rule position to show - let filter_loop = - lightcraft_catalog::rules::Problem { path: Vec::new(), field: Some("album".into()), issue: Issue::AlbumLoop, message: String::new() }; - assert!(problem_line_in(Locale::En, &filter_loop).starts_with("Album: "), "{}", problem_line_in(Locale::En, &filter_loop)); - let g = problems(serde_json::json!([{"group": {"rules": []}}])); - assert_eq!(problem_text_in(Locale::En, &g[0]), "This group is empty: add a rule or remove it."); - // the issues, the operators their hints name and the album picker's words, in every language - for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - // the field menu: every field and every group, so it never mixes languages - for label in lightcraft_catalog::rules::FIELDS.iter().map(|f| f.1).chain(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|g| g.0)) { - assert!(language.catalog().contains_key(label), "{} lacks the rule label {label:?}", language.code()); - } - for word in [ - "Choose an album…", - "No albums yet", - "Search albums", - "No albums match", - "This is the album you're editing", - "It tests this album, so testing it back would loop", - "Fix the marked rules to save this album.", - ] { - assert!(language.catalog().contains_key(word), "{} lacks {word:?}", language.code()); - } - for issue in Issue::ALL { - assert!(language.catalog().contains_key(issue.text()), "{} lacks {:?}", language.code(), issue.text()); - } - for (_, _, kind) in lightcraft_catalog::rules::FIELDS { - for (_, op) in lightcraft_catalog::rules::ops_for(*kind) { - assert!(language.catalog().contains_key(*op), "{} lacks the operator {op:?}", language.code()); - } - } - } - } - - /// The date picker's own words (its tooltip, the weekday headers) are in every language, as the - /// month and day labels it borrows from the date headings are. - #[test] - fn date_picker_words_are_translated() { - for language in Locale::ALL.iter().filter(|l| **l != Locale::En) { - for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) { - assert!(language.catalog().contains_key(*word), "{} lacks {word:?}", language.code()); - } - } - } - - /// Ukrainian's coverage of every catalog message and display label. Gaps are reported, not failed - /// (like `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR that adds a label doesn't - /// have to ship its Ukrainian text, which shows in English until the translation catches up. - #[test] - fn ukrainian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { - let catalog = Locale::Uk.catalog(); - let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); - let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); - if !lacking.is_empty() { - eprintln!("uk lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); - } - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); - if !missing.is_empty() { - eprintln!("uk lacks {} display label(s) (shown in English): {missing:?}", missing.len()); - } - } - - #[test] - fn ukrainian_switches_persists_formats_dates_and_preserves_values() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.ukrainian", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::Uk); - assert_eq!(crate::menubar::checked(&app, "app.language.ukrainian"), Some(true)); - assert_eq!(tr("Light"), "Світло"); - assert_eq!(tr("Landscape"), "Пейзаж"); - assert_eq!(tr("Home"), "Домашня тека"); - assert_eq!(date_group_label("2026-09-20", false), "Неділя, 20.09.2026"); - assert_eq!(date_group_label("2026-09", false), "09.2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Неділя, 20.09.2026 16:04:05"); - // Count-neutral labels work for every Ukrainian integer category, with no English suffix. - for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { - assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Імпортовано фото: {n}")); - assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Файлів: {n}, тек: {n}")); - let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); - assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), format!("Кешовані зображення: {n}")); - } - // This trailing argument is queue progress, not an English plural suffix. - let queued = tr_format!(" · {queued} waiting", queued = 5); - assert_eq!( - tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), - "Усунення шуму IMG.CR3: ділянки — 1 із 4 · очікують: 5" - ); - let name = "Color {title} 日本語"; - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("До «{name}» додано фото: 22")); - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Тепле сяйво"); - assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"uk""#)); - assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Uk); - let ctx = egui::Context::default(); - for code in ["uk", "uk-UA", "uk_UA.UTF-8", "uk-Cyrl-UA"] { - assert_eq!(Locale::parse_tag(code), Some(Locale::Uk)); - let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::Uk); - } - set_language(Locale::En); - } - - #[test] - fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains("Мої фото") && text.contains("Усі фото"), "{text}"); - for (command, title) in [("app.about", "Про LightCraft"), ("app.shortcuts", "Клавіатурні скорочення"), ("app.settings", "Налаштування")] - { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::Uk); - assert!(text.contains(title), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ҐґЄєІіЇї’«»" - .chars() - .chain(Locale::Uk.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{0400}'..='\u{04ff}').contains(ch))) - { - assert!(fonts.has_glyph(&font, ch), "missing Ukrainian glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - /// Every command, control, rule and rename label, and every line of What's New, is a message a - /// catalog can translate. What a language lacks is reported, not failed (like - /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every - /// language, and the translations catch up at their own pace. - #[test] - fn display_label_gaps_are_reported() { - let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); - labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); - labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); - for (_, label, kind) in lightcraft_catalog::rules::FIELDS { - labels.push(label); - labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); - } - labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); - labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); - labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); - let ui_labels = labels.len(); - for line in crate::panels::dialogs::WHATS_NEW.lines().map(str::trim) { - if line.is_empty() || line.starts_with("# ") { - continue; - } - let label = line.strip_prefix("### ").or_else(|| line.strip_prefix("## ")).or_else(|| line.strip_prefix("- ")).unwrap_or(line); - labels.push(label); - } - for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { - let catalog = language.catalog(); - let missing: Vec<&&str> = labels[..ui_labels].iter().filter(|label| !catalog.contains_key(**label)).collect(); - let notes = labels[ui_labels..].iter().filter(|label| !catalog.contains_key(**label)).count(); - if !missing.is_empty() || notes > 0 { - eprintln!( - "{} lacks {} display label(s) (shown in English): {missing:?}; and {notes} What's New line(s)", - language.code(), - missing.len() - ); - } - } - // the German catalog, which set out to cover them all, still covers every other catalog - let german = Locale::De.catalog(); - let gaps: Vec<&String> = - Locale::ALL.iter().flat_map(|language| language.catalog().keys()).filter(|key| !german.contains_key(key.as_str())).collect(); - if !gaps.is_empty() { - eprintln!("de lacks {} message(s) another catalog has: {gaps:?}", gaps.len()); - } - } - - #[test] - fn german_switches_via_menu_and_control_persists_and_formats_dates() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.german", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::De); - assert_eq!(tr("File"), "Datei"); - assert_eq!(crate::menubar::checked(&app, "app.language.german"), Some(true)); - assert_eq!(date_group_label("2026-09-20", false), "Sonntag, 20.09.2026"); - assert_eq!(date_group_label("2026-09-20", true), "Sonntag, 20."); - assert_eq!(date_group_label("2026-09", false), "09/2026"); - assert_eq!(display_time("2026-09-20T16:04:05"), "Sonntag, 20.09.2026 16:04:05"); - assert_eq!(tr_format!("Imported {} photo{}", 1, ""), "1 Foto importiert"); - assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12 Fotos importiert"); - assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Color", n = 12), "12 Fotos zu „Color“ hinzugefügt"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"de""#)); - assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::De); - let ctx = egui::Context::default(); - for code in ["de-DE", "de_AT.UTF-8", "de-CH"] { - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; - assert_eq!(reply["ok"], true); - assert_eq!(language(), Locale::De); - } - assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); - assert_eq!(builtin_label("Warm Glow", true), "Warmer Glanz"); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "keywords".into(), op: "contains".into(), value: serde_json::json!("Color") }], - }; - let mut catalog = lightcraft_catalog::Catalog::new(); - catalog - .apply(lightcraft_catalog::Op::AddAlbum { album: lightcraft_catalog::Album::new(lightcraft_catalog::AlbumId(4), "Ausgeschlossen") }) - .unwrap(); - assert_eq!(rules_label(&rules, &catalog), "Stichwörter enthält Color"); - let album = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "album".into(), op: "isNot".into(), value: serde_json::json!(4) }], - }; - assert!(rules_label(&album, &catalog).ends_with(" “Ausgeschlossen”"), "{}", rules_label(&album, &catalog)); - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { - field: "copyrightStatus".into(), - op: "is".into(), - value: serde_json::json!("publicDomain"), - }], - }; - assert!(rules_label(&rules, &catalog).ends_with(" Gemeinfrei"), "{}", rules_label(&rules, &catalog)); - for value in [serde_json::json!(false), serde_json::json!("false")] { - let rules = lightcraft_catalog::RuleSet { - mode: lightcraft_catalog::Match::All, - rules: vec![lightcraft_catalog::Rule::Field { field: "edited".into(), op: "is".into(), value }], - }; - assert!(rules_label(&rules, &catalog).ends_with(" Nein"), "{}", rules_label(&rules, &catalog)); - } - let filter = lightcraft_catalog::Filter { - labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], - ..Default::default() - }; - assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Rot oder Blau"); - app.run("label.setNames", serde_json::json!({"names": {"red": "Color"}})).unwrap(); - assert_eq!(filter_label(&filter, &app.session.catalog), "Farbmarkierung: Color oder Blau"); - set_language(Locale::En); - } - - #[test] - fn german_panels_dialogs_and_glyphs_are_painted_without_extra_fonts() { - let ctx = fonts_ctx(&[]); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains("Meine Fotos") && text.contains("Alle Fotos"), "{text}"); - app.ui.view = crate::state::ViewMode::People; - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains("Benannte Personen"), "{text}"); - for (command, title) in [("app.about", "Über LightCraft"), ("app.shortcuts", "Tastenkürzel"), ("app.settings", "Einstellungen")] { - app.ui.dialog = None; - app.run(command, serde_json::json!({})).unwrap(); - let text = painted_text(&ctx, &mut app, Locale::De); - assert!(text.contains(title), "{command}: {text}"); - } - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for ch in "ÄÖÜäöüß„“".chars() { - assert!(fonts.has_glyph(&font, ch), "missing German glyph {ch}"); - } - } - }); - set_language(Locale::En); - } - - #[test] - fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() { - fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { - match shape { - egui::epaint::Shape::Text(shape) => { - bounds.push((shape.galley.job.text.clone(), egui::Rect::from_min_size(shape.pos, shape.galley.size()))) - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, bounds)), - _ => {} - } - } - for language in [Locale::De, Locale::Uk] { - let ctx = fonts_ctx(&[]); - let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); - app.ui.language = language; - let mut bounds = Vec::new(); - for export in [false, true] { - if export { - app.run("dialog.export", serde_json::json!({})).unwrap(); - } - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - bounds.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut bounds); - } - } - let rect = |id: &str| app.widgets.iter().find(|entry| entry.0 == id).unwrap().1; - if export { - let dialog = rect("dialog:window"); - for id in ["button:exportNamingTags", "button:exportChooseFolder", "button:exportSavePreset"] { - assert!(dialog.contains_rect(rect(id)), "{id} spills outside {dialog:?}: {:?}", rect(id)); - } - } else { - let button = rect("button:copySettings"); - let (_, text) = bounds.iter().find(|entry| entry.0 == language.tr("Copy Edit Settings")).unwrap(); - assert!(button.contains_rect(*text), "{language:?} copy caption overflows its button: {text:?} vs {button:?}"); - assert!(!button.intersects(rect("icon:copyGear"))); - } - } - } - set_language(Locale::En); - } - - /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. - #[test] - fn menu_labels_translate_but_user_names_survive() { - for language in Locale::ALL { - set_language(*language); - assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); - } - set_language(Locale::ZhHans); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); - set_language(Locale::Ja); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); - set_language(Locale::En); - } - - /// The text the whole window paints over a few frames in `language`. - fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { - fn collect(shape: &egui::epaint::Shape, text: &mut String) { - match shape { - egui::epaint::Shape::Text(shape) => { - text.push_str(&shape.galley.job.text); - text.push('\n'); - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), - _ => {} - } - } - app.ui.language = language; - let mut text = String::new(); - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - // This inspects shapes without a renderer; discard texture uploads explicitly. - out.textures_delta.clear(); - text.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut text); - } - } - text - } - - /// Every language is painted by the real window, switching at runtime reinstalls the fonts - /// (their CJK fallback order follows the language), and command ids never change with it. - #[test] - fn every_language_is_painted_and_menu_ids_stay_the_same() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.left_panel = true; - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>(); - let english_ids = ids(&app); - for language in Locale::ALL { - let text = painted_text(&ctx, &mut app, *language); - for label in ["My Photos", "All Photos"] { - assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); - } - assert_eq!(app.font_language, *language, "the fonts follow the language"); - assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); - } - let text = painted_text(&ctx, &mut app, Locale::Ja); - assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); - let text = painted_text(&ctx, &mut app, Locale::ZhHans); - assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); - let text = painted_text(&ctx, &mut app, Locale::PtBr); - assert!(text.contains("Minhas fotos") && text.contains("Todas as fotos"), "{text}"); - set_language(Locale::En); - } - - /// The UI families, after one frame so the font definitions are loaded. - fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(craft)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - ctx - } - - /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI - /// family, Monospace included. - #[test] - fn craft_fonts_render_cjk_in_the_ui() { - let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; - for (language, sample) in samples { - if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); - continue; - } - set_language(language); - let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); - ctx.fonts_mut(|fonts| { - for family in - [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] - { - let font = egui::FontId::new(13.0, family); - for ch in sample.chars() { - assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); - } - let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); - let wide = 13.0 * (sample.chars().count() as f32 - 1.0); - assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); - } - }); - } - set_language(Locale::En); - } - - /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; - /// Latin text keeps Inter. - #[test] - fn the_ui_works_without_craft_fonts() { - let ctx = fonts_ctx(&[]); - ctx.fonts_mut(|fonts| { - // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's - // replacement-glyph face, which there is Hack, the face that draws Latin.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); - } - }); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - for language in Locale::ALL { - app.ui.language = *language; - for frame in 0..3 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - } - } - set_language(Locale::En); - } - - /// Traditional Chinese through the menu command and the control channel, persisted, with - /// localised headings and dates; ids and user text are untouched. - #[test] - fn traditional_chinese_switches_through_menu_and_control_and_persists() { - use crate::control::{ControlRequest, Outcome}; - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Locale::ZhHant); - // Applied at once, not on the next frame. - assert_eq!(tr("File"), "檔案"); - assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片"); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); - assert_eq!(date_group_label("2026-09-20", true), "20日 星期日"); - assert_eq!(date_group_label("2026-09", false), "2026年9月"); - assert_eq!(date_group_label("2026-09", true), "9月"); - assert_eq!(date_group_label("2026", false), "2026年"); - assert_eq!(date_group_label("", false), "未知日期"); - assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05"); - assert_eq!(display_time("a-user-value"), "a-user-value"); - assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); - assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { - assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); - } - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩"); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片"); - assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片"); - let saved = serde_json::to_string(&app.ui).unwrap(); - assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); - assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::ZhHant); - let ctx = egui::Context::default(); - app.ui.language = Locale::En; - for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; - assert_eq!(reply["ok"], true, "{code}"); - assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); - assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); - } - let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); - let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; - assert_eq!(reply["ok"], false); - assert_eq!(app.ui.language, Locale::ZhHant); - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>(); - let chinese_ids = ids(&app); - app.run("app.language.english", serde_json::json!({})).unwrap(); - assert_eq!(tr("File"), "File"); - assert_eq!(chinese_ids, ids(&app)); - } - - /// Dates follow every language's own patterns; English keeps the catalog's wording. - #[test] - fn date_headings_in_every_language() { - set_language(Locale::En); - assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); - assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); - assert_eq!(date_group_label("2026-09", true), "September"); - assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); - set_language(Locale::Ja); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)"); - assert_eq!(date_group_label("2026-09", true), "9月"); - set_language(Locale::ZhHans); - assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); - for language in Locale::ALL { - set_language(*language); - // A key that is not a date never panics and never shows a raw pattern. - for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { - assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); - assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); - } - } - set_language(Locale::En); - } - - /// Stock presets and panel headers are translated; a user preset keeps its name, also when it - /// shares a stock preset's name. - #[test] - fn traditional_chinese_presets_and_panel_headers_are_painted() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.presets = true; - let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); - app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); - for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] { - app.ui.right = panel; - let text = painted_text(&ctx, &mut app, Locale::ZhHant); - assert!(text.contains(title), "{text}"); - assert!(!text.contains("History") && !text.contains("Versions"), "{text}"); - assert!(text.contains("暖光"), "stock preset: {text}"); - assert!(text.contains("Warm Glow"), "user preset: {text}"); - assert!(text.contains("色彩"), "stock group: {text}"); - } - assert_eq!(history_label("Exposure", &app.session.presets), "曝光"); - assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片"); - assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); - set_language(Locale::En); - } -} diff --git a/packaging/crates/ui-egui/src/menus.rs b/packaging/crates/ui-egui/src/menus.rs deleted file mode 100644 index f35eb245c..000000000 --- a/packaging/crates/ui-egui/src/menus.rs +++ /dev/null @@ -1,1715 +0,0 @@ -//! UI-level commands (views, panels, zoom, tools, dialogs) and the menu model shared by the native -//! menu bar, the shortcut handler and the control channel. - -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::LightcraftApp; -use crate::pick::{PickRequest, Picked}; -use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; - -/// (id, label, shortcut, menu path) -pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); - -/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language -/// has a matching menu command (also listed through the control channel). Tests check coverage. -pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ - ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), - ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), - ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), - ("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"), - ("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"), - ("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"), - ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), - ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), - ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), - ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), -]; - -/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from -/// this file, so an id listed in `docs/parity-checklist.md` is checked wherever it is declared. -pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> { - LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) -} - -/// The language a Language-menu command selects, if the id is one. The engine and the UI both go -/// through here, so the menu, the settings row and the control channel agree on the mapping. -pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { - match id { - "app.language.english" => Some(crate::i18n::Locale::En), - "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), - "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), - "app.language.japanese" => Some(crate::i18n::Locale::Ja), - "app.language.portuguese" => Some(crate::i18n::Locale::PtBr), - "app.language.french" => Some(crate::i18n::Locale::Fr), - "app.language.spanish" => Some(crate::i18n::Locale::Es), - "app.language.german" => Some(crate::i18n::Locale::De), - "app.language.russian" => Some(crate::i18n::Locale::Ru), - "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), - "app.language.polish" => Some(crate::i18n::Locale::Pl), - _ => None, - } -} - -pub const UI_COMMANDS: &[UiCommand] = &[ - ("ui.dock", "Arrange Panel", None, ""), - ("ui.dock.reset", "Reset Panel Layout", None, "Window"), - ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), - ("view.photoGrid", "Photo Grid", None, "View"), - ("view.squareGrid", "Square Grid", None, "View"), - // G: Photo Grid ↔ Square Grid (from other views: the photo grid) - ("view.gridToggle", "Grid", Some("G"), ""), - ("tool.guidedUpright", "Guided Upright", Some("Shift+G"), "Window>Tools"), - ("view.detail", "Detail", Some("D"), "View"), - ("view.compare", "Compare", Some("Shift+C"), "View"), - ("view.survey", "Survey", Some("N"), "View"), - ("view.people", "People", None, "View"), - ("view.person", "Show Person", None, ""), - ("view.faceBoxes", "Face Boxes", None, "View"), - ("view.reference", "Reference View", Some("Shift+R"), "View"), - ("photo.setReference", "Set as Reference Photo", None, ""), - ("compare.swap", "Swap Compare Photos", None, "View"), - ("compare.makeSelect", "Make Candidate the Select", None, "View"), - ("view.autoAdvance", "Auto Advance", None, "Photo"), - ("view.filmstrip", "Filmstrip", Some("/"), "View"), - ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), - ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), - ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), - ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), - ("view.beforeAfterSplitTopBottom", "Before/After Split Top/Bottom", Some("Alt+Shift+Y"), "View"), - ("view.showOriginal", "Show Original", Some("\\"), "View"), - ("view.zoomFit", "Zoom to Fit", Some("Cmd+0"), "View"), - ("view.zoom100", "Zoom 100%", Some("Cmd+Alt+0"), "View"), - ("view.zoomToggle", "Toggle Zoom", Some("Z"), "View"), - // the ratio a click (and Z / Space) zooms to - ("view.clickZoom", "Click Zoom Ratio", None, ""), - ("view.navigate", "Set Image Zoom and Pan", None, ""), - ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), - ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), - ("view.clipping", "Show Clipping", Some("J"), "View"), - ("view.visualizeHdr", "Visualize HDR", None, "View"), - // in grids S expands/collapses stacks (the engine command it shadows) - ("view.softProof", "Soft Proofing", Some("S"), "View"), - ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), - ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), - // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) - ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), - ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), - ("view.maskPins", "Show Mask Pins", None, "View"), - ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), - ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), - ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), - ("view.back", "Back to Grid", Some("Escape"), ""), - ("tool.done", "Done", Some("Enter"), ""), - ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), - ("local.addRoot", "Add Folder to Local", None, ""), - ("local.hide", "Remove from Local", None, ""), - ("local.restoreHidden", "Show Hidden Local Locations", None, ""), - ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), - ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), - ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), - ("view.navigator", "Navigator", None, "View"), - ("panel.edit", "Edit", Some("E"), "Window"), - ("panel.profiles", "Profile Browser", None, "Window"), - ("panel.crop", "Crop & Rotate", Some("C"), "Window"), - ("panel.remove", "Remove", Some("H"), "Window"), - ("panel.masking", "Masking", Some("M"), "Window"), - ("panel.redeye", "Red Eye", None, "Window"), - ("panel.presets", "Presets", Some("Shift+P"), "Window"), - ("panel.info", "Info", Some("I"), "Window"), - ("panel.keywords", "Keywords", Some("K"), "Window"), - ("panel.versions", "Versions", Some("Shift+V"), "Window"), - ("panel.activity", "History", None, "Window"), - ("panel.close", "Close Panel", None, ""), - ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), - ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), - ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), - ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), - ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), - ("tool.brush", "Brush", Some("B"), "Window>Tools"), - ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), - ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), - ("tool.wbPicker", "White Balance Selector", Some("W"), "Window>Tools"), - ("tool.none", "No Tool", None, ""), - // brush size / feather of the active brush (Masking brush, Remove tool and its selected spot) - ("brush.smaller", "Decrease Brush Size", Some("["), "Window>Tools"), - ("brush.larger", "Increase Brush Size", Some("]"), "Window>Tools"), - ("brush.featherLess", "Decrease Brush Feather", Some("Shift+["), "Window>Tools"), - ("brush.featherMore", "Increase Brush Feather", Some("Shift+]"), "Window>Tools"), - ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), - ("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"), - ("dialog.smartAlbum", "New Smart Album…", None, "File"), - ("view.photoCounts", "Show Photo Counts", None, "View"), - ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), - ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), - ("tool.keywordPainter", "Keyword Painter", None, ""), - ("view.gridInfo", "Grid Info", None, ""), - ("dialog.allMetadata", "All Metadata…", None, "Photo"), - ("dialog.faceModel", "Add Face Model…", None, ""), - ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), - ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), - ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), - ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Edit"), - ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), - ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), - ("dialog.export", "Export…", None, "File"), - ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), - ("app.contactSheet", "Export Contact Sheet PDF", None, ""), - ("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"), - ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), - ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), - ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), - ("merge.hdrLast", "HDR with Last Settings", Some("Ctrl+Shift+H"), "Photo>Photo Merge"), - ("merge.panoramaLast", "Panorama with Last Settings", Some("Ctrl+Shift+M"), "Photo>Photo Merge"), - ("merge.hdrPanoramaLast", "HDR Panorama with Last Settings", None, "Photo>Photo Merge"), - ("file.addPhotos", "Import Photos…", Some("Cmd+Shift+I"), "File"), - ("file.addFolder", "Import from Folder…", None, "File"), - ("file.importLightroom", "Import Lightroom Catalog…", None, "File"), - ("file.addFromDevice", "Import from Device", None, ""), - ("file.findMissing", "Find Missing Photos…", None, "File"), - ("file.backupLibrary", "Back Up Library…", None, "File"), - ("file.restoreLibrary", "Restore Library from Backup…", None, "File"), - ("photo.locate", "Locate Missing File…", None, ""), - ("dialog.saveMetadataPreset", "Save Metadata Preset…", None, ""), - ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), - ("file.importPresets", "Import Profiles & Presets…", None, "File"), - ("file.exportPresets", "Export Presets…", None, "File"), - ("file.importKeywords", "Import Keywords…", None, "File"), - ("file.exportKeywords", "Export Keywords…", None, "File"), - // Edit panel ▸ Curve ▸ Point Curve dropdown - ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), - ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), - ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), - ("app.openLibrary", "Open Library…", None, "File"), - // Settings ▸ Display - ("app.displayProfile", "Display Profile", None, ""), - ("app.about", "About LightCraft", None, "Help"), - ("app.systemInfo", "System Info…", None, "Help"), - ("app.openLogFolder", "Open Log Folder", None, "Help"), - ("app.whatsNew", "What's New", None, "Help"), - ("dialog.cull", "Assisted Culling…", None, "Photo"), - ("app.help", "LightCraft Help", Some("F1"), "Help"), - ("app.discord", "Join the ArtCraft Discord…", None, "Help"), - ("app.feedback", "Send Feedback…", None, "Help"), - ("app.website", "LightCraft Website", None, "Help"), - ("app.github", "LightCraft on GitHub", None, "Help"), - ("app.artcraft", "ArtCraft Website", None, "Help"), - ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), - ("app.setShortcut", "Set Keyboard Shortcut", None, ""), - ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), - ("app.export", "Export Now", None, ""), - ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), - ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), - ("dialog.labelNames", "Edit Color Label Names…", None, ""), - ("dialog.captureTime", "Edit Capture Time…", None, "Photo"), - ("photo.tagFromTracklog", "Auto-Tag from Tracklog…", None, "Photo"), - ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), -]; - -fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { - if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { - app.ui.right = RightPanel::None; - app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); - } else { - app.ui.right = p; - app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); - if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - } - if p != RightPanel::Masking && app.ui.tool != "wbPicker" { - app.ui.tool.clear(); - } - let _ = app.session.end_interaction(); -} - -/// `[` / `]` (size ×`k`) and ⇧`[` / ⇧`]` (feather +`df`) for the brush in use: the Remove tool's -/// (and its selected spot's) or the Masking brush's. -fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { - if app.ui.right == RightPanel::Remove { - app.ui.remove_size = (app.ui.remove_size * k).clamp(0.001, 0.25); - app.ui.remove_feather = (app.ui.remove_feather + df).clamp(0.0, 100.0); - if app.session.active_spot.is_some() { - let mut p = json!({}); - if k != 1.0 { - p["size"] = json!(app.ui.remove_size); - } - if df != 0.0 { - p["feather"] = json!(app.ui.remove_feather); - } - let _ = app.run("spot.update", p); - } - json!({"size": app.ui.remove_size, "feather": app.ui.remove_feather}) - } else { - app.ui.brush_size = (app.ui.brush_size * k).clamp(0.002, 0.5); - app.ui.brush_feather = (app.ui.brush_feather + df).clamp(0.0, 100.0); - json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather}) - } -} - -/// The `parent` folder id of a `dialog.new*` command (none: the top level). -fn parent_param(p: &Value) -> Option<u64> { - p.get("parent").and_then(Value::as_u64) -} - -/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). -pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { - if let Some(s) = v.as_str() { - let h = s.strip_prefix('#').unwrap_or(s); - if h.len() != 6 { - return None; - } - let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); - return Some([c(0)?, c(2)?, c(4)?]); - } - let a = v.as_array()?; - let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); - Some([c(0)?, c(1)?, c(2)?]) -} - -/// Handle UI commands; `None` means "not a UI command — send it to the engine". -pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> { - if matches!(id, "ui.dock" | "ui.dock.reset") { - return Some(crate::docking::command(app, id == "ui.dock.reset", p)); - } - if matches!(id, "library.inspectLightroom" | "library.importLightroom") { - // the app's own context: a fresh one's repaints reach no window and its clock starts at zero - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - return Some(crate::lightroom_import::command(app, id, p, &ctx)); - } - if let Some(language) = language_from_command(id) { - app.ui.language = language; - // Immediately, not on the next frame: the reply and anything else run this frame - // (menus rebuilt from it, toasts) are already in the new language. - crate::i18n::set_language(app.ui.language); - return Some(Ok(json!(app.ui.language))); - } - // the app's own context: a toast's time comes from its clock (a fresh context's clock is at 0, - // and a toast set by it was long over by the app's clock: it never showed) - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - let r: Result<Value, String> = match id { - "view.photoGrid" => { - app.ui.view = ViewMode::PhotoGrid; - Ok(Value::Null) - } - "view.squareGrid" => { - app.ui.view = ViewMode::SquareGrid; - Ok(Value::Null) - } - "view.gridInfo" => { - // {info?: filename | exposure | date} (cycles when omitted) - let next = match p.get("info").and_then(Value::as_str) { - Some(i @ ("filename" | "exposure" | "date")) => i.to_string(), - Some(other) => return Some(Err(format!("view.gridInfo: unknown `{other}` (filename|exposure|date)"))), - None => match app.ui.grid_info.as_str() { - "filename" => "exposure".into(), - "exposure" => "date".into(), - _ => "filename".into(), - }, - }; - app.ui.grid_info = next; - app.ui.show_filenames = true; - Ok(json!({"info": app.ui.grid_info})) - } - "tool.keywordPainter" => { - // {keyword?}: paint that keyword onto photos in the grid by clicking them; no keyword stops - app.ui.keyword_painter = p.get("keyword").and_then(Value::as_str).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string); - if app.ui.keyword_painter.is_some() && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { - app.ui.view = ViewMode::PhotoGrid; - } - if let Some(k) = app.ui.keyword_painter.clone() { - app.toast(&ctx, crate::i18n::tr_format!("Painting “{k}”: click photos to add or remove it · Esc stops", k = k)); - } - Ok(json!({"keyword": app.ui.keyword_painter})) - } - "view.secondWindow" => { - app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); - Ok(json!({"show": app.ui.second_window})) - } - "view.photoCounts" => { - app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); - Ok(json!({"show": app.ui.show_counts})) - } - "view.gridToggle" => { - app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; - Ok(Value::Null) - } - "view.detail" => { - app.ui.view = ViewMode::Detail; - Ok(Value::Null) - } - "view.compare" => crate::panels::compare::enter_compare(app), - "view.faceBoxes" => { - app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); - Ok(json!({"show": app.ui.face_boxes})) - } - "view.people" => { - // Everyone; the page just left stays one click away (the chip next to the title) - if let Some(name) = app.ui.person_page.take() { - app.ui.last_person = Some(name); - } - app.ui.person_from = None; - app.ui.view = ViewMode::People; - Ok(json!({"people": app.session.catalog.people().len()})) - } - "view.person" => { - // {name}: one person's page in the People view: their faces, and the faces that look like them - let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { - return Some(Err("view.person: missing `name`".into())); - }; - app.ui.view = ViewMode::People; - app.ui.person_from = None; - app.ui.person_page = Some(name.to_string()); - Ok(json!({"person": name})) - } - "view.survey" => { - app.ui.view = ViewMode::Survey; - Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) - } - "view.reference" => { - // the reference: the one set before, else the active photo (the next one becomes active) - let vis = app.session.visible_cloned(); - let reference = app - .ui - .reference - .filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some()) - .or_else(|| app.session.active().map(|a| a.0)); - let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) }; - app.ui.reference = Some(r); - if app.session.active().is_none_or(|a| a.0 == r) - && let Some(i) = vis.iter().position(|x| x.0 == r) - && let Some(next) = vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))) - { - let _ = app.session.execute("library.select", &json!({"ids": [next.0]})); - } - app.ui.view = ViewMode::Reference; - Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)})) - } - "photo.setReference" => { - let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0)); - app.ui.reference = id; - Ok(json!({"reference": id})) - } - "compare.swap" => crate::panels::compare::swap(app), - "compare.makeSelect" => crate::panels::compare::make_select(app), - "view.autoAdvance" => { - app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); - Ok(json!({"autoAdvance": app.ui.auto_advance})) - } - "view.back" => { - if app.ui.dialog.is_some() { - app.ui.dialog = None; - } else if app.ui.keyword_painter.is_some() { - app.ui.keyword_painter = None; - } else if app.ui.fullscreen { - app.ui.fullscreen = false; - app.ui.slideshow = None; - } else if !app.ui.tool.is_empty() { - app.ui.tool.clear(); - } else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() { - app.ui.person_page = None; - } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { - app.ui.view = ViewMode::Detail; - } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page goes back to that page, any other to the grid - match app.ui.person_from.take() { - Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { - app.ui.view = ViewMode::People; - app.ui.person_page = Some(name); - } - _ => app.ui.view = ViewMode::PhotoGrid, - } - } - Ok(Value::Null) - } - "view.slideshow" => { - // {interval?: seconds (4)}: the photos in view, full screen, one after another - let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0); - if app.session.active().is_none() { - let first = app.session.visible_cloned().first().copied(); - match first { - Some(f) => { - let _ = app.run("library.select", json!({"ids": [f.0]})); - } - None => return Some(Err("no photos to show".into())), - } - } - let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, false)); - app.ui.fullscreen = true; - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); - Ok(json!({"interval": interval})) - } - "view.fullScreenPreview" => { - app.ui.fullscreen = !app.ui.fullscreen; - if !app.ui.fullscreen { - app.ui.slideshow = None; - } - if app.ui.fullscreen { - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - } - Ok(json!({"fullscreen": app.ui.fullscreen})) - } - "view.enterFullScreen" => { - // applied by the host's frame logic (it knows the window's current state) - let on = p.get("on").and_then(Value::as_bool); - app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); - Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) - } - "view.infoOverlay" => { - app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { - Some(m) => match serde_json::from_value(json!(m)) { - Ok(v) => v, - Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), - }, - None => app.ui.info_overlay.next(), - }; - let label = match app.ui.info_overlay { - crate::state::InfoOverlay::Off => "Info Overlay Off", - crate::state::InfoOverlay::Basic => "Info Overlay: File", - crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", - }; - app.toast(&ctx, label); - Ok(json!({"infoOverlay": app.ui.info_overlay})) - } - "view.navigator" => { - app.ui.navigator = !app.ui.navigator; - Ok(json!({"navigator": app.ui.navigator})) - } - "app.settings" => { - let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); - if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { - let tabs: Vec<&str> = crate::panels::settings::TABS.iter().map(|(id, _)| *id).collect(); - return Some(Err(format!("unknown settings tab `{tab}` ({})", tabs.join("|")))); - } - app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); - Ok(Value::Null) - } - "app.openLibrary" => crate::panels::settings::open_library(app, p), - "app.displayProfile" => crate::panels::settings::display_profile(app, p), - "file.backupLibrary" | "file.restoreLibrary" => { - let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; - match action { - Some(f) => f(&mut app.session), - None => Err("not available here: on the desktop the library is a folder; back it up with your other files".into()), - } - } - "view.filmstrip" => { - app.ui.filmstrip = !app.ui.filmstrip; - Ok(Value::Null) - } - "view.leftPanel" => { - app.ui.left_panel = !app.ui.left_panel; - Ok(Value::Null) - } - "view.beforeAfter" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; - Ok(Value::Null) - } - "view.beforeAfterSplit" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::Split { BeforeAfter::Off } else { BeforeAfter::Split }; - Ok(Value::Null) - } - "view.beforeAfterTopBottom" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::TopBottom { BeforeAfter::Off } else { BeforeAfter::TopBottom }; - Ok(Value::Null) - } - "view.beforeAfterSplitTopBottom" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::SplitTopBottom { BeforeAfter::Off } else { BeforeAfter::SplitTopBottom }; - Ok(Value::Null) - } - "view.showOriginal" => { - app.ui.before_after = if app.ui.before_after == BeforeAfter::Original { BeforeAfter::Off } else { BeforeAfter::Original }; - Ok(Value::Null) - } - "view.zoomFit" => { - app.ui.zoom = Zoom::Fit; - Ok(Value::Null) - } - "view.zoom100" => { - app.ui.zoom = Zoom::Percent(100.0); - Ok(Value::Null) - } - "view.zoomToggle" => { - // the same ratio a click on the photo zooms to - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; - app.ui.zoom_anim = true; - Ok(Value::Null) - } - "view.clickZoom" => { - // {ratio?: 1|2|3|4|8} → {ratio} - if let Some(r) = p.get("ratio").and_then(Value::as_f64) { - let pct = (r * 100.0).round() as u32; - if !crate::state::CLICK_ZOOMS.contains(&pct) { - return Some(Err(format!("view.clickZoom: ratio {r} (1, 2, 3, 4 or 8)"))); - } - app.ui.click_zoom = pct; - } - Ok(json!({"ratio": app.ui.click_zoom / 100})) - } - "view.zoomIn" | "view.zoomOut" => { - let steps = [25.0, 50.0, 100.0, 200.0, 400.0, 800.0]; - let cur = match app.ui.zoom { - Zoom::Percent(p) => p, - _ => 25.0, - }; - let next = if id == "view.zoomIn" { - steps.iter().find(|s| **s > cur).copied().unwrap_or(800.0) - } else { - steps.iter().rev().find(|s| **s < cur).copied().unwrap_or(0.0) - }; - app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; - Ok(Value::Null) - } - "view.navigate" => { - // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). - // Validate the complete request before changing either part of the viewport. - let zoom = match p.get("zoom") { - Some(v) => match serde_json::from_value::<Zoom>(v.clone()) { - Ok(Zoom::Percent(p)) if !p.is_finite() || p <= 0.0 || p > 800.0 => { - return Some(Err("view.navigate: zoom percent must be greater than 0 and at most 800".into())); - } - Ok(z) => z, - Err(e) => return Some(Err(format!("view.navigate: {e}"))), - }, - None => app.ui.zoom, - }; - let pan = match p.get("pan") { - Some(v) => match serde_json::from_value::<(f32, f32)>(v.clone()) { - Ok((x, y)) if x.is_finite() && y.is_finite() && (0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y) => (x, y), - _ => return Some(Err("view.navigate: pan must be [x, y] with finite coordinates from 0 to 1".into())), - }, - None => app.ui.pan, - }; - app.ui.zoom = zoom; - app.ui.pan = pan; - app.ui.zoom_anim = false; - Ok(json!({"zoom": zoom, "pan": pan})) - } - "view.clipping" => { - app.ui.show_clipping = !app.ui.show_clipping; - Ok(Value::Null) - } - "view.visualizeHdr" => { - // {on?}; no params toggles. Shown on photos edited in HDR (see `develop.hdr`). - app.ui.hdr_visualize = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.hdr_visualize); - Ok(json!({"on": app.ui.hdr_visualize})) - } - "view.softProof" => { - // {on?, space?, destWarning?, displayWarning?}; no params toggles - let has = |k: &str| p.get(k).is_some(); - if let Some(s) = p.get("space").and_then(Value::as_str) { - match lightcraft_engine::pipeline::OutputSpace::parse(s) { - Some(sp) => app.ui.proof.space = sp, - None => return Some(Err(format!("view.softProof: unknown space {s:?} (srgb|displayP3|adobeRgb|proPhoto|rec2020)"))), - } - } - if let Some(b) = p.get("destWarning").and_then(Value::as_bool) { - app.ui.proof.dest_warning = b; - } - if let Some(b) = p.get("displayWarning").and_then(Value::as_bool) { - app.ui.proof.display_warning = b; - } - app.ui.soft_proof = match p.get("on").and_then(Value::as_bool) { - Some(b) => b, - None if has("space") || has("destWarning") || has("displayWarning") => app.ui.soft_proof, - None => !app.ui.soft_proof, - }; - if app.ui.soft_proof && !matches!(app.ui.view, ViewMode::Detail | ViewMode::Reference) { - app.ui.view = ViewMode::Detail; - } - let pr = app.ui.proof; - Ok(json!({"on": app.ui.soft_proof, "space": pr.space, "destWarning": pr.dest_warning, "displayWarning": pr.display_warning})) - } - "view.histogram" => { - app.ui.histogram = !app.ui.histogram; - Ok(Value::Null) - } - "view.maskOverlay" => { - app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); - Ok(json!({"maskOverlay": app.ui.mask_overlay})) - } - "view.maskOverlayMode" => { - use lightcraft_engine::pipeline::MaskView; - let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); - let next = match p.get("mode").and_then(Value::as_str) { - Some(m) => match MaskView::parse(m) { - Some(v) => v, - None => { - let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); - return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); - } - }, - None => cur.next(), - }; - app.ui.mask_overlay_mode = next.name().into(); - app.ui.mask_overlay = true; - app.toast(&ctx, next.label()); - Ok(json!({"mode": next.name()})) - } - "view.maskOverlayColor" => { - // no params: the next of the panel's swatch colours - if p.get("color").is_none() && p.get("opacity").is_none() { - let all = crate::panels::masking::OVERLAY_COLORS; - let i = all.iter().position(|c| *c == app.ui.mask_overlay_color).map_or(0, |i| (i + 1) % all.len()); - app.ui.mask_overlay_color = all[i]; - } - if let Some(c) = p.get("color") { - match parse_rgb(c) { - Some(rgb) => app.ui.mask_overlay_color = rgb, - None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), - } - } - if let Some(o) = p.get("opacity").and_then(Value::as_f64) { - app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; - } - let [r, g, b] = app.ui.mask_overlay_color; - Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) - } - "brush.smaller" | "brush.larger" | "brush.featherLess" | "brush.featherMore" => { - let (k, df) = match id { - "brush.smaller" => (1.0 / 1.2, 0.0), - "brush.larger" => (1.2, 0.0), - "brush.featherLess" => (1.0, -10.0), - _ => (1.0, 10.0), - }; - Ok(adjust_brush(app, k, df)) - } - "view.maskPins" => { - app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); - Ok(json!({"maskPins": app.ui.mask_pins})) - } - "view.visualizeSpots" => { - // like Lightroom's A: opens the Remove tool with the view on, or toggles it there - if app.ui.right == RightPanel::Remove { - app.ui.visualize_spots = !app.ui.visualize_spots; - } else { - app.ui.right = RightPanel::Remove; - app.ui.visualize_spots = true; - } - Ok(Value::Null) - } - "view.cropOverlay" => { - use crate::state::CropOverlay::*; - app.ui.crop_overlay = match app.ui.crop_overlay { - Thirds => Grid, - Grid => Golden, - Golden => Diagonal, - Diagonal => Triangle, - Triangle => Spiral, - Spiral => None, - None => Thirds, - }; - Ok(json!({"overlay": app.ui.crop_overlay})) - } - "view.cropOverlayOrientation" => { - app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4; - Ok(json!({"orientation": app.ui.crop_overlay_orient})) - } - "local.addRoot" => { - // a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes - let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) }; - let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into()); - let abs = if abs.is_empty() { path.to_string() } else { abs }; - if !std::path::Path::new(&abs).is_dir() { - return Some(Err(format!("{path}: not a folder"))); - } - use crate::panels::left::same_folder; - if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) { - app.ui.local_roots.push(abs.clone()); - } - app.ui.hidden_locations.retain(|h| !same_folder(h, &abs)); - Ok(json!({"roots": app.ui.local_roots})) - } - "local.hide" => match p.get("path").and_then(Value::as_str) { - Some(path) => { - if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) { - app.ui.hidden_locations.push(path.to_string()); - } - Ok(json!({"hidden": app.ui.hidden_locations})) - } - None => Err("local.hide needs a path".into()), - }, - "local.restoreHidden" => { - // one path, or (no path) every hidden location - match p.get("path").and_then(Value::as_str) { - Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)), - None => app.ui.hidden_locations.clear(), - } - Ok(json!({"hidden": app.ui.hidden_locations})) - } - "view.filterBar" => { - app.ui.filter_bar = !app.ui.filter_bar; - if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { - app.ui.view = ViewMode::PhotoGrid; - } - Ok(json!({"filterBar": app.ui.filter_bar})) - } - "panel.edit" => { - panel(app, &ctx, RightPanel::Edit, "Edit"); - Ok(Value::Null) - } - "panel.profiles" => { - // toggles between the profile browser and the Edit panel it belongs to - app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { - RightPanel::Edit - } else { - RightPanel::Profiles - }; - if !matches!(app.ui.view, ViewMode::Detail) { - app.ui.view = ViewMode::Detail; - } - Ok(json!({"open": app.ui.right == RightPanel::Profiles})) - } - "panel.crop" => { - panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); - Ok(Value::Null) - } - "panel.remove" => { - panel(app, &ctx, RightPanel::Remove, "Remove"); - if app.ui.right == RightPanel::Remove && app.ui.tool.is_empty() { - app.ui.tool = "remove".into(); - } - Ok(Value::Null) - } - "panel.masking" => { - panel(app, &ctx, RightPanel::Masking, "Masking"); - Ok(Value::Null) - } - "panel.redeye" => { - panel(app, &ctx, RightPanel::RedEye, "Red Eye"); - Ok(Value::Null) - } - "panel.info" => { - panel(app, &ctx, RightPanel::Info, "Info"); - Ok(Value::Null) - } - "panel.keywords" => { - panel(app, &ctx, RightPanel::Keywords, "Keywords"); - Ok(Value::Null) - } - "panel.versions" => { - panel(app, &ctx, RightPanel::Versions, "Versions"); - Ok(Value::Null) - } - "panel.activity" => { - panel(app, &ctx, RightPanel::Activity, "History"); - Ok(Value::Null) - } - "panel.presets" => { - app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); - if app.ui.presets && app.ui.view != ViewMode::Detail { - app.ui.view = ViewMode::Detail; - } - Ok(Value::Null) - } - "tool.done" => { - // Return commits a tool panel (crop, remove, red eye, masking): back to Edit - use RightPanel::*; - if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { - let _ = app.session.end_interaction(); - app.ui.tool.clear(); - app.ui.right = Edit; - } - Ok(Value::Null) - } - "panel.close" => { - app.ui.right = RightPanel::None; - app.ui.presets = false; - Ok(Value::Null) - } - s if s.starts_with("section.") => { - let sec = &s["section.".len()..]; - if app.ui.right != RightPanel::Edit { - app.ui.right = RightPanel::Edit; - } - app.ui.toggle_section(sec); - Ok(Value::Null) - } - s if s.starts_with("tool.") => { - let tool = &s["tool.".len()..]; - match tool { - "none" => app.ui.tool.clear(), - "guidedUpright" => { - // Crop & Geometry with Guided Upright on, ready to draw guides - app.ui.right = RightPanel::Crop; - app.ui.view = ViewMode::Detail; - let guided = app - .session - .active() - .and_then(|id| app.session.develop_of(id)) - .is_some_and(|d| d.geometry.upright == lightcraft_develop::Upright::Guided); - if !guided && let Err(e) = app.run("geometry.upright", json!({"mode": "guided"})) { - return Some(Err(e)); - } - app.ui.tool = "guidedUpright".into(); - } - "brush" => { - // A new mask and Add/Subtract are selection operations, not the brush's - // Erase mode. Paint positively into a component with the requested op. - let create = if p.get("new").and_then(Value::as_bool) == Some(true) { - Some(app.run("mask.add", json!({"kind": "brush"}))) - } else { - p.get("op").map(|op| app.run("mask.addComponent", json!({"kind": "brush", "op": op}))) - }; - if let Some(result) = create { - if let Err(e) = result { - return Some(Err(e)); - } - app.ui.brush_erase = false; - app.ui.mask_overlay = true; - } - app.ui.right = RightPanel::Masking; - app.ui.view = ViewMode::Detail; - app.ui.tool = "brush".into(); - } - "linear" | "radial" => { - app.ui.right = RightPanel::Masking; - app.ui.view = ViewMode::Detail; - app.ui.tool = tool.into(); - return Some(app.session.execute("mask.add", &json!({"kind": tool})).map_err(|e| e.to_string())); - } - "wbPicker" => { - app.ui.right = RightPanel::Edit; - app.ui.view = ViewMode::Detail; - app.ui.tool = "wbPicker".into(); - } - other => return Some(Err(format!("unknown tool `{other}`"))), - } - Ok(Value::Null) - } - "dialog.newFolder" => { - app.ui.dialog = - Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) }); - Ok(Value::Null) - } - "dialog.newAlbum" => { - app.ui.dialog = - Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) }); - Ok(Value::Null) - } - "dialog.autoStack" => { - app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); - Ok(Value::Null) - } - "dialog.allMetadata" => { - let r = match app.session.execute("photo.allMetadata", p) { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - let title = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.file_name.clone()).unwrap_or_default(); - app.ui.dialog = Some(Dialog::AllMetadata { title, rows: r, search: String::new() }); - Ok(Value::Null) - } - "dialog.smartAlbum" => { - // {id?}: edit that smart album's rules; without: a new one starting at Rating ≥ 3 - let album = p - .get("id") - .and_then(Value::as_u64) - .and_then(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(id)).filter(|a| a.is_smart()).cloned()); - app.ui.dialog = Some(match album { - Some(a) => Dialog::SmartRules { - id: Some(a.id.0), - name: a.name.clone(), - // older rules read as they always matched (RuleSet::upgrade) - rules: a - .smart - .as_ref() - .and_then(|f| f.rule_set.clone()) - .map(|mut r| { - r.upgrade(); - r - }) - .unwrap_or_default(), - parent: None, - }, - None => Dialog::SmartRules { - id: None, - name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: parent_param(p), - }, - }); - Ok(Value::Null) - } - "dialog.newSmartAlbum" => { - app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); - Ok(Value::Null) - } - "dialog.captureTime" => { - let time = app.session.active().and_then(|id| app.session.catalog.photo(id)).map(|p| p.date().replace('T', " ")).unwrap_or_default(); - let mode = p.get("mode").and_then(Value::as_str).unwrap_or("set").to_string(); - app.ui.dialog = - Some(Dialog::CaptureTime { mode, time: time.get(..19).unwrap_or(&time).to_string(), days: 0, hours: 0, minutes: 0, zone: 0.0 }); - Ok(Value::Null) - } - "dialog.labelNames" => { - let names = - lightcraft_catalog::ColorLabel::ALL.iter().map(|l| app.session.catalog.custom_label_name(*l).unwrap_or("").to_string()).collect(); - app.ui.dialog = Some(Dialog::LabelNames { names, save_as: String::new() }); - Ok(Value::Null) - } - "dialog.rename" => { - let template = p.get("template").and_then(Value::as_str).unwrap_or("{name}").to_string(); - app.ui.dialog = Some(Dialog::Rename { template, start: p.get("start").and_then(Value::as_u64).unwrap_or(1) as u32 }); - Ok(Value::Null) - } - "dialog.createPreset" => { - app.ui.dialog = Some(Dialog::create_preset()); - Ok(Value::Null) - } - "dialog.pasteSettings" => { - let groups = - app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); - app.ui.dialog = Some(Dialog::PasteSettings { groups }); - Ok(Value::Null) - } - "view.focusSearch" => { - // the search field lives in the top bar of the library and detail views alike - app.ui.focus_search = true; - Ok(Value::Null) - } - "dialog.copySettings" => { - let groups = - app.session.copy_groups.iter().filter_map(|g| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string))).collect(); - app.ui.dialog = Some(Dialog::CopySettings { groups }); - Ok(Value::Null) - } - "dialog.contactSheet" => { - app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); - Ok(Value::Null) - } - "app.contactSheet" => { - if app.export.is_some() { - return Some(Err("an export is already running".into())); - } - let mut params = p.clone(); - if p.get("path").is_none() { - let req = PickRequest::save("Contact Sheet PDF", "PDF", &["pdf"], "Contact Sheet.pdf"); - let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), - }; - let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; - params["path"] = json!(path); - } - crate::export_task::start_contact_sheet(app, ¶ms) - } - "dialog.export" => { - let prev = app.session.last_export.clone().unwrap_or_default(); - let u = |k: &str, d: u64| prev.get(k).and_then(Value::as_u64).unwrap_or(d); - let dir = prev.get("dir").and_then(Value::as_str).map(str::to_string).unwrap_or_else(crate::control::default_export_dir); - let opts = lightcraft_engine::export::ExportOptions::from_json(&prev); - // no previous export: 2048 px long edge; a previous full-size export: full size - let full_size = opts.resize.is_none() && lightcraft_engine::export::ExportOptions::has_size_param(&prev); - let resize = opts.resize.unwrap_or_default(); - app.ui.dialog = Some(Dialog::Export { opts, full_size, resize, preset_name: String::new(), limit_kb: u("limitKb", 0) as u32, dir }); - Ok(Value::Null) - } - "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), - "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), - "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), - "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), - "dialog.faceModel" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => app.services.pick_model_file.as_mut().and_then(|f| f().into_iter().next()), - }; - match path { - Some(path) => crate::panels::faces::open_dialog(app, &path), - None => Ok(Value::Null), - } - } - "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), - "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), - "app.about" => { - app.ui.dialog = Some(Dialog::About); - Ok(Value::Null) - } - "photo.editInExternal" => { - // render an edit copy (stacked on the original), then open it in the editor - let mut params = p.clone(); - if !params.is_object() { - params = json!({}); - } - let r = match app.session.execute("photo.editExternal", ¶ms) { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - let path = r["path"].as_str().unwrap_or_default().to_string(); - if let Some(id) = r["id"].as_u64() { - app.ui.external_edits.push(id); - } - let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone()); - if let Some(f) = app.services.open_with.as_mut() - && let Err(e) = f(&path, &editor) - { - app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); - } - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(&ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); - Ok(r) - } - "dialog.cull" => { - app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); - Ok(Value::Null) - } - "app.whatsNew" => { - app.ui.dialog = Some(Dialog::WhatsNew); - Ok(json!({"text": crate::panels::dialogs::WHATS_NEW})) - } - "app.systemInfo" => { - let info = app.session.execute("library.info", &json!({})).unwrap_or_default(); - let gpu = (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(); - let mb = |b: u64| format!("{:.0} MB", b as f64 / (1u64 << 20) as f64); - let mut rows = vec![ - ("Version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ("System".to_string(), format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH)), - ("CPU threads".to_string(), std::thread::available_parallelism().map(|n| n.get().to_string()).unwrap_or_else(|_| "?".into())), - ( - "GPU".to_string(), - gpu.unwrap_or_else(|| match lightcraft_engine::gpu::unavailable_reason() { - Some(why) => crate::i18n::tr_format!("none (CPU rendering): {why}", why = why), - None => "none (CPU rendering)".into(), - }), - ), - ("GPU rendering".to_string(), if app.ui.settings.gpu { "on".into() } else { "off".into() }), - ("Memory budget".to_string(), mb(lightcraft_engine::memory::default_budget() as u64)), - ( - "Preview size".to_string(), - match app.ui.settings.preview_limit { - 0 => "automatic".to_string(), - n => format!("{n} px"), - }, - ), - ("Photos".to_string(), info["photos"].to_string()), - ("Albums".to_string(), info["albums"].to_string()), - ]; - if let Some(dir) = info["dir"].as_str().or(info["path"].as_str()) { - rows.push(("Library".into(), dir.to_string())); - } - rows.push(("Frame time".into(), format!("{:.1} ms ({:.0} fps)", app.perf.frame_ms, app.perf.fps))); - rows.push(( - "Frame update".into(), - format!("{:.1} ms (logic {:.1} ms; slowest {:.0} ms)", app.perf.update_ms, app.perf.logic_ms, app.perf.max_update_ms), - )); - rows.push(("Last loupe render".into(), format!("{:.0} ms", app.renderer.last_main_ms))); - if let Some(f) = lightcraft_engine::gpu::last_fallback() { - rows.push(("Last GPU fallback".into(), f)); - } - let r = json!(rows.iter().map(|(k, v)| json!({"label": k, "value": v})).collect::<Vec<_>>()); - if p.get("open").and_then(Value::as_bool).unwrap_or(true) { - app.ui.dialog = Some(Dialog::SystemInfo { rows }); - } - Ok(r) - } - "app.shortcuts" => { - app.ui.dialog = Some(Dialog::Shortcuts); - Ok(Value::Null) - } - "app.setShortcut" => crate::shortcuts::set_shortcut(app, p), - "app.resetShortcuts" => { - app.ui.settings.keymap.clear(); - Ok(Value::Null) - } - "library.browse" if !cfg!(target_arch = "wasm32") => { - if crate::lightroom_import::is_running(app) { - return Some(Err("finish Lightroom catalog import before browsing folders".into())); - } - // listed and read in the background (see `import::browse`) - let path = p.get("path").and_then(Value::as_str)?; - crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) - } - "file.addPhotos" => { - if crate::lightroom_import::is_running(app) { - return Some(Err("finish Lightroom catalog import before adding photos".into())); - } - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // review first: the import dialog lists what was found - crate::import::open(app, paths) - } - "file.importLightroom" => { - let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { - Some(path) => Some(path), - None => { - let req = - PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => app.run("library.importLightroom", json!({"path":path})), - None => Ok(Value::Null), - } - } - "app.quit" => { - app.ui.quit = true; - Ok(Value::Null) - } - "dialog.saveMetadataPreset" => { - // from the active photo's copyright, creator and place - crate::panels::dialogs::prompt(app, "Save Metadata Preset", "Preset name", "", "metadata.savePreset", json!({}), "name"); - Ok(Value::Null) - } - "file.findMissing" => { - let folder = match p.get("folder").and_then(Value::as_str) { - Some(f) => Some(f.to_string()), - None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - let Some(folder) = folder else { return Some(Ok(Value::Null)) }; - // the search (checking every photo's file, walking the folder) runs on a worker thread; - // the relinking happens back here, as one undo step - const LABEL: &str = "Find Missing Photos"; - if app.tasks.is_running(LABEL) { - return Some(Err("Find Missing Photos is already searching".into())); - } - let candidates = lightcraft_engine::cmd::missing::find_candidates(&app.session.catalog); - let work = move || lightcraft_engine::cmd::missing::plan_find_missing(&candidates, &folder); - let done = |app: &mut LightcraftApp, ctx: &egui::Context, plan: Result<lightcraft_engine::cmd::missing::FindPlan, String>| { - // relinked under the session as it is now: photos relinked meanwhile and files - // now in use are skipped - let r = plan.and_then(|plan| app.run("library.findMissing", plan.to_json())); - match r { - Ok(v) => { - let n = v["found"].as_array().map_or(0, Vec::len); - let left = v["missing"].as_u64().unwrap_or(0); - let unsure = v["ambiguous"].as_array().map_or(0, Vec::len); - let unsure = if unsure > 0 { - crate::i18n::tr_format!(" ({unsure} with several look-alike files: use Locate)", unsure = unsure) - } else { - String::new() - }; - app.toast( - ctx, - crate::i18n::tr_format!( - "Found {n} missing photo{}; {left} still missing{unsure}", - if n == 1 { "" } else { "s" }, - left = left, - n = n, - unsure = unsure - ), - ); - app.ui.last_find_missing = Some(v); - } - Err(e) => app.toast(ctx, e), - } - }; - app.ui.last_find_missing = None; - if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) { - return Some(Err(e)); - } - if p.get("wait").and_then(Value::as_bool).unwrap_or(false) { - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - crate::tasks::wait(app, &ctx, std::time::Duration::from_secs(600)); - return Some(Ok(app.ui.last_find_missing.clone().unwrap_or(Value::Null))); - } - Ok(json!({"background": true})) - } - "photo.tagFromTracklog" => { - // a GPX file → GPS for the selected photos by capture time (one undo step) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = p.as_object().cloned().unwrap_or_default(); - params.insert("path".into(), json!(path)); - let ask_zone = !params.contains_key("offset"); - let params = Value::Object(params); - if ask_zone { - // GPX times are UTC, camera clocks are local: ask for the camera's zone, then come back here - crate::panels::dialogs::prompt( - app, - "Auto-Tag from Tracklog", - "Camera time zone, e.g. -07:00 (empty: UTC)", - "", - "photo.tagFromTracklog", - params, - "offset", - ); - return Some(Ok(Value::Null)); - } - let r = app.run("photo.autoTagTracklog", params); - if let Ok(v) = &r { - let n = v["tagged"].as_u64().unwrap_or(0); - let sk = &v["skipped"]; - let mut msg = crate::i18n::tr_format!("Tagged {n} photo{} from the tracklog", if n == 1 { "" } else { "s" }, n = n); - let outside = sk["outside"].as_u64().unwrap_or(0); - if outside > 0 { - msg += &crate::i18n::tr_format!("; {outside} outside its time range", outside = outside); - } - let kept = sk["hasGps"].as_u64().unwrap_or(0); - if kept > 0 { - msg += &crate::i18n::tr_format!("; {kept} already had a location", kept = kept); - } - let ctx = app.tasks.repaint.clone().unwrap_or_default(); - app.toast(&ctx, msg); - } - r - } - "photo.locate" => { - let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) }; - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = - PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); - match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - } - } - }; - match path { - Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), - None => Ok(Value::Null), - } - } - "file.addFromDevice" => { - // a camera / card: review its DCIM folder, copying into the library by default - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => match lightcraft_engine::devices::devices_now().into_iter().next() { - Some(d) => d.path, - None => return Some(Err("no camera or memory card found".into())), - }, - }; - let r = crate::import::open(app, vec![path]); - // only the scan just started (an error means another scan is running) - if r.is_ok() - && let Some(t) = &mut app.scan - { - t.copy = true; - } - r - } - "file.addFolder" => { - // a folder (searched recursively) into the import review - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| { - s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later | Picked::Unavailable => None, - }, - }; - match path { - Some(path) => crate::import::open(app, vec![path]), - None => Ok(Value::Null), - } - } - "file.importPresets" => { - let paths = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Presets").into()), - crate::i18n::tr("Presets & Profiles"), - crate::pick::PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - // .cube LUTs (and those inside zips / folders) become profiles - let lut_paths: Vec<&String> = paths - .iter() - .filter(|p| { - let l = p.to_ascii_lowercase(); - l.ends_with(".cube") || l.ends_with(".zip") || std::path::Path::new(p.as_str()).is_dir() - }) - .collect(); - let profiles = if lut_paths.is_empty() { - 0 - } else { - app.session - .execute("profile.import", &json!({"paths": lut_paths})) - .ok() - .and_then(|v| v["imported"].as_array().map(Vec::len)) - .unwrap_or(0) - }; - let preset_paths: Vec<&String> = paths.iter().filter(|p| !p.to_ascii_lowercase().ends_with(".cube")).collect(); - let r = if preset_paths.is_empty() { - Ok(json!({"imported": [], "failed": [], "skipped": 0})) - } else { - app.session.execute("preset.import", &json!({"paths": preset_paths})).map_err(|e| e.to_string()) - }; - if profiles > 0 { - app.toast( - &ctx, - crate::i18n::tr_format!( - "Imported {profiles} profile{} (Profile browser ▸ their groups)", - if profiles == 1 { "" } else { "s" }, - profiles = profiles - ), - ); - } - if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = match (n, failed) { - (0, 0) => "No new presets".to_string(), - (n, 0) => crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n), - (n, f) => crate::i18n::tr_format!( - "Imported {n} preset{}, {f} file{} not readable", - if n == 1 { "" } else { "s" }, - if f == 1 { "" } else { "s" }, - f = f, - n = n - ), - }; - // settings with no counterpart here (the other editor's profiles, masks…) - let mut skipped: Vec<&str> = v["imported"] - .as_array() - .into_iter() - .flatten() - .flat_map(|i| i["unmapped"].as_array().into_iter().flatten()) - .filter_map(Value::as_str) - .collect(); - skipped.sort_unstable(); - skipped.dedup(); - if !skipped.is_empty() { - let names: Vec<&str> = skipped.iter().take(3).copied().collect(); - msg += &crate::i18n::tr_format!(" — not carried over: {}{}", names.join(", "), if skipped.len() > 3 { "…" } else { "" }); - } - app.toast(&ctx, msg); - if n > 0 { - app.ui.presets = true; - } - } - return Some(r); - } - "file.importKeywords" => { - // a keyword list file: Lightroom Classic's, Capture One's, Photo Supreme's (.utf8) - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let req = PickRequest::file(crate::i18n::tr("Import Keywords"), crate::i18n::tr("Keyword Lists"), &["txt", "utf8"]); - match crate::pick::ask(app, id, p, "path", req, |s| s.pick_keyword_list.as_mut().map(|f| f())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.import", &json!({"path": path})).map_err(|e| e.to_string()); - match &r { - Ok(v) => { - let (added, updated) = (v["added"].as_u64().unwrap_or(0), v["updated"].as_u64().unwrap_or(0)); - let mut msg = crate::i18n::tr_format!("Added {n} keyword{}", if added == 1 { "" } else { "s" }, n = added); - if updated > 0 { - msg.push_str(&crate::i18n::tr_format!("; {n} gained synonyms", n = updated)); - } - app.toast(&ctx, msg); - } - Err(e) => app.toast(&ctx, e.clone()), - } - return Some(r); - } - "file.exportKeywords" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => x.to_string(), - None => { - let name = "Keywords.txt"; - let req = PickRequest::save(crate::i18n::tr("Export Keywords"), crate::i18n::tr("Keyword Lists"), &["txt"], name); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_keyword_list.as_mut().map(|f| f(name).into_iter().collect())) { - Picked::Now(v) => match v.into_iter().next() { - Some(x) => x, - None => return Some(Ok(Value::Null)), - }, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let r = app.session.execute("keyword.export", &json!({"path": path})).map_err(|e| e.to_string()); - if let Err(e) = &r { - app.toast(&ctx, e.clone()); - } - if let Ok(v) = &r { - let n = v["keywords"].as_u64().unwrap_or(0); - let mut msg = crate::i18n::tr_format!("Exported {n} keyword{}", if n == 1 { "" } else { "s" }, n = n); - // Capture One's importer refuses ; , < > in a list: say which to rename first - let refuses: Vec<&str> = v["captureOneRefuses"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !refuses.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — Capture One won't import a list with ; , < or > in a keyword: {names}", - names = refuses.iter().take(5).copied().collect::<Vec<_>>().join(" · ") - )); - } - // what the format can't hold (a name in brackets, a line break…) isn't in the file - let left: Vec<&str> = v["unwritable"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - if !left.is_empty() { - msg.push_str(&crate::i18n::tr_format!( - " — left out, as a keyword list can't hold them: {names}", - // escaped: a line break in a name would break the toast - names = left.iter().take(5).map(|n| n.escape_debug().to_string()).collect::<Vec<_>>().join(" · ") - )); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "file.exportPresets" => { - let group = p.get("group").and_then(Value::as_str).map(str::to_string); - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); - let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone()); - match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(g) = group { - params["group"] = json!(g); - } - if let Some(ids) = p.get("ids") { - params["ids"] = ids.clone(); - } - let r = app.session.execute("preset.export", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); - } - "file.importCurvePresets" => { - let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) { - Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => { - let req = PickRequest::files( - Some(crate::i18n::tr("Import Point Curve Presets").into()), - crate::i18n::tr("Point Curve Presets"), - crate::pick::CURVE_PRESET_EXTENSIONS, - ); - match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { - Picked::Now(paths) => paths, - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - if paths.is_empty() { - return Some(Ok(Value::Null)); - } - let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string()); - if let Ok(v) = &r { - let n = v["imported"].as_array().map_or(0, Vec::len); - let failed = v["failed"].as_array().map_or(0, Vec::len); - let mut msg = crate::i18n::tr_format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" }, n = n); - if failed > 0 { - msg += &crate::i18n::tr_format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" }, failed = failed); - } - app.toast(&ctx, msg); - } - return Some(r); - } - "file.exportCurvePresets" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(x) => Some(x.to_string()), - None => { - let req = PickRequest::save( - crate::i18n::tr("Export Point Curve Presets"), - crate::i18n::tr("Point Curve Presets"), - &["lccurve"], - "Point Curves.lccurve", - ); - match crate::pick::ask(app, id, p, "path", req, |s| { - s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) - }) { - Picked::Now(v) => v.into_iter().next(), - Picked::Later => return Some(Ok(Value::Null)), - Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), - } - } - }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; - let mut params = json!({"path": path}); - if let Some(n) = p.get("names") { - params["names"] = n.clone(); - } - let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.toast(&ctx, crate::i18n::tr_format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })); - } - return Some(r); - } - "app.export" => crate::control::export_active(app, p), - "app.showInFinder" => show_in_finder(app), - "app.openLogFolder" => open_log_folder(app), - "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { - let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); - crate::links::open(app, url) - } - "app.exportPrevious" => match app.session.last_export.clone() { - Some(prev) => crate::control::export_active(app, &lightcraft_engine::export::ExportOptions::known_keys_only(&prev)), - None => Err("nothing exported yet — use Export…".into()), - }, - _ => return None, - }; - Some(r) -} - -pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { - match id { - "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), - s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", - "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { - app.session.active().is_some() - } - "photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(), - "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), - "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), - "app.showInFinder" => { - app.services.reveal.is_some() - && app - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) - } - "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), - "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), - "view.compare" => app.session.catalog.len() > 1, - "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, - "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), - "file.backupLibrary" => app.services.backup_library.is_some(), - "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), - "file.restoreLibrary" => app.services.restore_library.is_some(), - "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, - s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { - app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() - } - _ => true, - } -} - -#[derive(Clone, Debug, Serialize)] -pub struct MenuEntry { - pub id: String, - pub label: String, - pub menu: Vec<String>, - pub shortcut: Option<String>, - pub enabled: bool, -} - -/// The flattened menu model (UI commands + engine commands with menu paths). -pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> { - let mut v: Vec<MenuEntry> = ui_commands() - .filter(|c| !c.3.is_empty()) - .map(|(id, label, sc, m)| MenuEntry { - id: id.to_string(), - label: label.to_string(), - menu: m.split('>').map(str::to_string).collect(), - shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string), - enabled: ui_enabled(app, id), - }) - .collect(); - for c in app.session.commands() { - if !c.menu.is_empty() { - v.push(MenuEntry { - id: c.id.into(), - label: c.label.into(), - menu: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string), - enabled: c.enabled, - }); - } - } - v -} - -/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead -/// of deleting; true when it did (the dialog's OK runs `photo.delete`). -pub fn confirm_delete(app: &mut LightcraftApp) -> bool { - if !app.ui.settings.confirm_delete { - return false; - } - let count = app.session.targets(&json!({})).len(); - if count == 0 { - return false; - } - app.ui.dialog = Some(Dialog::ConfirmDelete { count }); - true -} - -/// Platform-appropriate label for revealing a file in the system file manager. -pub fn reveal_label() -> &'static str { - if cfg!(target_os = "macos") { - "Show in Finder" - } else if cfg!(target_os = "windows") { - "Show in Explorer" - } else { - "Show in File Manager" - } -} - -/// Help ▸ Open Log Folder: reveal the host's log file in the system file manager, so it can be -/// attached to a report without hunting for the settings folder (#260). -fn open_log_folder(app: &mut LightcraftApp) -> Result<Value, String> { - let path = app.services.log_file.clone().ok_or("this session keeps no log file")?; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&path)?; - Ok(json!({"path": path})) -} - -/// Reveal the active photo's original in the system file manager. -fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> { - let id = app.session.active().ok_or("no photo selected")?; - let path = match app.session.catalog.photo(id).map(|p| p.source.clone()) { - Some(lightcraft_engine::catalog::Source::File { path }) => path, - _ => return Err("this photo has no file (demo scene)".into()), - }; - let reveal = app.services.reveal.as_mut().ok_or("not available here")?; - reveal(&path)?; - Ok(json!({"path": path})) -} From f63522a23450bd5c53c0ee3897013718deacca24 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 06:05:11 +1100 Subject: [PATCH 026/315] LR-IMP-CAMERA-COVERAGE: verify 21 Sony and Leica models on CC0 files One CC0 raw.pixls.us file per Sony and Leica body that no earlier bulk check covered (22 files, 21 Adobe models) is added to RAW_SAMPLES and pinned in crates/raw/tests/sony_leica_corpus.rs: own sensor hash plus a black-box check against the camera's embedded JPEG where it has a colour one (five files pinned only, inspected by eye). Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint. Docs: evidence.tsv + coverage re-measure (256 verified), gap 10, checklist, raw-parity, ROADMAP. Headline unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 5 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/tests/sony_leica_corpus.rs | 114 ++++++++++++++++++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 22 ++--- docs/raw/coverage.tsv | 42 +++++----- docs/raw/evidence.tsv | 21 +++++ xtask/src/main.rs | 110 +++++++++++++++++++++++++ 9 files changed, 289 insertions(+), 39 deletions(-) create mode 100644 crates/raw/tests/sony_leica_corpus.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..8cddff019 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (21 Sony and Leica models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 18% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 21 Sony and Leica models verified (256 models, 18%); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/tests/sony_leica_corpus.rs b/crates/raw/tests/sony_leica_corpus.rs new file mode 100644 index 000000000..edd04cac3 --- /dev/null +++ b/crates/raw/tests/sony_leica_corpus.rs @@ -0,0 +1,114 @@ +//! One CC0 file per Sony and Leica body not covered by an earlier check from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.62–1.00 on these files, a misread layout or stream gives r near 0). Five files are +//! pinned only: the M8 and M9 DNGs carry no JPEG, the two M Monochrom bodies have no colour filter, and the +//! M (Typ 240) preview covers a different field (r 0.58); their renders were inspected by eye. +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn sony_and_leica_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples, compare with the camera JPEG) + let cases: &[(&str, (usize, usize), u64, bool)] = &[ + ("arw-sony-dsc-rx100m7a.arw", (5504, 3672), 0xd307_0cab_69e7_be05, true), + ("arw-sony-dsc-rx1rm3.arw", (9600, 6376), 0x15a1_1634_d24c_0ba2, true), + ("arw-sony-ilce-1m2.arw", (8672, 5784), 0x7e88_f7b5_8759_d3e9, true), + ("arw-sony-ilce-6400a.arw", (6048, 4024), 0x538b_bd85_9783_7d07, true), + ("arw-sony-ilce-7m5.arw", (7168, 5120), 0x337c_89f0_d11a_3153, true), + ("arw-sony-ilme-fx2.arw", (7040, 4688), 0x0897_d087_2ac8_b8a5, true), + ("arw-sony-zv-1m2.arw", (5504, 3672), 0x25c4_09f4_ae51_5ada, true), + ("arw-sony-zv-e10m2.arw", (6656, 4608), 0x47e5_b9b2_3912_b2ac, true), + ("dng-leica-cl.dng", (6120, 4016), 0xc59f_487f_bb92_6877, true), + ("dng-leica-m-monochrom-typ-246.dng", (5984, 4000), 0xc0d0_57fa_ed5c_0d1d, false), + ("dng-leica-m-monochrom.dng", (5216, 3472), 0x17ef_5964_1abc_6b39, false), + ("dng-leica-m-typ-240.dng", (5984, 4000), 0x7955_7612_8c83_a10f, false), + ("dng-leica-m10-r.dng", (7872, 5208), 0x573a_9fc4_9aa2_284c, true), + ("dng-leica-m10.dng", (5984, 3992), 0x1594_dd1c_ca00_d124, true), + ("dng-leica-m8.dng", (3920, 2638), 0x8354_ac17_b3ca_f4b8, false), + ("dng-leica-m9.dng", (5216, 3472), 0x141c_4039_429d_5676, false), + ("dng-leica-q-typ-116.dng", (6120, 4016), 0x11f9_9859_db23_b42e, true), + ("dng-leica-q2.dng", (8424, 5632), 0xbb4b_2bf3_ffd0_e412, true), + ("dng-leica-sl-typ-601.dng", (6120, 4016), 0xa3df_d8cf_9d7c_eff2, true), + ("dng-leica-sl2.dng", (8424, 5632), 0xadbf_7522_c3d0_a02d, true), + ("dng-leica-x2.dng", (4944, 3272), 0xbde8_5411_9431_1bd6, true), + ("rwl-leica-c-lux.rwl", (5488, 3664), 0x3c13_2cae_7028_aa0d, true), + ]; + let mut seen = 0; + for &(name, sensor, hash, jpeg_check) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + if !jpeg_check { + seen += 1; + continue; + } + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Sony / Leica bodies checked on {seen} files"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..6190c4202 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 10: 21 Sony and Leica models verified; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 256 of 1,446 Adobe models verified (18%; 21 Sony and Leica models pinned on one CC0 file each in `crates/raw/tests/sony_leica_corpus.rs`), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (256 models) | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..5417b8506 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-CAMERA-COVERAGE: 21 Sony and Leica models pinned on CC0 files; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases, 4 pinned EOS R5 Mark II files and one pinned CC0 file for each of 22 Sony and Leica bodies (`crates/raw/tests/sony_leica_corpus.rs`) (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: 21 Sony and Leica Adobe models verified on one CC0 file each; status unchanged (🟡) | | 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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..9145e7cb3 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (21 more Sony and Leica models verified on one CC0 file each) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,11 +14,11 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| Decoded from sensor data and **verified** on a real file | **256** (18%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **921** (64%) | measured from the format rules (632 of them DNG, mostly phones) | | **Preview-only** (embedded JPEG) | **233** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 94 / 628 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~42%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | @@ -43,7 +43,8 @@ committed table is stale. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; - the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference + the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), `crates/raw/tests/sony_leica_corpus.rs` (21 Sony and Leica models no bulk check covered, one CC0 raw.pixls.us + file each, pinned and, where the file has a colour JPEG, checked against it), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the @@ -63,7 +64,7 @@ committed table is stale. | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | +| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | | Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | | Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | @@ -72,7 +73,7 @@ committed table is stale. | Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | | Google | 88 | 1 | 87 | 0 | 0 | — | | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | -| Leica | 61 | 13 | 48 | 0 | 0 | — | +| Leica | 61 | 26 | 35 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | | Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | @@ -81,21 +82,21 @@ committed table is stale. | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **256 (18%)** | **921 (64%)** | **233 (16%)** | **36 (2%)** | | ## By use (estimated) | Bucket | Weight | Camera fully works | On a verified body | Why | |---|---:|---:|---:|---| | Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | -| Sony | 27% | 98% | 91% | bulk-checked | +| Sony | 27% | 98% | 92% | bulk-checked, plus 8 newer bodies pinned | | Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | | Phones | 3% | 93% | 1% | DNG | | Pentax / Ricoh | 1.5% | 94% | 14% | | -| Leica | 1% | 95% | 5% | | +| Leica | 1% | 98% | 56% | | | DJI | 1% | 95% | 0% | | | Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 256 verified, 921 unverified; Leica 5% → 56% on a verified body; use-weighted 85% and 42% unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..6ff474c45 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -586,8 +586,8 @@ Leaf Credo 50 IIQ 2013 preview IIQ is opened from its embedded JPEG only Leaf Credo 60 IIQ 2012 preview IIQ is opened from its embedded JPEG only Leaf Credo 80 IIQ 2012 preview IIQ is opened from its embedded JPEG only Leica C (Typ 112) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica CL DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica C-Lux DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica CL DNG 2017 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica C-Lux DNG 2017 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica D-Lux 2 RAW 2005 unverified Panasonic decoder, every raw format Leica D-Lux 3 RAW 2005 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body Leica D-Lux 4 RWL 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format @@ -601,26 +601,26 @@ Leica Digilux 2 RAW 2003 verified PR #215: raw.pixls.us bulk check of every RW2 Leica Digilux 3 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica Digital-Modul-R DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Leitz Phone 3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M8 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M9 DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M10 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M8 DNG 2003 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M9 DNG 2008 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M10 DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica M10-D DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M10-P DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M10-R DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10-R DNG 2019 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica M11-D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-D (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-E DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-EV1 DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-P DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M Monochrom DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M Monochrom (Typ 246) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M Monochrom DNG 2012 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M Monochrom (Typ 246) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) Leica M10 Monochrom DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11 MONOCHROM DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11-P DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q (Typ 116) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q2 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q (Typ 116) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q2 DNG 2018 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica Q2 Monochrom DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q3 DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q3 43 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -628,8 +628,8 @@ Leica Q3 Monochrom DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, D Leica S2 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S (Typ 007) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S3 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica SL (Typ 601) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica SL2 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL (Typ 601) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL2 DNG 2019 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica SL2-S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica SL3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica SL3-P DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -643,7 +643,7 @@ Leica V-LUX 3 RAW 2010 unverified Panasonic decoder, every raw format Leica V-LUX 4 RWL 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 5 RWL 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX (Typ 114) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica X2 RWL 2012 unverified Panasonic decoder, every raw format +Leica X2 RWL 2012 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica X (Typ 113) DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica X-U (Typ 113) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) LG G7 ThinQ DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1243,12 +1243,12 @@ Sony Xperia 1 V Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92 Sony Xperia 1 V Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony Xperia 1 V Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony Xperia 1 V Zoom Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sony A1 II (ILCE-1M2) (** see note) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A1 II (ILCE-1M2) (** see note) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A7 (ILCE-7) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7 II (ILCE-A7M2) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A7 III (ILCE-7M3) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7 IV (ILCE-7M4) ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony A7 V (ILCE-7M5) (** see note) ARW 2025 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A7 V (ILCE-7M5) (** see note) ARW 2025 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A7C (ILCE-7C) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7C II (ILCE-7CM2) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7CR (ILCE-7CR) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -1310,7 +1310,7 @@ Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the impo Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-V3 SRF 2005 unsupported SRF not in the import list -Sony DSC-RX100M7A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony NEX-3 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -1375,16 +1375,16 @@ Sony Xperia 5 V Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, Sony Xperia Pro-I Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony ZV-E10 ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony ZV-E10M2 ARW unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony ZV-1M2 ARW 2022 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ZV-E10M2 ARW verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-1M2 ARW 2022 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony ZV-1A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILX-LR1 ARW 2023 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILCE - 1M2 (Final Support) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILCE - 6100A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony ILCE - 6400A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony FX2 (ILMEFX2) (** see note) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ILCE - 6400A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony FX2 (ILMEFX2) (** see note) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3A (ILMEFX3A) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony RX1R III (DSC-RX1RM3) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony RX1R III (DSC-RX1RM3) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Xiaomi Mi 11 Lite Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Xiaomi Mi 11 Lite Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Xiaomi Mi 11 Lite Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..ccd879dfe 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -44,6 +44,19 @@ Leica V-LUX (Typ 114) verified PR #215: 178 raw.pixls.us files from 118 Panasoni Leica V-LUX 1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica M Monochrom verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica CL verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica C-Lux verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M Monochrom (Typ 246) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M8 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica X2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL (Typ 601) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10-R verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M9 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica Q (Typ 116) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX B700 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -248,3 +261,11 @@ Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 10 Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-1M2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-E10M2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony RX1R III (DSC-RX1RM3) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A1 II (ILCE-1M2) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony DSC-RX100M7A verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A7 V (ILCE-7M5) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony FX2 (ILMEFX2) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ILCE - 6400A verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..ccca3ca7d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -885,6 +885,116 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "dng-leica-m-monochrom.dng", + "https://raw.pixls.us/getfile.php/974/nice/Leica%20-%20M%20Monochrom%20-%2016bit%20%283:2%29.DNG", + "149d5b3fbd5cd40d51c1f56d5e392a81870d129e047a6c8a596447590e7a0e4f", + ), + ( + "dng-leica-cl.dng", + "https://raw.pixls.us/getfile.php/2489/nice/Leica%20-%20CL%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", + "1f4cfa565d176d85838d96bd3eb598bbeed719cac8acb9999d5823c527635950", + ), + ( + "dng-leica-m10.dng", + "https://raw.pixls.us/getfile.php/1603/nice/Leica%20-%20M10%20-%2016bit%2016bit%20uncompressed%20%283:2%29.dng", + "26be5e06dfb53a2938dab3ca8f06024533a9c0dd0c652938f39eb48bc325f95e", + ), + ( + "rwl-leica-c-lux.rwl", + "https://raw.pixls.us/getfile.php/3863/nice/Leica%20-%20C-Lux%20-%204:3.RWL", + "286106ec2df65539912709c64e84a9b94686443fe5ea05b8ebada76daa1feb2d", + ), + ( + "arw-sony-zv-1m2.arw", + "https://raw.pixls.us/getfile.php/8461/nice/Sony%20-%20ZV-1M2%20-%2012bit%20%283:2%29.ARW", + "2a45845a93ab5e428868366d6575a264818b184541b1c114ab4de980872a7184", + ), + ( + "dng-leica-m-monochrom-typ-246.dng", + "https://raw.pixls.us/getfile.php/1087/nice/Leica%20-%20M%20Monochrom%20%28Typ%20246%29%20-%2012bit%20%283:2%29.DNG", + "3fb74d1fc402daaa48a9c3f1d6279d7e85fb58dfdaf816f036927d49ab228977", + ), + ( + "arw-sony-zv-e10m2.arw", + "https://raw.pixls.us/getfile.php/8021/nice/Sony%20-%20ZV-E10M2%20-%2012bit%20%284:3%29.ARW", + "49db06da969cf3894cf070d9171fe0ed4d0f125e16a87b5eba7eaec27cd75595", + ), + ( + "arw-sony-dsc-rx1rm3.arw", + "https://raw.pixls.us/getfile.php/8544/nice/Sony%20-%20DSC-RX1RM3%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "6ef9956b343051748a6a16ba9ba7bd0d314a4ae0b4f8d8e2f1c13b4a1e50cfb8", + ), + ( + "arw-sony-ilce-1m2.arw", + "https://raw.pixls.us/getfile.php/7827/nice/Sony%20-%20ILCE-1M2%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "7def3c8d80537f79af199052c6e13d86750203122dcb94888ada65291dabb793", + ), + ( + "dng-leica-m8.dng", + "https://raw.pixls.us/getfile.php/2988/nice/Leica%20-%20M8%20-%208bit%208bit%20uncompressed%20%283:2%29.DNG", + "7fdcca2371bb07fbf9483fcb08e3b5ab093eca1ca3924cbc86d6863736c31153", + ), + ( + "arw-sony-dsc-rx100m7a.arw", + "https://raw.pixls.us/getfile.php/8096/nice/Sony%20-%20DSC-RX100M7A%20-%2012bit%20%283:2%29.ARW", + "80adbc5b1cb3342058d5998cf2eda1e6b408565169462d6d26dfe02f0c140b30", + ), + ( + "arw-sony-ilce-7m5.arw", + "https://raw.pixls.us/getfile.php/8845/nice/Sony%20-%20ILCE-7M5%20-%2014bit%20%284:3%29.ARW", + "8c35cd1ab0ca36b487ae50ae5e0333a8e967122700fce5678e1e5b97561896a4", + ), + ( + "arw-sony-ilme-fx2.arw", + "https://raw.pixls.us/getfile.php/8807/nice/Sony%20-%20ILME-FX2%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "924a6e4efd5c068883dd7de2bf2a2d155c7c13bdde668232077bdf9d92a201a8", + ), + ( + "dng-leica-x2.dng", + "https://raw.pixls.us/getfile.php/882/nice/Leica%20-%20X2%20-%2012bit%20%283:2%29.DNG", + "a67eca6271afb10510ffc9cf998b641d0e8a0ebfa4c779a7bc0bfed5d28b62dd", + ), + ( + "dng-leica-q2.dng", + "https://raw.pixls.us/getfile.php/3204/nice/Leica%20-%20Q2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", + "a71f98405e9e9c1e4ac0f2d2e6c52d65daae3234fc0a9f6eed2a84ee4ce794ad", + ), + ( + "arw-sony-ilce-6400a.arw", + "https://raw.pixls.us/getfile.php/7826/nice/Sony%20-%20ILCE-6400A%20-%2014bit%20%283:2%29.ARW", + "a8a45e9b6c437eba6724fcda24c3efd084c05389711b955d84b2af67635e241b", + ), + ( + "dng-leica-sl-typ-601.dng", + "https://raw.pixls.us/getfile.php/1951/nice/Leica%20-%20SL%20%28Typ%20601%29%20-%2014bit%20%283:2%29.DNG", + "aa9fd9ef937ed34467d6f91bfa2603be00cb47542b7fffd7258b79652d3b7bef", + ), + ( + "dng-leica-m10-r.dng", + "https://raw.pixls.us/getfile.php/7853/nice/Leica%20-%20M10-R%20-%2016bit%20compressed%20%283:2%29.DNG", + "ba35d25d521c7ddbc4feca69c8cdbe071ac65dcd6cf2c93374391bf6f3662b8d", + ), + ( + "dng-leica-m-typ-240.dng", + "https://raw.pixls.us/getfile.php/2317/nice/Leica%20-%20M%20%28Typ%20240%29%20-%2016bit%2016bit%20compressed%20%283:2%29.DNG", + "bf35a4da121f853cb644253b69515b441748f95fcbd6fdea2de025a76e6c24cd", + ), + ( + "dng-leica-sl2.dng", + "https://raw.pixls.us/getfile.php/7872/nice/Leica%20-%20SL2%20-%2014bit%20uncompressed%20%283:2%29.DNG", + "d00f05468793e4509b53230e90dcdc078e823b254eee95f1c68b29e217df99bf", + ), + ( + "dng-leica-m9.dng", + "https://raw.pixls.us/getfile.php/753/nice/Leica%20-%20M9%20-%208bit%208bit%20compressed%20%283:2%29.DNG", + "d1d162fce62210b8951c862189a6098329b5163b7f7492dc397c6e53bba04239", + ), + ( + "dng-leica-q-typ-116.dng", + "https://raw.pixls.us/getfile.php/1797/nice/Leica%20-%20Q%20%28Typ%20116%29%20-%2014bit%20%283:2%29.DNG", + "f7b2a6b6d7684536511cf2db2e2bfa46fae1ad7e52214093714f35e7ca60acbf", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From 9181aa0b6cdd77384c3597015b8fe4d944d09b0d Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 06:27:05 +1100 Subject: [PATCH 027/315] LR-IMP-FORMATS: import and decode Mamiya MEF (uncompressed 12-bit; WB from the RGB preview) Sources: TIFF 6.0 and own black-box analysis of the CC0 ZD sample. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 5 +- crates/engine/src/import.rs | 2 +- crates/mcp/src/headless.rs | 2 +- crates/raw/src/lib.rs | 7 ++ crates/raw/src/sparse.rs | 4 +- crates/raw/src/vendor/mef.rs | 185 ++++++++++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/wb_estimate.rs | 15 ++- crates/raw/tests/corpus.rs | 30 +++++- docs/gaps.md | 7 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 20 ++-- docs/raw/coverage.py | 2 + docs/raw/coverage.tsv | 2 +- docs/raw/evidence.tsv | 1 + docs/target-app-parity.md | 3 +- xtask/src/main.rs | 5 + 17 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 crates/raw/src/vendor/mef.rs diff --git a/ROADMAP.md b/ROADMAP.md index c93d48108..34f066595 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded, 3 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Mamiya MEF and Epson ERF decoded) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -114,6 +114,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 239 models + verified, 35 unsupported; headline numbers unchanged. - 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each file's preview; 3 bodies verified on CC0 files. 238 models verified; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a @@ -163,6 +165,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (239); headline numbers unchanged | | 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (238); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20d1983e1..ce1c347e4 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[ // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 8678b1f7e..cd072e6e2 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -14,7 +14,7 @@ use crate::backend::{Backend, ProgressHook}; pub const PHOTO_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", ]; /// Headless backend: a [`Session`] with filesystem hooks. diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 801ee7f5b..c5211a6e2 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -113,6 +113,8 @@ pub enum RawFormat { KodakDcs, /// Epson ERF: a CFA sub-IFD of 12-bit samples in 16-byte blocks (`vendor::erf`). Erf, + /// Mamiya MEF: an uncompressed 12-bit packed CFA sub-IFD (`vendor::mef`). + Mef, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -135,6 +137,7 @@ impl RawFormat { | RawFormat::Srw | RawFormat::KodakDcs | RawFormat::Erf + | RawFormat::Mef ) } } @@ -209,6 +212,9 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> { if vendor::erf::raw_ifd(&t, bytes.len()).is_some() { return Some(RawFormat::Erf); } + if vendor::mef::raw_ifd(&t, bytes.len()).is_some() { + return Some(RawFormat::Mef); + } if vendor::kodak::raw_ifd(&t, bytes).is_some() { return Some(RawFormat::KodakDcs); } @@ -378,6 +384,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> { RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), RawFormat::Erf => vendor::erf::decode(bytes, mode), + RawFormat::Mef => vendor::mef::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index 85e599349..cf13223e7 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -227,7 +227,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { return plan; } // (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read) - if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf)) { + if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef)) { return HeaderPlan::Whole("its header probe reads samples"); } let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; @@ -260,7 +260,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { holes.insert(off.saturating_add(HEAD), len); } finish(len, loaded, &holes, &meta, || match crate::probe(bytes) { - Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf) => { + Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef) => { HeaderPlan::Whole("its header probe reads samples") } Some(f) if f.is_supported() => HeaderPlan::Ready, diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs new file mode 100644 index 000000000..244245818 --- /dev/null +++ b/crates/raw/src/vendor/mef.rs @@ -0,0 +1,185 @@ +//! Mamiya MEF (ZD): a big-endian TIFF whose first `SubIFDs` child is an uncompressed 12-bit packed CFA image. +//! +//! Sources: TIFF 6.0 (container, MSB-first bit packing, `CFAPattern`) and our own black-box analysis of the one CC0 +//! MEF sample on raw.pixls.us (Mamiya ZD); every number below was measured on it. +//! +//! - IFD0 is a 144 x 192 RGB thumbnail with three `SubIFDs`: the raw (4016 x 5344, 12 bits, `Compression` 1, +//! photometric CFA, one strip of exactly 1.5 bytes per pixel, two samples in three bytes most significant bits +//! first, as TIFF packs them), a 992 x 1328 and a 240 x 320 RGB preview, all uncompressed and 8 bits. No JPEG. +//! - `CFAPattern` `00 01 01 02`: RGGB; the two green sites agree (means 254 and 256 against 235 and 165). +//! - No masked border and no stored black level: the darkest 0.01% of each site's samples sit at 3..6 and the +//! minimum is 0, so black is 0. White: the saturation plateau, else 4095. +//! - The 992 x 1328 preview shows a 3968 x 5312 window (4 sensor pixels per preview pixel) that starts at column 24, +//! row 12: that offset gives the best correlation of the preview's luminance with the box-filtered mosaic (0.965, +//! against 0.83 for the full frame). It is the default crop. +//! - Orientation is the raw IFD's own `Orientation` (6 on the sample, a portrait shot). +//! - No white-balance tag was found (the maker note holds an ICC-like profile), so the as-shot gains are measured +//! from that preview's neutral pixels ([`crate::wb_estimate`]). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::{Ifd, Tiff, tags as t}; + +/// Where the preview's window starts in the sensor frame, and how many sensor pixels one preview pixel covers. +const CROP_ORIGIN: (usize, usize) = (24, 12); +const PREVIEW_SCALE: usize = 4; + +fn is_mamiya(tiff: &Tiff) -> bool { + tiff.find(t::MAKE).and_then(|e| e.value.as_str()).is_some_and(|m| m.to_ascii_uppercase().starts_with("MAMIYA")) +} + +/// The raw IFD of a Mamiya MEF: CFA, 12 bits, uncompressed, one strip of 1.5 bytes per pixel. +pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> { + if !is_mamiya(tiff) { + return None; + } + tiff.all_ifds().into_iter().find(|i| { + let Ok(info) = i.image() else { return false }; + let (w, h) = (info.width as u64, info.height as u64); + i.u16(t::PHOTOMETRIC) == Some(t::photometric::CFA) + && i.u16(t::COMPRESSION).unwrap_or(1) == 1 + && i.u16(t::BITS_PER_SAMPLE) == Some(12) + && w > 0 + && w % 2 == 0 + && matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 3 / 2).checked_mul(h)) + }) +} + +/// The largest uncompressed 8-bit RGB sub-IFD (the preview): its pixels, width and height. +fn rgb_preview<'a>(tiff: &Tiff, bytes: &'a [u8]) -> Option<(&'a [u8], usize, usize)> { + tiff.all_ifds() + .into_iter() + .filter_map(|i| { + let info = i.image().ok()?; + let ok = i.u16(t::PHOTOMETRIC) == Some(2) + && i.u16(t::COMPRESSION).unwrap_or(1) == 1 + && i.u16(t::SAMPLES_PER_PIXEL) == Some(3) + && i.u64s(t::BITS_PER_SAMPLE).is_some_and(|b| b.iter().all(|&v| v == 8)); + let (w, h) = (info.width as usize, info.height as usize); + let chunks = info.chunks(bytes.len() as u64); + let [c] = chunks.as_slice() else { return None }; + let px = chunk_bytes(bytes, c)?; + (ok && px.len() >= w.checked_mul(h)?.checked_mul(3)?).then_some((px, w, h)) + }) + .max_by_key(|(_, w, h)| w * h) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let tiff = Tiff::parse(bytes)?; + let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Mef raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Mef raw without a 2x2 CFAPattern".into())), + }; + let preview = rgb_preview(&tiff, bytes); + let crop = match preview { + Some((_, pw, ph)) => { + let (cw, ch) = (pw.saturating_mul(PREVIEW_SCALE), ph.saturating_mul(PREVIEW_SCALE)); + if cw > 0 && ch > 0 && CROP_ORIGIN.0 + cw <= w && CROP_ORIGIN.1 + ch <= h { + Rect::new(CROP_ORIGIN.0, CROP_ORIGIN.1, cw, ch) + } else { + Rect::new(0, 0, w, h) + } + } + None => Rect::new(0, 0, w, h), + }; + // the white balance is measured from the samples, so the header probe reads them too + let chunks = info.chunks(bytes.len() as u64); + let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + let mut d = vec![0u16; n]; + crate::unpack::unpack_msb(src, 12, &mut d); + let white = super::white_from_data(&d, 12); + let black = BlackLevel::uniform(0.0); + let frame = crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white }; + let wb = match preview { + Some((px, pw, ph)) => crate::wb_estimate::as_shot_rgb(px, pw, ph, &frame), + None => crate::wb_estimate::as_shot(bytes, &frame), + }; + let data = if mode == Mode::Full { RawData::U16(d) } else { RawData::U16(Vec::new()) }; + let mut metadata = lightcraft_meta::from_tiff(&tiff); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let orientation = raw.u16(t::ORIENTATION).or_else(|| tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION))).unwrap_or(1); + let img = RawImage { + format: RawFormat::Mef, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black, + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop, + orientation: Orientation::from_exif(orientation), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + + fn pack(px: &[u16]) -> Vec<u8> { + px.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect() + } + + fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::ORIENTATION, Value::Short(vec![6])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into())); + ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn decodes_packed_12_bit() { + let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Mef)); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.data, RawData::U16(px)); + assert_eq!(r.orientation, Orientation::from_exif(6)); + assert_eq!(r.crop, Rect::new(0, 0, 16, 6)); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + + #[test] + fn malformed_files_never_panic() { + let px: Vec<u16> = (0..16 * 6).map(|i| (i * 7 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + for n in 0..f.len() { + let _ = crate::decode(&f[..n]); + } + for i in 0..f.len() { + for v in [0u8, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 45fe4549b..3541b481e 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod crx; pub(crate) mod crx_wavelet; pub mod erf; pub mod kodak; +pub mod mef; pub mod nef; pub mod nefc; pub mod orf; diff --git a/crates/raw/src/wb_estimate.rs b/crates/raw/src/wb_estimate.rs index 96918a853..17a86a2a5 100644 --- a/crates/raw/src/wb_estimate.rs +++ b/crates/raw/src/wb_estimate.rs @@ -67,7 +67,20 @@ fn median(v: &mut [f64]) -> Option<f64> { fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { let jpeg = crate::preview::embedded_preview(bytes)?; let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?; - if ch != 3 || pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS { + if ch != 3 { + return None; + } + from_rgb(&px, pw, ph, f) +} + +/// Gains from an 8-bit RGB preview `pw` x `ph` (rows of RGB triplets) showing the crop: its neutral pixels, else +/// grey world. For raws whose preview is an uncompressed RGB image rather than a JPEG. +pub(crate) fn as_shot_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> { + from_rgb(px, pw, ph, f).or_else(|| grey_world(f)) +} + +fn from_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> { + if pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS || px.len() < pw * ph * 3 { return None; } // the preview must show the crop: aspect ratios within 3% diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 6d43700b2..88b6775d1 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -42,7 +42,7 @@ fn corpus_raw_decodes() { let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; // DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG, - // except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks. + // except the Panasonic `.RAW` files of 2005–2007, Canon's explicit HEVC preview tracks and Mamiya MEF. if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL "); @@ -51,7 +51,11 @@ fn corpus_raw_decodes() { let hevc_preview = fmt == RawFormat::Cr3 && lightcraft_meta::cr3::parse_cr3(&bytes) .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview"); + // the Mamiya ZD's previews are uncompressed RGB sub-IFDs, not JPEGs + assert!( + fmt == RawFormat::Dng || fmt == RawFormat::Mef || name.starts_with("raw-panasonic-") || hevc_preview, + "{name}: no embedded preview" + ); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); @@ -1166,3 +1170,25 @@ fn corpus_epson_erf() { } eprintln!("ERF: {checked} corpus files checked"); } + +/// Mamiya MEF (`crates/raw/src/vendor/mef.rs`): the uncompressed 12-bit strip decodes to a smooth 4016 x 5344 RGGB +/// mosaic, the crop is the preview's window and the white balance comes from the RGB preview. Skips when absent. +#[test] +fn corpus_mamiya_mef() { + let Ok(bytes) = std::fs::read(corpus_root().join("raw").join("mef-mamiya-zd.mef")) else { + eprintln!("skip: mef-mamiya-zd.mef absent"); + return; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Mef)); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{e}")); + assert_eq!((img.width, img.height, img.bits), (4016, 5344, 12)); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (24, 12, 3968, 5312)); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB")); + assert_eq!(img.orientation, lightcraft_geom::Orientation::from_exif(6)); + let wb = img.wb_multipliers.expect("white balance"); + eprintln!("MEF wb {wb:?}"); + assert!(wb.iter().all(|g| (0.5..=4.0).contains(g)), "{wb:?}"); + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let step: f64 = d.chunks(4016).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 4014.0); + assert!(step < 40.0, "rough {step}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 1c34f2f1c..f63874fbf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 20: Epson ERF decodes; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 20: Mamiya MEF and Epson ERF decode; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 238 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 239 of 1,446 Adobe models verified (17%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -34,7 +34,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | -| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, KDC, DCR, MEF, GPR, old FinePix (ERF decodes since 2026-10-12) | raw-parity.md | small user share each | 43–83 | [raw-parity](raw-parity.md) | +| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, KDC, DCR, GPR, old FinePix (ERF and MEF decode since 2026-10-12) | raw-parity.md | small user share each | 42–82 | [raw-parity](raw-parity.md) | | 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** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 20 shrinks: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 238 → 239 | | 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 235 → 238 | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 61abd6c2e..5cec60445 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS: Epson ERF decodes; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS: Mamiya MEF and Epson ERF decode; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Epson ERF (R-D1, R-D1s, R-D1x: a CFA sub-IFD of 12-bit samples in 16-byte blocks, ten MSB-first samples and a pad byte; maker-note black levels and picture size, the crop registered against the preview; no white-balance tag was found, so the as-shot gains are measured from the preview's neutral pixels, grey world without them, `crates/raw/src/wb_estimate.rs`; file-local look fitted to the preview; `crates/raw/src/vendor/erf.rs`, 3 CC0 files), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`) and Leaf MOS (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Mamiya MEF (ZD: an uncompressed 12-bit packed CFA sub-IFD; crop is its RGB preview's window, white balance from that preview's neutral pixels; no JPEG preview, so it renders with the neutral starting look; `crates/raw/src/vendor/mef.rs`, 1 CC0 file), Epson ERF (R-D1, R-D1s, R-D1x: a CFA sub-IFD of 12-bit samples in 16-byte blocks, ten MSB-first samples and a pad byte; maker-note black levels and picture size, the crop registered against the preview; no white-balance tag was found, so the as-shot gains are measured from the preview's neutral pixels, grey world without them, `crates/raw/src/wb_estimate.rs`; file-local look fitted to the preview; `crates/raw/src/vendor/erf.rs`, 3 CC0 files), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`) and Leaf MOS (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | | 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | | 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) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 18d087a04..31dce107d 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded: 3 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Mamiya MEF decoded: ZD verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,10 +14,10 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **238** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoded from sensor data and **verified** on a real file | **239** (17%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | | Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | | **Preview-only** (embedded JPEG) | **230** (16%) | measured | -| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | +| **Unsupported** (not imported, refused or decodes wrongly) | **35** (2%) | measured | | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~42%** | estimated, same weights | @@ -41,7 +41,7 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 104 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 105 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue @@ -76,12 +76,12 @@ committed table is stale. | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | | Hasselblad | 34 | 0 | 2 | 32 | 0 | Hasselblad-compressed 3FR / FFF | -| Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | +| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 3 | 14 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (ERF decodes) | -| **All** | **1,446** | **238 (16%)** | **942 (65%)** | **230 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **239 (17%)** | **942 (65%)** | **230 (16%)** | **35 (2%)** | | ## By use (estimated) @@ -97,7 +97,7 @@ committed table is stale. | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 24% | 2% | IIQ, 3FR, X3F | +| Medium format and others | 0.5% | 25% | 2% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually @@ -146,7 +146,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | | IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview | -| DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | +| MEF | uncompressed 12-bit (Mamiya ZD) | ✅ | 1 CC0 corpus file; crop and WB from its uncompressed RGB preview (no JPEG, no WB tag); `vendor/mef.rs` | +| DCR, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | ### Compressed ORF: a clean-room starting point @@ -252,7 +253,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | | 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | | 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | -| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, MEF, GPR (ERF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | Beyond decoding: @@ -267,6 +268,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded (`vendor/mef.rs`): ZD verified; 239 verified, 35 unsupported | | 2026-10-12 | minor | Epson ERF decoded (`vendor/erf.rs`): R-D1, R-D1s, R-D1x verified; 238 verified, 230 preview | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index d129c2bf8..c4552feee 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -83,6 +83,8 @@ def classify(maker, camera, ext_field): return "preview", "SRW", "compressed SRW is not decoded" return "unverified", "SRW", "uncompressed / packed SRW" e = es[0] if es else "" + if e == "MEF": + return "unverified", "MEF", "MEF decoder (uncompressed 12-bit)" if e == "ERF": return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)" if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "MRW", "X3F", "RAW", "ORI"): diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index 1a0f57c3d..1910fcf48 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -673,7 +673,7 @@ Mamiya Mamiya M18 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M22 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M31 TIF 2008 unsupported TIF is not imported Mamiya Mamiya DM40 MOS 2010 preview MOS is opened from its embedded JPEG only -Mamiya ZD MEF 2005 unsupported MEF is not imported +Mamiya ZD MEF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index e4f66d78c..2fb0e38cb 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -251,3 +251,4 @@ Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Mamiya ZD verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f15a6e911..2333d62ad 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Epson ERF decoded: 3 models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Mamiya MEF and Epson ERF decoded; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded (raw-parity.md): 239 verified, 35 unsupported; rounded numbers unchanged | | 2026-10-12 | minor | Epson ERF decoded (raw-parity.md): 238 models verified, 230 preview-only; shares and rounded numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b900136d1..e39d6bd79 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -888,6 +888,11 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ ("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"), ("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"), ("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"), + ( + "mef-mamiya-zd.mef", + "https://raw.pixls.us/data/Mamiya/ZD/RAW_MAMIYA_ZD.MEF", + "bcd63507c3c4cc3ea1bad2945e3f88cb4677a1a3c762e1acc38df4445714eb7c", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From b7d77f16b84109e972e0657caae9a9e711091980 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 06:38:36 +1100 Subject: [PATCH 028/315] LR-IMP-CAMERA-COVERAGE: verify 17 uncompressed Olympus and Samsung models on CC0 files One CC0 raw.pixls.us file per uncompressed Olympus ORF and Samsung SRW body not yet pinned (23 files, 17 Adobe models) is added to RAW_SAMPLES and pinned in crates/raw/tests/olympus_samsung_corpus.rs: own sensor hash plus a black-box check against the camera's embedded JPEG (the E-10 pinned only, inspected by eye). Also: as_chunks in spots.rs for the chunks_exact_to_as_chunks lint. Docs: evidence.tsv + coverage re-measure (252 verified), gap 10, checklist, raw-parity, ROADMAP. Headline unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 5 +- crates/pipeline/src/spots.rs | 4 +- crates/raw/tests/olympus_samsung_corpus.rs | 114 ++++++++++++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 20 ++-- docs/raw/coverage.tsv | 34 +++--- docs/raw/evidence.tsv | 17 +++ xtask/src/main.rs | 115 +++++++++++++++++++++ 9 files changed, 285 insertions(+), 34 deletions(-) create mode 100644 crates/raw/tests/olympus_samsung_corpus.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..a5f57cc1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (17 Olympus and Samsung models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 17% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 17 uncompressed Olympus and Samsung models verified (252 models, 17%); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/tests/olympus_samsung_corpus.rs b/crates/raw/tests/olympus_samsung_corpus.rs new file mode 100644 index 000000000..b90e7c7cb --- /dev/null +++ b/crates/raw/tests/olympus_samsung_corpus.rs @@ -0,0 +1,114 @@ +//! One CC0 file per uncompressed Olympus ORF and Samsung SRW body from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.70–1.00 on these files, a misread layout or stream gives r near 0). The E-10 is +//! pinned only (its small JPEG gives r 0.59); its render was inspected by eye. +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn olympus_and_samsung_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples, compare with the camera JPEG) + let cases: &[(&str, (usize, usize), u64, bool)] = &[ + ("dng-samsung-gx10.dng", (3936, 2624), 0x25d5_1c29_e67f_99e8, true), + ("dng-samsung-gx20.dng", (4864, 3136), 0x516f_3df8_c879_a2c7, true), + ("orf-olympus-c5060wz.orf", (2608, 1949), 0x4185_d439_e31d_72bf, true), + ("orf-olympus-c7070wz.orf", (3088, 2309), 0x016a_f9d1_89e5_4aaf, true), + ("orf-olympus-e-10.orf", (2256, 1684), 0x1029_c758_4810_f56b, false), + ("orf-olympus-e-300.orf", (3360, 2504), 0xa9b4_e783_03a4_45c2, true), + ("orf-olympus-e-330.orf", (3280, 2450), 0xc938_38fa_9822_c81a, true), + ("orf-olympus-e-500.orf", (3360, 2504), 0x5e22_2ac8_17ed_89d9, true), + ("orf-olympus-e-m5-mark-ii.orf", (9280, 6932), 0xa222_be38_c767_7782, true), + ("orf-olympus-pen-f.orf", (10400, 7796), 0x415e_bf27_092e_9897, true), + ("orf-olympus-sh-2.orf", (4624, 3470), 0xb25b_8d24_4ade_1f3d, true), + ("orf-olympus-sp510uz.orf", (3088, 2309), 0xa885_b7ca_83ba_02f1, true), + ("orf-olympus-sp550uz.orf", (3088, 2309), 0x30ac_5e9f_34b9_8645, true), + ("orf-olympus-sp565uz.orf", (3664, 2741), 0x0962_c436_052d_efa8, true), + ("orf-olympus-sp570uz.orf", (3664, 2741), 0xdd62_06d6_17d0_8a46, true), + ("orf-olympus-xz-10.orf", (3984, 2990), 0x6274_1a67_cd42_40f7, true), + ("srw-samsung-nx1000.srw", (5632, 3720), 0xd99f_f1d0_e3d6_435f, true), + ("srw-samsung-nx11.srw", (4704, 3124), 0x29b5_58f8_09e4_85cb, true), + ("srw-samsung-nx1100.srw", (5632, 3720), 0x69cd_048b_c8a1_dd7a, true), + ("srw-samsung-nx200.srw", (5632, 3720), 0x61ef_6100_b9f6_dabd, true), + ("srw-samsung-nx210.srw", (5632, 3720), 0x50d5_e55e_6ec7_7faa, true), + ("srw-samsung-nx5.srw", (4704, 3124), 0xf20d_905b_41b3_32f6, true), + ("srw-samsung-wb2000.srw", (3728, 2781), 0xe40f_1c19_41ce_9000, true), + ]; + let mut seen = 0; + for &(name, sensor, hash, jpeg_check) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + if !jpeg_check { + seen += 1; + continue; + } + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Olympus / Samsung bodies checked on {seen} files"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..5d52b5410 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 10: 17 uncompressed Olympus and Samsung models verified; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 252 of 1,446 Adobe models verified (17%; 17 uncompressed Olympus and Samsung models pinned on one CC0 file each in `crates/raw/tests/olympus_samsung_corpus.rs`), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (252 models) | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..d449f4053 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung models pinned on CC0 files; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases, 4 pinned EOS R5 Mark II files and one pinned CC0 file for each of 23 uncompressed Olympus ORF and Samsung SRW bodies (`crates/raw/tests/olympus_samsung_corpus.rs`) (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) | | 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 | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..e1b08b55a 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (17 uncompressed Olympus and Samsung models verified on one CC0 file each) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -14,9 +14,9 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | +| Decoded from sensor data and **verified** on a real file | **252** (17%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **933** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **225** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | @@ -43,7 +43,8 @@ committed table is stale. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; - the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference + the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), `crates/raw/tests/olympus_samsung_corpus.rs` (17 uncompressed Olympus ORF and Samsung SRW models, one CC0 + raw.pixls.us file each, pinned and checked against the camera JPEG), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body there, 178 files from 118 bodies). Bulk-check body lists were matched to Adobe names through the @@ -65,13 +66,13 @@ committed table is stale. |---|---:|---:|---:|---:|---:|---| | Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | | Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | -| Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | +| Samsung | 128 | 4 | 115 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | | Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | | Nikon | 99 | 5 | 94 | 0 | 0 | — (HE / HE★ is a per-shot setting, preview-only) | | Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | | Google | 88 | 1 | 87 | 0 | 0 | — | -| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | +| Olympus | 71 | 17 | 3 | 51 | 0 | **compressed ORF** | | Leica | 61 | 13 | 48 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | @@ -81,7 +82,7 @@ committed table is stale. | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| **All** | **1,446** | **252 (17%)** | **933 (65%)** | **225 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -92,7 +93,7 @@ committed table is stale. | Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | -| OM / Olympus | 3% | 3% | 1% | compressed ORF | +| OM / Olympus | 3% | 6% | 5% | compressed ORF | | Phones | 3% | 93% | 1% | DNG | | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 95% | 5% | | @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | 17 uncompressed Olympus and Samsung models verified (one CC0 raw.pixls.us file each; `olympus_samsung_corpus.rs`): 252 verified, 933 unverified, 225 preview; OM / Olympus 3% → 6% (5% on verified bodies); use-weighted 85% and 42% unchanged | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..edf5e4f5c 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -788,13 +788,13 @@ OM Digital Solutions OM-5 Mark II ORF 2024 preview compressed ORF is not decoded OM Digital Solutions OM-3 Astro ORF 2025 preview compressed ORF is not decoded Olympus AIR A01 ORF 2015 preview compressed ORF is not decoded Olympus Camedia C-5050 Zoom ORF 2003 unverified uncompressed / packed ORF -Olympus Camedia C-5060 Zoom ORF 2003 unverified uncompressed / packed ORF -Olympus Camedia C-7070 Wide Zoom ORF 2005 unverified uncompressed / packed ORF +Olympus Camedia C-5060 Zoom ORF 2003 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-7070 Wide Zoom ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Camedia C-8080 Wide Zoom ORF 2003 unverified uncompressed / packed ORF Olympus E-1 ORF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus E-3 ORF 2007 preview compressed ORF is not decoded Olympus E-5 ORF 2010 preview compressed ORF is not decoded -Olympus E-10 ORF 2003 unverified uncompressed / packed ORF +Olympus E-10 ORF 2003 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash) Olympus E-20 ORF 2003 unverified uncompressed / packed ORF Olympus E-30 ORF 2008 preview compressed ORF is not decoded Olympus E-420 ORF 2008 preview compressed ORF is not decoded @@ -802,18 +802,18 @@ Olympus E-450 ORF 2008 preview compressed ORF is not decoded Olympus E-520 ORF 2008 preview compressed ORF is not decoded Olympus E-600 ORF 2010 preview compressed ORF is not decoded Olympus E-620 ORF 2008 preview compressed ORF is not decoded -Olympus EVOLT E-300 ORF 2005 unverified uncompressed / packed ORF -Olympus EVOLT E-330 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-300 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-330 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus EVOLT E-400 ORF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-410 ORF 2007 preview compressed ORF is not decoded -Olympus EVOLT E-500 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-500 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus EVOLT E-510 ORF 2007 preview compressed ORF is not decoded Olympus OM-D E-M1 ORF 2013 preview compressed ORF is not decoded Olympus OM-D E-M1X ORF 2018 preview compressed ORF is not decoded Olympus OM-D E-M1 Mark II ORF 2015 preview compressed ORF is not decoded Olympus OM-D E-M1 Mark III ORF 2019 preview compressed ORF is not decoded Olympus OM-D E-M5 ORF 2012 preview compressed ORF is not decoded -Olympus OM-D E-M5 Mark II ORF 2015 preview compressed ORF is not decoded +Olympus OM-D E-M5 Mark II ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus OM-D E-M5 Mark III ORF 2019 preview compressed ORF is not decoded Olympus OM-D E-M10 ORF 2013 preview compressed ORF is not decoded Olympus OM-D E-M10 Mark II ORF 2015 preview compressed ORF is not decoded @@ -837,25 +837,25 @@ Olympus PEN E-PL8 ORF 2015 preview compressed ORF is not decoded Olympus PEN E-PL9 ORF 2017 preview compressed ORF is not decoded Olympus PEN E-PM1 ORF 2010 preview compressed ORF is not decoded Olympus PEN E-PM2 ORF 2012 preview compressed ORF is not decoded -Olympus PEN-F ORF 2015 preview compressed ORF is not decoded +Olympus PEN-F ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus SP-310 ORF 2005 preview compressed ORF is not decoded Olympus SP-320 ORF 2005 preview compressed ORF is not decoded Olympus SP-350 ORF 2005 preview compressed ORF is not decoded Olympus SP-500 UZ ORF 2005 preview compressed ORF is not decoded -Olympus SP-510 UZ ORF 2005 preview compressed ORF is not decoded -Olympus SP-550 UZ ORF 2007 preview compressed ORF is not decoded +Olympus SP-510 UZ ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-550 UZ ORF 2007 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus SP-560 UZ ORF 2007 preview compressed ORF is not decoded -Olympus SP-565 UZ ORF 2008 preview compressed ORF is not decoded -Olympus SP-570 UZ ORF 2007 preview compressed ORF is not decoded +Olympus SP-565 UZ ORF 2008 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-570 UZ ORF 2007 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Stylus 1 ORF 2013 preview compressed ORF is not decoded Olympus Stylus 1s ORF 2013 preview compressed ORF is not decoded -Olympus Stylus SH-2 ORF 2015 preview compressed ORF is not decoded +Olympus Stylus SH-2 ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Stylus SH-3 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-4 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-5 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-6 ORF 2018 preview compressed ORF is not decoded Olympus Stylus XZ-2 iHS ORF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Olympus Stylus XZ-10 ORF 2012 preview compressed ORF is not decoded +Olympus Stylus XZ-10 ORF 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus XZ-1 ORF 2010 preview compressed ORF is not decoded OnePlus 7T Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) OnePlus 7T Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1105,14 +1105,14 @@ Samsung EK-KN120 SRW 2012 preview compressed SRW is not decoded Samsung EX2F SRW 2012 unverified uncompressed / packed SRW Samsung NX300 SRW 2012 preview compressed SRW is not decoded Samsung NX500 SRW 2015 preview compressed SRW is not decoded -Samsung NX1000 SRW 2012 unverified uncompressed / packed SRW -Samsung NX1100 SRW 2012 unverified uncompressed / packed SRW +Samsung NX1000 SRW 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1100 SRW 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung NX2000 SRW 2012 preview compressed SRW is not decoded Samsung NX3000 SRW 2013 preview compressed SRW is not decoded Samsung NX3300 SRW 2015 preview compressed SRW is not decoded Samsung NX U SRW 2012 unverified uncompressed / packed SRW Samsung Pro 815 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung TL350 (WB2000) SRW 2010 unverified uncompressed / packed SRW +Samsung TL350 (WB2000) SRW 2010 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung TL500 (EX1) SRW 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Samsung Galaxy Note 9 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Note 20 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..a65b02dce 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -52,6 +52,20 @@ Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus Stylus XZ-2 iHS verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Olympus SP-550 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus E-10 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash) +Olympus Camedia C-5060 Zoom verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-565 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus OM-D E-M5 Mark II verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-300 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus SH-2 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-7070 Wide Zoom verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus XZ-10 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-330 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-570 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-510 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus PEN-F verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-500 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Panasonic LUMIX DC - G97 (DC-G99M2) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-FZ1000M2 (DC-FZ10002) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-FZ80 (DC-FZ82, DC-FZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format @@ -150,6 +164,9 @@ Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Samsung TL350 (WB2000) verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1000 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1100 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..3791c7152 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -885,6 +885,121 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "srw-samsung-nx200.srw", + "https://raw.pixls.us/getfile.php/839/nice/Samsung%20-%20NX200%20-%2012bit%20%283:2%29.srw", + "03563eadfe4ef32b5bf2a2850abb40b29fcc1f8d56b3b2ed92ef307b90d7d9dc", + ), + ( + "orf-olympus-sp550uz.orf", + "https://raw.pixls.us/getfile.php/8609/nice/Olympus%20-%20SP550UZ%20-%2012bit%20%284:3%29.ORF", + "05f49918e1b1894c3abd2b5451b356f7a00adb1c8568abc5babff33fcade1bb2", + ), + ( + "orf-olympus-e-10.orf", + "https://raw.pixls.us/getfile.php/5424/nice/Olympus%20-%20E-10%20-%2016bit%20%284:3%29.ORF", + "2bfdade72439017a60a47aad1e5bbcb1aca36f2be7a21e94a4257a678fb6f4da", + ), + ( + "orf-olympus-c5060wz.orf", + "https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284:3%29.ORF", + "3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd", + ), + ( + "dng-samsung-gx10.dng", + "https://raw.pixls.us/getfile.php/1165/nice/Samsung%20-%20GX10%20-%2012bit%20%283:2%29.DNG", + "44cbb7980b320c0920d999758eb8a3f27e6b98dd14afc3dfc135bc8526abda58", + ), + ( + "orf-olympus-sp565uz.orf", + "https://raw.pixls.us/getfile.php/3438/nice/Olympus%20-%20SP565UZ%20-%2012bit%20%284:3%29.ORF", + "5088771f83ce08a001b44672e1dd9f013e603933c37ce1bfb7030a02abbe801c", + ), + ( + "dng-samsung-gx20.dng", + "https://raw.pixls.us/getfile.php/1537/nice/Samsung%20-%20GX20%20-%2012bit%20%283:2%29.DNG", + "5557016b2712b621befdb05731923562a802beafd13e0f366737390a8f7b3286", + ), + ( + "orf-olympus-e-m5-mark-ii.orf", + "https://raw.pixls.us/getfile.php/2856/nice/Olympus%20-%20E-M5%20Mark%20II%20-%2016bit%2016bit%20%22high-res%22%20%284:3%29.ORF", + "5c42fa75d6b549514b722e2c50726c03e34fac4909ec3640e147ea7fa825fc8d", + ), + ( + "orf-olympus-e-300.orf", + "https://raw.pixls.us/getfile.php/2878/nice/Olympus%20-%20E-300%20-%2016bit%20%284:3%29.ORF", + "6d818dbff78b3e2efa4835f568d04733052b9211e6bc65b0a3748a841170e95b", + ), + ( + "orf-olympus-sh-2.orf", + "https://raw.pixls.us/getfile.php/792/nice/Olympus%20-%20SH-2%20-%2012bit%20%284:3%29.ORF", + "6e6b9b7dd9ded7c4b364b83116c817ca2d6daac15f2e9ef7796159b08c3288a1", + ), + ( + "srw-samsung-nx5.srw", + "https://raw.pixls.us/getfile.php/5684/nice/Samsung%20-%20NX5%20-%2012bit%20%283:2%29.SRW", + "908fc61d5bbc1f0d0a5cea4f4e39e7753060c1af8673f7a4a9199284b70930f7", + ), + ( + "orf-olympus-c7070wz.orf", + "https://raw.pixls.us/getfile.php/780/nice/Olympus%20-%20C7070WZ%20-%2012bit%20%284:3%29.ORF", + "9d55506f9d6ee31d2d4c4b6df0dcd1d016d358b3e5eb4ed31edd7227dfefd5f8", + ), + ( + "orf-olympus-xz-10.orf", + "https://raw.pixls.us/getfile.php/2431/nice/Olympus%20-%20XZ-10%20-%2012bit%20%284:3%29.ORF", + "a25383acad9b5bb0bb404715e0957f2eb80aa1aaad6c448a71025c03cfa631be", + ), + ( + "srw-samsung-wb2000.srw", + "https://raw.pixls.us/getfile.php/4262/nice/Samsung%20-%20WB2000%20-%2014bit%20%284:3%29.SRW", + "b9a1149fb0dda9d23aee0ad8c402bf2f9ba42832366d46c4c1b739c0b0f48497", + ), + ( + "orf-olympus-e-330.orf", + "https://raw.pixls.us/getfile.php/3624/nice/Olympus%20-%20E-330%20-%2016bit%20%284:3%29.ORF", + "ba11eb970ab32e1e9cf3d0176041e4bd11c501d445720c0a344217e785ad65a6", + ), + ( + "orf-olympus-sp570uz.orf", + "https://raw.pixls.us/getfile.php/5491/nice/Olympus%20-%20SP570UZ%20-%2012bit%20%284:3%29.ORF", + "baab92391834076b427e406e98da79aaba48fb87243d6ccae60e08e299cfccf7", + ), + ( + "srw-samsung-nx11.srw", + "https://raw.pixls.us/getfile.php/5985/nice/Samsung%20-%20NX11%20-%2012bit%20%283:2%29.SRW", + "bdce6179b4180478057f4971b512e0303da71528db454cb3f399ab10e05b68fe", + ), + ( + "srw-samsung-nx1000.srw", + "https://raw.pixls.us/getfile.php/2588/nice/Samsung%20-%20NX1000%20-%2012bit%20%283:2%29.SRW", + "beef4d1e6f25ff28fb20bb3ad00ffadb62ab57d7041237e295b0b62f7de5309c", + ), + ( + "srw-samsung-nx1100.srw", + "https://raw.pixls.us/getfile.php/4422/nice/Samsung%20-%20NX1100%20-%2012bit%20%283:2%29.srw", + "d230af65ee55314792c5337ea8e0e757626a8541e7a25ed03d63b8df202ed9cb", + ), + ( + "srw-samsung-nx210.srw", + "https://raw.pixls.us/getfile.php/1831/nice/Samsung%20-%20NX210%20-%2012bit%20%283:2%29.SRW", + "d613503ddf9bedd2d2e84708749b1a94f0cccd4fa15492e01242368c5c07cfad", + ), + ( + "orf-olympus-sp510uz.orf", + "https://raw.pixls.us/getfile.php/2420/nice/Olympus%20-%20SP510UZ%20-%2012bit%20%284:3%29.ORF", + "f0737d78bb2e588963a3d5038fab493cdff1c9c48475704de96d797bbb81e62a", + ), + ( + "orf-olympus-pen-f.orf", + "https://raw.pixls.us/getfile.php/2977/nice/Olympus%20-%20PEN-F%20-%2016bit%20%284:3%29.orf", + "f87a5f3295aeb4d6ab27bb5b41c5e0f3a211ec9b4e3b1d9f105a650bee1a2f13", + ), + ( + "orf-olympus-e-500.orf", + "https://raw.pixls.us/getfile.php/3540/nice/Olympus%20-%20E-500%20-%2016bit%20%284:3%29.ORF", + "fa5a4759b2de2b573f59629ad1baf64ad04a85fb8fe1fb3cbf68e08a42fff4f0", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From 8fbc53584f717a3d18751455db4ab911e8a480f6 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Mon, 12 Oct 2026 06:45:46 +1100 Subject: [PATCH 029/315] L10N: fill the de, es, fr, ja and uk catalogs (gap 21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every message another catalog knows is now in the German, Spanish, French, Japanese and Ukrainian catalogs (plain and formats): 927 plain and 34 format messages added or translated, following each catalog's existing terminology, register and punctuation; placeholders unchanged. Menu paths inside translated text use → (the UI font has no ▸). Values identical to English are kept only where correct (ISO, Display P3, words spelled the same, language self-names, placeholder-only formats). Measured: de 97%, es 98%, fr 97%, ja 99%, uk 99% of 2,398 catalog messages. pt-BR, ru, zh-hans and zh-hant are left to their open PRs. Agent-made translations: native-speaker review welcome. Also fixes the Rust 1.99 clippy lint chunks_exact_to_as_chunks in pipeline/spots.rs that fails `cargo xtask ci` on main. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- ROADMAP.md | 5 +- crates/pipeline/src/spots.rs | 4 +- crates/ui-egui/locales/de-formats.json | 22 +- crates/ui-egui/locales/de.json | 219 ++++++++------ crates/ui-egui/locales/es-formats.json | 22 +- crates/ui-egui/locales/es.json | 381 +++++++++++++++++++------ crates/ui-egui/locales/fr-formats.json | 2 +- crates/ui-egui/locales/fr.json | 223 ++++++++++++++- crates/ui-egui/locales/ja-formats.json | 22 +- crates/ui-egui/locales/ja.json | 377 ++++++++++++++++++------ crates/ui-egui/locales/uk.json | 23 +- docs/gaps.md | 5 +- docs/localization-parity.md | 13 +- docs/target-app-parity.md | 7 +- 14 files changed, 985 insertions(+), 340 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..a444ec004 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -49,7 +49,7 @@ Hours are calibrated on this repo's PR history; see | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | -| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | +| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | | Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec<f32> = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..ac404ca28 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -197,23 +197,23 @@ "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", ", {} star{}": ", Sterne: {}{:.0}", ", {} label": ", Markierung: {}", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Lädt von {host} herunter. Sobald es angekommen und geprüft ist, installiert LightCraft es und {after}.", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "Läuft auf dem Prozessor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Im Ordner „denoise“ der Bibliothek gespeichert; bei Erreichen der Grenze werden zuerst die ältesten entfernt, die kein Foto verwendet.", + "Denoising {name}: {done} of {total} tiles{}": "{name} wird entrauscht: {done} von {total} Kacheln{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{} von {} von {host}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "Entrauschtes Bild wird berechnet… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "Trainiert mit: {provenance}", + " · {queued} waiting": " · {queued} wartend", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Zeigt die Bedingungen des Modells, lädt dann {} von {host} herunter, installiert es und verwendet es. Sonst wird nichts gesendet.", + "In line for AI Denoise ({ahead} ahead)…": "In der Warteschlange für KI-Entrauschen ({ahead} davor)…", + "Running on {}.": "Läuft auf {}.", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "In „{parent}“ einfügen", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..31b2be4b1 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1813,96 +1813,96 @@ "Install the SAM 3 Model": "SAM-3-Modell installieren", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.", "Open Log Folder": "Protokollordner öffnen", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", - "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", + "Back to this person": "Zurück zu dieser Person", + "AI Denoise": "KI-Entrauschen", + "AI Denoise Model": "KI-Entrauschungsmodell", + "AI Denoise is off: press Use on a model.": "KI-Entrauschen ist aus: Klicke bei einem Modell auf „Verwenden“.", + "AI Denoise needs a model. Install one in Settings.": "KI-Entrauschen benötigt ein Modell. Installiere eines in den Einstellungen.", + "Accept & Download": "Akzeptieren und herunterladen", + "Already installed. Remove it before installing a replacement.": "Bereits installiert. Entferne es, bevor du einen Ersatz installierst.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatisch verwendet die Grafikkarte, wo sie schneller als der Prozessor ist: Beide werden beim ersten berechneten Foto auf diesem Computer gemessen.", "Cache": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", - "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "Next up for AI Denoise…", - "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", - "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", - "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "This build cannot run denoise models.", - "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Checking and installing the model…": "Modell wird geprüft und installiert…", + "Checking…": "Wird geprüft…", + "Clear cache": "Cache leeren", + "Denoise Models": "Entrauschungsmodelle", + "Denoise Settings": "Entrauschungseinstellungen", + "Denoise all photos with an Amount": "Alle Fotos mit einer Stärke entrauschen", + "Downloaded and checked. Installing…": "Heruntergeladen und geprüft. Wird installiert…", + "Failed its last test": "Letzter Test fehlgeschlagen", + "Graphics card": "Grafikkarte", + "I have read these terms and accept them for my own use": "Ich habe diese Bedingungen gelesen und akzeptiere sie für meine eigene Nutzung", + "Inspect Denoise Model": "Entrauschungsmodell prüfen", + "Install Denoise Model": "Entrauschungsmodell installieren", + "Install from file…": "Aus Datei installieren…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installiere ein eigenes ONNX-Modell mit einer denoise-model.json daneben. Diese Version bietet keine Modelldownloads an.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Sein entrauschtes Bild ist noch nicht berechnet, und Bilder werden nicht automatisch berechnet (Einstellungen, KI-Entrauschen).", + "LightCraft does not know this model, so it assumed:": "LightCraft kennt dieses Modell nicht und hat daher angenommen:", + "Limit": "Grenze", + "Make it now": "Jetzt berechnen", + "Make the denoised picture of the photos I am looking at": "Entrauschtes Bild der angezeigten Fotos berechnen", + "Next up for AI Denoise…": "Als Nächstes beim KI-Entrauschen…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nichts wird geladen, bevor du zustimmst. Das Modell bleibt nur auf diesem Computer; LightCraft lädt es nie hoch und gibt es nie weiter.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Exporte berechnen ihres immer, wenn es fehlt.", + "Open page": "Seite öffnen", + "Open the model's page": "Seite des Modells öffnen", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Öffnet die Seite des Modells im Browser, um mehr darüber zu lesen oder die Datei selbst herunterzuladen.", + "Processor": "Prozessor", + "Set up AI Denoise…": "KI-Entrauschen einrichten…", + "Speed": "Geschwindigkeit", + "Starting…": "Wird gestartet…", + "Tested and works on this computer": "Getestet, funktioniert auf diesem Computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Das Entrauschungsmodell ist installiert und in Gebrauch: Der Regler für die KI-Entrauschungsstärke unter „Detail“ funktioniert jetzt", + "The graphics card is set up when the next photo is made.": "Die Grafikkarte wird eingerichtet, wenn das nächste Foto berechnet wird.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Das Modell bleibt nur auf diesem Computer. LightCraft lädt es nie hoch und gibt es nie weiter.", + "The processor does the work.": "Der Prozessor erledigt die Arbeit.", + "There is no model file to install": "Es gibt keine Modelldatei zum Installieren", + "This build cannot run denoise models.": "Diese Version kann keine Entrauschungsmodelle ausführen.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Diese Version hat keinen Speicherort für Entrauschungsmodelle, daher ist KI-Entrauschen nicht verfügbar. (Die Desktop-App schon.)", + "This file cannot be used yet": "Diese Datei kann noch nicht verwendet werden", + "Tick the box to accept the model's terms first": "Setze zuerst das Häkchen, um die Bedingungen des Modells zu akzeptieren", + "Try again": "Erneut versuchen", + "Try the graphics card again": "Grafikkarte erneut versuchen", + "Turn off": "Ausschalten", + "Unknown licence": "Unbekannte Lizenz", + "Use": "Verwenden", + "Waiting to start…": "Wartet auf Start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit Raw-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.", + "cached picture": "zwischengespeichertes Bild", + "cached pictures": "zwischengespeicherte Bilder", + "commercial use allowed": "kommerzielle Nutzung erlaubt", "denoiseModel.accept": "denoiseModel.accept", - "in use": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "Pending photo action cancelled": "Pending photo action cancelled", - "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", - "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "in use": "in Gebrauch", + "installed": "installiert", + "makes AI Denoise available": "macht KI-Entrauschen verfügbar", + "non-commercial use only": "nur nichtkommerzielle Nutzung", + "photo has its denoised picture.": "Foto hat sein entrauschtes Bild.", + "photo waiting": "Foto wartet", + "photos have their denoised picture.": "Fotos haben ihr entrauschtes Bild.", + "photos waiting": "Fotos warten", + "starts using it for AI Denoise": "verwendet es dann für KI-Entrauschen", + "terms unclear": "Bedingungen unklar", + "the graphics card": "die Grafikkarte", + "the graphics card is not used": "die Grafikkarte wird nicht verwendet", + "Cancel Denoise": "Entrauschen abbrechen", + "Cancel Denoise Model Download": "Download des Entrauschungsmodells abbrechen", + "Choose Denoise Model": "Entrauschungsmodell wählen", + "Clear Denoise Cache": "Entrauschungs-Cache leeren", + "Denoise Model Downloads": "Downloads von Entrauschungsmodellen", + "Denoise Photos": "Fotos entrauschen", + "Denoise Pump": "Entrauschungs-Warteschlange", + "Denoise Status": "Entrauschungsstatus", + "Download Denoise Model": "Entrauschungsmodell herunterladen", + "Remove Denoise Model": "Entrauschungsmodell entfernen", + "Test Denoise Model": "Entrauschungsmodell testen", + "AI Denoise is enabled on the requesting photo": "KI-Entrauschen ist für das anfragende Foto aktiviert", + "After installation, AI Denoise will turn on for the requesting photo.": "Nach der Installation wird KI-Entrauschen für das anfragende Foto eingeschaltet.", + "Cancel pending action": "Ausstehende Aktion abbrechen", + "Install a denoise model to enable AI Denoise on this photo": "Installiere ein Entrauschungsmodell, um KI-Entrauschen für dieses Foto zu aktivieren", + "Pending photo action cancelled": "Ausstehende Fotoaktion abgebrochen", + "Pending photo action cancelled because its library or photo changed": "Ausstehende Fotoaktion abgebrochen, weil sich die Bibliothek oder das Foto geändert hat", + "ONNX model": "ONNX-Modell", + "commercial use subject to GPL conditions": "kommerzielle Nutzung unter GPL-Bedingungen", "Decrease Rating": "Bewertung verringern", "Increase Rating": "Bewertung erhöhen", "Click to type a value": "Klicken, um einen Wert einzugeben", @@ -2109,5 +2109,46 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Add Face Model…": "Gesichtsmodell hinzufügen…", + "Cancel Face Model Download": "Download des Gesichtsmodells abbrechen", + "Cancel Pending Photo Action": "Ausstehende Fotoaktion abbrechen", + "Choose Face Recognition Model": "Gesichtserkennungsmodell wählen", + "Choose a model below (Use) to start.": "Wähle unten ein Modell („Verwenden“), um zu beginnen.", + "Detect Faces": "Gesichter erkennen", + "Detect Faces (Photo menu) needs it: a small one-time download.": "„Gesichter erkennen“ (Menü „Foto“) benötigt es: ein kleiner, einmaliger Download.", + "Download Face Model": "Gesichtsmodell herunterladen", + "Drag Crop Handle": "Freistellungsgriff ziehen", + "Evaluate Face Recognition": "Gesichtserkennung bewerten", + "Face Model Downloads": "Downloads von Gesichtsmodellen", + "Face Models": "Gesichtsmodelle", + "Face Recognition On/Off": "Gesichtserkennung ein/aus", + "Face detector": "Gesichtsdetektor", + "Face recognition": "Gesichtserkennung", + "Faces": "Gesichter", + "Faces of a Person": "Gesichter einer Person", + "Go back to listing them by name": "Wieder nach Namen auflisten", + "Index Faces": "Gesichter indizieren", + "Index Faces in the Background": "Gesichter im Hintergrund indizieren", + "Inspect Face Model File": "Gesichtsmodelldatei prüfen", + "Install Face Model": "Gesichtsmodell installieren", + "Library Folders": "Bibliotheksordner", + "Name Face": "Gesicht benennen", + "Name Faces": "Gesichter benennen", + "Recognise faces (experimental)": "Gesichter erkennen (experimentell)", + "Recognition models": "Erkennungsmodelle", + "Remove Face Model": "Gesichtsmodell entfernen", + "Reorder Album": "Album umordnen", + "Show Person": "Person anzeigen", + "Sort Albums A–Z": "Alben A–Z sortieren", + "Sort Albums by Name": "Alben nach Namen sortieren", + "Sort Contents A–Z": "Inhalt A–Z sortieren", + "Suggest Names for Faces": "Namen für Gesichter vorschlagen", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Schlägt anhand der von dir benannten Gesichter vor, wer auf einem Foto zu sehen ist. Alles bleibt auf deinem Computer.", + "Test Face Model": "Gesichtsmodell testen", + "This build cannot run recognition models: they can be added and chosen, not used.": "Diese Version kann keine Erkennungsmodelle ausführen: Sie lassen sich hinzufügen und auswählen, aber nicht verwenden.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Diese Version hat keinen Speicherort für Gesichtsmodelle, daher ist die Modellliste nicht verfügbar. (Die Desktop-App schon.)", + "Unnamed Faces": "Unbenannte Gesichter", + "smart album value|No": "Nein", + "smart album value|Yes": "Ja" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..2a81be427 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -197,23 +197,23 @@ "{} label (click more labels to show any of them)": "Etiqueta {} (hacer clic en más etiquetas para mostrar cualquiera de ellas)", ", {} star{}": ", {} estrella{}", ", {} label": ", etiqueta {}", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Se descarga de {host}. Una vez recibido y comprobado, LightCraft lo instala y {after}.", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "Se ejecuta en el procesador: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Se guardan en la carpeta «denoise» de la biblioteca; al llegar al límite se eliminan primero las más antiguas que ninguna foto usa.", + "Denoising {name}: {done} of {total} tiles{}": "Reduciendo ruido de {name}: {done} de {total} mosaicos{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{} de {} de {host}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "Generando la imagen sin ruido… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "Entrenado con: {provenance}", + " · {queued} waiting": " · {queued} en espera", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Muestra las condiciones del modelo, luego descarga {} de {host}, lo instala y empieza a usarlo. No se envía nada más.", + "In line for AI Denoise ({ahead} ahead)…": "En cola para la reducción de ruido con IA ({ahead} por delante)…", + "Running on {}.": "Se ejecuta en {}.", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Poner dentro de “{parent}”", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..26e93c9c9 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -126,7 +126,7 @@ "Back to Edit (E)": "Volver a Editar (E)", "Back to Grid": "Volver a la cuadrícula", "Background": "Fondo", - "Balance": "Balance", + "Balance": "Equilibrio", "Basic": "Básico", "Before": "Antes", "Before/After (Y)": "Antes/Después (Y)", @@ -1658,96 +1658,96 @@ "Captured until": "Capturadas hasta", "Only": "Solo", "Open Log Folder": "Abrir carpeta de registros", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", - "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", - "Cache": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", - "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "Next up for AI Denoise…", - "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", - "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", - "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "This build cannot run denoise models.", - "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Back to this person": "Volver a esta persona", + "AI Denoise": "Reducción de ruido con IA", + "AI Denoise Model": "Modelo de reducción de ruido con IA", + "AI Denoise is off: press Use on a model.": "La reducción de ruido con IA está desactivada: pulsar Usar en un modelo.", + "AI Denoise needs a model. Install one in Settings.": "La reducción de ruido con IA necesita un modelo. Instalar uno en Ajustes.", + "Accept & Download": "Aceptar y descargar", + "Already installed. Remove it before installing a replacement.": "Ya instalado. Quitarlo antes de instalar otro en su lugar.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automático usa la tarjeta gráfica cuando es más rápida que el procesador: ambos se cronometran en este ordenador la primera vez que se genera una foto.", + "Cache": "Caché", + "Checking and installing the model…": "Comprobando e instalando el modelo…", + "Checking…": "Comprobando…", + "Clear cache": "Vaciar caché", + "Denoise Models": "Modelos de reducción de ruido", + "Denoise Settings": "Ajustes de reducción de ruido", + "Denoise all photos with an Amount": "Reducir el ruido de todas las fotos con una cantidad", + "Downloaded and checked. Installing…": "Descargado y comprobado. Instalando…", + "Failed its last test": "Falló su última prueba", + "Graphics card": "Tarjeta gráfica", + "I have read these terms and accept them for my own use": "He leído estas condiciones y las acepto para mi propio uso", + "Inspect Denoise Model": "Inspeccionar modelo de reducción de ruido", + "Install Denoise Model": "Instalar modelo de reducción de ruido", + "Install from file…": "Instalar desde archivo…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Instalar un modelo ONNX propio con denoise-model.json al lado. Esta compilación no ofrece descargas de modelos.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Su imagen sin ruido aún no se ha generado, y las imágenes no se generan por sí solas (Ajustes, Reducción de ruido con IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft no conoce este modelo, así que ha supuesto:", + "Limit": "Límite", + "Make it now": "Generarla ahora", + "Make the denoised picture of the photos I am looking at": "Generar la imagen sin ruido de las fotos que estoy viendo", + "Next up for AI Denoise…": "Siguiente en la reducción de ruido con IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "No se descarga nada hasta aceptar. El modelo se guarda solo en este ordenador; LightCraft nunca lo sube ni lo comparte.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Solo se generan las fotos cuya cantidad de reducción de ruido con IA es mayor que 0. Las exportaciones siempre generan la suya si falta.", + "Open page": "Abrir página", + "Open the model's page": "Abrir la página del modelo", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Abre la página del modelo en el navegador, para informarse sobre él u obtener el archivo manualmente.", + "Processor": "Procesador", + "Set up AI Denoise…": "Configurar la reducción de ruido con IA…", + "Speed": "Velocidad", + "Starting…": "Iniciando…", + "Tested and works on this computer": "Probado y funciona en este ordenador", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "El modelo de reducción de ruido está instalado y en uso: el regulador Cantidad de la reducción de ruido con IA en Detalle ya funciona", + "The graphics card is set up when the next photo is made.": "La tarjeta gráfica se prepara cuando se genere la próxima foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "El modelo se guarda solo en este ordenador. LightCraft nunca lo sube ni lo comparte.", + "The processor does the work.": "El procesador hace el trabajo.", + "There is no model file to install": "No hay ningún archivo de modelo que instalar", + "This build cannot run denoise models.": "Esta compilación no puede ejecutar modelos de reducción de ruido.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Esta compilación no tiene dónde guardar modelos de reducción de ruido, así que la reducción de ruido con IA no está disponible. (La aplicación de escritorio sí).", + "This file cannot be used yet": "Este archivo aún no se puede usar", + "Tick the box to accept the model's terms first": "Marcar primero la casilla para aceptar las condiciones del modelo", + "Try again": "Reintentar", + "Try the graphics card again": "Volver a probar la tarjeta gráfica", + "Turn off": "Desactivar", + "Unknown licence": "Licencia desconocida", + "Use": "Usar", + "Waiting to start…": "Esperando para empezar…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funciona con archivos raw de cámaras con sensor estándar (Bayer). Los demás archivos conservan su reducción de ruido normal.", + "cached picture": "imagen en caché", + "cached pictures": "imágenes en caché", + "commercial use allowed": "uso comercial permitido", "denoiseModel.accept": "denoiseModel.accept", - "in use": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "Pending photo action cancelled": "Pending photo action cancelled", - "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", - "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "in use": "en uso", + "installed": "instalado", + "makes AI Denoise available": "habilita la reducción de ruido con IA", + "non-commercial use only": "solo uso no comercial", + "photo has its denoised picture.": "foto tiene su imagen sin ruido.", + "photo waiting": "foto en espera", + "photos have their denoised picture.": "fotos tienen su imagen sin ruido.", + "photos waiting": "fotos en espera", + "starts using it for AI Denoise": "empieza a usarlo para la reducción de ruido con IA", + "terms unclear": "condiciones poco claras", + "the graphics card": "la tarjeta gráfica", + "the graphics card is not used": "no se usa la tarjeta gráfica", + "Cancel Denoise": "Cancelar reducción de ruido", + "Cancel Denoise Model Download": "Cancelar descarga del modelo de reducción de ruido", + "Choose Denoise Model": "Elegir modelo de reducción de ruido", + "Clear Denoise Cache": "Vaciar caché de reducción de ruido", + "Denoise Model Downloads": "Descargas de modelos de reducción de ruido", + "Denoise Photos": "Reducir ruido de las fotos", + "Denoise Pump": "Cola de reducción de ruido", + "Denoise Status": "Estado de la reducción de ruido", + "Download Denoise Model": "Descargar modelo de reducción de ruido", + "Remove Denoise Model": "Quitar modelo de reducción de ruido", + "Test Denoise Model": "Probar modelo de reducción de ruido", + "AI Denoise is enabled on the requesting photo": "La reducción de ruido con IA se activa en la foto que la solicitó", + "After installation, AI Denoise will turn on for the requesting photo.": "Tras la instalación, la reducción de ruido con IA se activará en la foto que la solicitó.", + "Cancel pending action": "Cancelar acción pendiente", + "Install a denoise model to enable AI Denoise on this photo": "Instalar un modelo de reducción de ruido para activar la reducción de ruido con IA en esta foto", + "Pending photo action cancelled": "Acción pendiente de la foto cancelada", + "Pending photo action cancelled because its library or photo changed": "Acción pendiente de la foto cancelada porque cambió su biblioteca o su foto", + "ONNX model": "Modelo ONNX", + "commercial use subject to GPL conditions": "uso comercial sujeto a las condiciones de la GPL", "Decrease Rating": "Reducir valoración", "Increase Rating": "Aumentar valoración", "Click to type a value": "Hacer clic para escribir un valor", @@ -1951,5 +1951,204 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«guardado en memoria pero no escrito en el disco», la barra superior muestra un aviso y LightCraft lo sigue intentando hasta que el", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): un DNG se comprueba con los datos raw, se escribe en un archivo temporal, se sincroniza y se vuelve a leer antes de recibir su", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Ayuda → Información del sistema y Ajustes → Rendimiento indican por qué no se usa la GPU", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(p. ej., D750, D780, D850, D7500, Z 50) ya no se ven casi negros ni con las sombras empastadas: su nivel de negro se", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(issue #94): una solicitud HTTP desde una página web ya no puede llevar un comando en su cuerpo. Las líneas se limitan a", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 en studio-mac)») en lugar de dejar que ambos escriban y se descarten en silencio las ediciones. Un bloqueo nunca deja la", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "vista previa de 1620 × 1080, y se importan con sus metadatos: hora de captura, cámara, objetivo, exposición, GPS y XMP. Sus datos raw", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nuevos ajustes preestablecidos integrados: Retrato, Paisaje, Urbano, Comida, Estaciones, Vintage y virados de ByN.", + "4 MiB and connections at 16.": "4 MiB y las conexiones a 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "con controladores 630): en Windows, la ventana y el procesamiento en GPU usan solo DirectX 12 y nunca cargan el controlador Vulkan salvo que", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Un cambio que no se puede guardar en el disco (unidad llena o desconectada) ya no pasa desapercibido: el comando indica", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Una biblioteca solo está abierta en un programa a la vez (issue #99): abrir una biblioteca que LightCraft o `lightcraft-cli` ya", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Un guardado que falla a medias (disco lleno, una carpeta de red que se desconecta) ya no parece un catálogo dañado", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Un NAS en reposo, una carpeta de red caída o una unidad USB que arranca ya no bloquean la ventana (issue #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Máscaras con IA mediante SAM 3 (Objeto y Describir en el panel Máscaras): hacer clic en un objeto para seleccionarlo (⌥-clic deja una", + "Add Face Model…": "Añadir modelo de caras…", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Sincronización automática: las ediciones se aplican a todas las fotos seleccionadas. Mezcla de ByN automática. Versiones automáticas.", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Etiquetado automático desde registro de track: ubicaciones GPS para las fotos a partir de un registro GPX, emparejadas por hora de captura.", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Versión para navegador (experimental), para mantener segura una biblioteca (issue #107): Archivo → Hacer copia de seguridad de la biblioteca… descarga el catálogo", + "Cancel Face Model Download": "Cancelar descarga del modelo de caras", + "Cancel Pending Photo Action": "Cancelar acción pendiente de la foto", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Fotos CR2 de Canon de las EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II y 1D Mark IV (y otros", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Los archivos CR3 de Canon muestran su JPEG incrustado a tamaño completo (p. ej., 6960 × 4640 en una EOS R6 Mark III) en lugar de la", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Los archivos C-RAW de la Canon EOS R5 Mark II que se abrían como «solo vista previa» (más de la mitad, según la", + "Choose Face Recognition Model": "Elegir modelo de reconocimiento facial", + "Choose a model below (Use) to start.": "Elegir un modelo abajo (Usar) para empezar.", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Mayús+R, conserva la foto y borra sus ediciones).", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtro de etiquetas de color con varias etiquetas a la vez; árbol de carpetas desplegable en Local.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Máscaras de rango de color: hacer clic en la foto para tomar una muestra. Rangos de luminancia: barra de rango, suavizado, mapa de luminancia.", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "NEF de Nikon comprimidos (con y sin pérdida, de 12 y 14 bits; p. ej., D3200, D5100, D7000, D750, D850, Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Convertir a DNG, Copiar como DNG, Combinar fotos y las vistas previas inteligentes ya no escriben directamente en el archivo final (issue", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Estado del copyright, condiciones de uso de los derechos y URL de información de copyright en Información, ajustes preestablecidos de metadatos y exportaciones.", + "Detect Faces": "Detectar caras", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar caras (menú Foto) lo necesita: una pequeña descarga única.", + "Download Face Model": "Descargar modelo de caras", + "Drag Crop Handle": "Arrastrar tirador de recorte", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ajuste de borde, y reciben en segundo plano una pasada más nítida al ampliar. El modelo se ejecuta dentro de LightCraft en Rust puro", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Editar en editor externo (⇧⌘E): una copia TIFF de 16 bits, apilada, que se actualiza al volver.", + "Evaluate Face Recognition": "Evaluar reconocimiento facial", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Los nombres de los archivos exportados usan las etiquetas de Cambiar nombre de las fotos ({title}, {seq:2}, {date:%Y-%m-%d}…), más las nuevas {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Los archivos exportados se escriben en un archivo temporal y luego se renombran en su lugar, así que un disco lleno o una unidad desconectada", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Las exportaciones y los procesados nunca sobrescriben el original de una foto (issue #93): exportar a la propia carpeta de la foto con", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Las exportaciones nunca salen negras por culpa de la GPU (issue #78): un procesado en GPU que se queda sin", + "Face Model Downloads": "Descargas de modelos de caras", + "Face Models": "Modelos de caras", + "Face Recognition On/Off": "Reconocimiento facial activado/desactivado", + "Face detector": "Detector de caras", + "Face recognition": "Reconocimiento facial", + "Faces": "Caras", + "Faces of a Person": "Caras de una persona", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Exportaciones e importaciones desde tarjeta más rápidas, con la misma protección para lo que no se puede volver a crear (issue #134): las exportaciones,", + "Go back to listing them by name": "Volver a ordenarlos por nombre", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Si la biblioteca no se puede abrir al iniciar (abierta en otro programa, ilegible, en una unidad no conectada,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importar ajustes preestablecidos de otros editores: ajustes XMP, archivos clásicos `.lrtemplate`, «ajustes DNG» de aplicaciones móviles y paquetes `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Importar → Copiar verifica cada copia, como Mover (issue #96): cada archivo se escribe como un archivo nuevo, se sincroniza con el disco y", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Importar → Mover: las fotos van al destino (con las mismas carpetas y cambios de nombre que Copiar, p. ej.,", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Importación: copiar a cualquier carpeta, por día / por mes / en una carpeta, cambiar el nombre al importar, ajuste preestablecido de metadatos, Copiar como DNG.", + "Index Faces": "Indexar caras", + "Index Faces in the Background": "Indexar caras en segundo plano", + "Inspect Face Model File": "Inspeccionar archivo del modelo de caras", + "Install Face Model": "Instalar modelo de caras", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG con el mismo nombre (`IMG_0001.CR3` + `IMG_0001.JPG`) ya no comparten un archivo complementario: el raw conserva `IMG_0001.xmp`,", + "Library Folders": "Carpetas de la biblioteca", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 y anteriores la rechazan («unsupported format … v2»).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft ya no se bloquea al iniciar en PC con Windows cuyo controlador Vulkan está dañado (issue #136, p. ej., algunas Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "Los archivos DNG con pérdida y las vistas previas inteligentes se abren como fotos raw.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Looks de Luminar: los archivos `.lmp` y las colecciones `.mplumpack` se importan como ajustes preestablecidos (agrupados por colección); los reguladores", + "Name Face": "Poner nombre a la cara", + "Name Faces": "Poner nombre a las caras", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Los NEF de Nikon parten de un aspecto de color y tono ajustado al JPEG de la propia cámara, como los ARW de Sony, en lugar de un procesado neutro apagado", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon divide en dos mitades comprimidas de forma distinta siguen usando la vista previa por ahora).", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Los raw de Panasonic y Leica (RW2, RWL y los archivos RAW antiguos, de la DMC-LX1 a la DC-S1R II) se revelan a partir de los datos raw en", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menú Foto. La tira de diapositivas también tiene el menú contextual de la foto. Volver a añadir un archivo que está en Eliminadas recientemente ya no", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Las fotos de Eliminadas recientemente se pueden restaurar desde la aplicación: clic derecho → Restaurar (o Eliminar definitivamente), también en el", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "fotos que faltan); la importación (también arrastrando y soltando), Buscar fotos que faltan, Generar / Descartar vistas previas inteligentes, la vista previa de Cambiar nombre,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Colección rápida y álbum de destino (B en la cuadrícula), conjuntos de palabras clave (⌥1–⌥9), conjuntos de etiquetas de color.", + "Recognise faces (experimental)": "Reconocer caras (experimental)", + "Recognition models": "Modelos de reconocimiento", + "Remove Face Model": "Quitar modelo de caras", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Cambiar nombre de carpeta… y Mover carpeta a… (Local) se pueden deshacer y rehacer (issue #97): la carpeta vuelve a su sitio en el disco con", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Cambiar nombre de las fotos nunca sobrescribe otra foto cuando solo cambian las mayúsculas y minúsculas (issue #95): en", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Cambiar nombre de las fotos indica los archivos que no pudo devolver a su sitio tras un fallo (issue #105), p. ej., cuando una carpeta de red se desconecta", + "Reorder Album": "Reordenar álbum", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Guardar metadatos en un archivo XMP complementario escrito por otra aplicación ya no lo sustituye (issue #92): LightCraft combina sus", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Los archivos de ajustes nunca se restablecen en silencio (issue #103): un `prefs.json`, `presets.json` o `view.json` dañado se conserva", + "Show Person": "Mostrar persona", + "Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Catálogos más pequeños y rápidos: las fotos que solo se vieron en Local (nunca añadidas, valoradas ni editadas) se olvidan cuando", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Álbumes inteligentes con editor de reglas: coincidir con todas / alguna / ninguna, grupos anidados, 26 campos.", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,", + "Sort Albums A–Z": "Ordenar álbumes A–Z", + "Sort Albums by Name": "Ordenar álbumes por nombre", + "Sort Contents A–Z": "Ordenar contenido A–Z", + "Suggest Names for Faces": "Sugerir nombres para las caras", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Sugiere quién aparece en una foto a partir de las caras con nombre. Todo se queda en el ordenador.", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "series TZ/ZS, las Leica D-Lux, V-Lux y C-Lux, y la generación GH6, GH7, G9 II, S5 II y S9. Parten de un", + "Test Face Model": "Probar modelo de caras", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "El catálogo tiene una versión de formato (issue #102). Abrir una biblioteca de un LightCraft anterior la actualiza; una biblioteca", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "El puerto de control (`--control`) cierra una conexión en cuanto recibe algo que no es una solicitud JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "La caché de miniaturas solo cuenta y elimina sus propios archivos (issue #98): una biblioteca abierta en una carpeta que", + "This build cannot run recognition models: they can be added and chosen, not used.": "Esta compilación no puede ejecutar modelos de reconocimiento: se pueden añadir y elegir, pero no usar.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Esta compilación no tiene dónde guardar modelos de caras, así que la lista de modelos no está disponible. (La aplicación de escritorio sí).", + "Unnamed Faces": "Caras sin nombre", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Importación automática desde carpeta vigilada; Convertir a DNG; Duplicar; Generar vistas previas estándar / 1:1 / inteligentes.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "sin guardar y Salir (issue #100). Ya no inicia en silencio una sesión de demostración que parecía una biblioteca restablecida y", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) junto con sus archivos XMP complementarios; cada original sale de la tarjeta solo cuando su", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; en volúmenes que no distinguen mayúsculas, el cambio de mayúsculas se aplica igualmente.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "una unidad llena) ya no cuenta como generada y Generar vistas previas inteligentes la sustituye.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "después (issue #101): la escritura parcial se recorta antes de que LightCraft lo reintente, así que el siguiente inicio reproduce cada", + "again. Change the period (or turn it off) in Settings → Performance.": "de nuevo. Cambiar el periodo (o desactivarlo) en Ajustes → Rendimiento.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "con los originales de la biblioteca ya no recorre toda la biblioteca cuando no hay nada en esa ruta.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ya tiene una carpeta `thumbs/` con otras imágenes ya no las pierde al recortar o vaciar la caché.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "y comprueba el archivo antes de usarlo. Las máscaras conservan su selección, así que se procesan y exportan sin el modelo.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "y todas las fotos importadas en un zip, y Archivo → Restaurar biblioteca desde copia de seguridad… la recupera (la biblioteca actual", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "y nunca bloquea la ventana. Es opcional y no forma parte de LightCraft (licencia SAM de Meta, unos 3,4 GB): la", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "como `<name>.corrupt-<time>` y se avisa; uno que no se puede leer (p. ej., bloqueado por otro programa) se deja como está", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "se pida. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (o `WGPU_BACKEND` de wgpu, que el procesado en GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "con cualquiera de ellos, carpetas enteras de una vez, agrupados por paquete. Las máscaras de los ajustes preestablecidos se incluyen.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "cámara ya no queda incorporado. Las fotos ya importadas como «solo vista previa» cambian al recargarlas. (Los archivos que", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "cambio. Los catálogos que ya contienen un fragmento así se cargan por completo. Salir mientras no se puede escribir el registro del catálogo", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "no se pudieron guardar los cambios lo intenta una vez más y luego pregunta: Volver a intentar guardar, Salir de todos modos o Cancelar.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "o de MCP, una ruta de vista previa de combinación o `lightcraft-cli render IMG.jpg -o IMG.jpg` se rechaza con un", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "se compara con el contenido leído de la tarjeta. Una copia que falla o difiere se elimina y se indica como", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "aspecto de color y tono ajustado al JPEG de la propia cámara, como los raw de Nikon y Sony, encuadrado en la relación de aspecto definida en", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "copia está verificada y en la biblioteca. Los duplicados y los archivos que fallan se quedan donde estaban.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "aún no se descodifican, así que se quedan como solo vista previa. Para los CR3 importados antes, Foto → Recargar desde el disco (ahora en", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "se recortan, y los archivos «12 bits sin comprimir» ya no se recortan en las luces. La serie RX100 se ve mucho más cerca", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "todos los formatos que escriben las cámaras. La mayoría de los cuerpos se abrían antes como solo vista previa: GH1–GH5, las series G, GX, GF, GM, FZ, LX y", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "exportación. El catálogo, los archivos XMP complementarios, los ajustes, los DNG, las combinaciones, las vistas previas inteligentes y las copias que hace una importación siguen", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "campos y conserva el resto (p. ej., los ajustes de revelado y el historial de edición de esa aplicación) byte a byte. Un", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "archivo (en estos cuerpos Sony los guarda solo en datos cifrados de la nota del fabricante), las pocas columnas de relleno del borde", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "primera vez que se usa una máscara con IA, LightCraft pregunta antes de descargarlo, muestra el progreso, permite cancelar y reanudar,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "durante la sesión en lugar de sobrescribirse con los valores predeterminados. Los ajustes de la aplicación (`ui.json`, que recuerda la", + "gets -1, -2… instead of being replaced.": "recibe -1, -2… en lugar de sustituirse.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "y verdoso (issue #150); el balance de blancos se ajusta respecto al aspecto de toma. Los NEF de 12 bits", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "tiene abierta (en este ordenador u otro que comparte la carpeta) indica quién la tiene («already open in LightCraft (process", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "cómo restaurarla o eliminarla definitivamente e importarla de nuevo. (Para empezar de cero con una foto, Restablecer ediciones,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ignoraba antes) elige el backend gráfico; `off` procesa en la CPU. La GPU ahora se inicia después de que la ventana", + "immutable, and HOSTING.md describes the actual build.": "como inmutables, y HOSTING.md describe la compilación real.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importación, de modo que «importación completada» significa que las copias están bien antes de formatear la tarjeta, y un nombre ocupado", + "imported as \"preview only\" switch over on Reload.": "importadas como «solo vista previa» cambian al recargarlas.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "importadas antes cambian con Foto → Recargar desde el disco.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "en trozos cortos para que el watchdog no reinicie las GPU integradas lentas. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "incompleto se rehace en la CPU; el archivo es la misma imagen en ambos casos. El trabajo se envía a la GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "se conserva). Un guardado fallido (almacenamiento lleno) ahora muestra el aviso de cambios sin guardar y se reintenta, una foto que no se puede almacenar", + "is refused and nothing is overwritten.": "se rechaza y no se sobrescribe nada.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "aparece, y solo cuando Ajustes → Rendimiento → Usar la GPU para procesar está activado; si LightCraft se cierra inesperadamente al", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "no se añade, una segunda pestaña muestra un mensaje en lugar de sobrescribir la primera, `?reset` pregunta antes y la página indica", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "al otro (issue #92). Buscar fotos que faltan también encuentra archivos renombrados por su contenido, prefiere una coincidencia de contenido", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "sus fotos y archivos XMP complementarios, y las fotos vuelven a apuntar a ella. Si ahora algo ocupa el lugar anterior, deshacer", + "layout is read from each file instead of assumed.": "disposición se lee de cada archivo en lugar de suponerse.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "no deja ningún DNG y conserva el raw. Las vistas previas inteligentes se escriben igual; una dañada (cortada por un bloqueo o", + "library locked: the lock is the operating system's and goes away with the program.": "biblioteca bloqueada: el bloqueo es del sistema operativo y desaparece con el programa.", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteca) se escriben de forma atómica y se guardan en cuanto se abre otra biblioteca, no solo al salir. Salir cuando", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "se limita a decir «duplicado omitido»: abre el panel lateral en Eliminadas recientemente con la foto seleccionada e indica", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "lo perdía todo al salir; una sesión temporal muestra un aviso todo el tiempo y nunca escribe en la biblioteca.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "memoria del dispositivo, supera los límites de búfer de la GPU, sufre un error o reinicio del controlador, o vuelve", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "mensaje claro, y el original queda intacto byte a byte. Las exportaciones anteriores normales se siguen sobrescribiendo cuando se pide.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "a mitad del lote: el error los enumera (antiguo → nuevo) y la biblioteca apunta a sus nuevos nombres (un cambio de nombre parcial que se puede deshacer),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modelos cuyo sensor empieza por una fila verde-azul) ya no salen magenta (issue #85): la disposición del filtro de color", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nombre (sin sustituir nunca un archivo), y solo entonces se vuelve a vincular la foto o se elimina la copia raw; una escritura fallida", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "nunca dejan un archivo truncado; el archivo XMP complementario de una exportación «Original» también sigue la opción «Si el archivo existe».", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ahora se revelan a partir de los datos raw en lugar del JPEG incrustado de la cámara, así que un estilo de imagen ByN u otro definido en la", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "en la tarjeta después de Revisar importación se indica en lugar de importarse con datos obsoletos. Comprobar una ruta de exportación", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "uno a uno: siempre se pueden volver a exportar, y en una unidad USB o un NAS esa sincronización por archivo dominaba una gran", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "a un archivo parecido con el mismo nombre y tamaño, y omite (e indica) las fotos que no puede distinguir en lugar de adivinar.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "parte fuera), o escribir qué seleccionar («cielo», «el coche rojo», «coche, carretera»); ambas se combinan con otras máscaras, tienen un", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "conservando la edición RAW y la exportación a resolución completa en lugar de usar las vistas previas JPEG incrustadas.", + "read in the wrong units.": "leía en unidades incorrectas.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "leer la tarjeta una tercera vez (Mover sigue comparando byte a byte antes de eliminar un origen); un archivo que cambió", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "los procesados y las capturas de pantalla se siguen escribiendo en un archivo temporal y renombrando en su lugar, pero ya no se fuerzan al disco", + "save goes through.": "guardado se completa.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "escena) ahora se revelan a partir de los datos del sensor con todas las herramientas de edición, como los demás raw de la cámara. Las fotos", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "modelos hermanos) ya no se abren de color verde intenso (issue #148): su balance de blancos de toma y su nivel de negro se leen del", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "archivo complementario que no es XMP válido se copia antes a `<name>.xmp.bak-<time>`. Con la denominación predeterminada por raíz, un raw y un", + "smart album value|No": "No", + "smart album value|Yes": "Sí", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "para que ninguna aparezca como que falta. Cambiar el nombre de uno de un par raw + JPEG copia su `IMG_0001.xmp` compartido en lugar de quitárselo", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "iniciar la GPU, el siguiente inicio arranca con el procesado en GPU desactivado e indica cómo volver a activarlo.", + "still saves your queued changes in the closing snapshot.": "sigue guardando los cambios en cola en la instantánea de cierre.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "sincronizaban. Importar → Copiar ahora comprueba cada copia con el hash de contenido tomado al explorar la tarjeta en lugar de", + "the JPEG uses `IMG_0001.JPG.xmp`.": "el JPEG usa `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "el árbol de carpetas de Local y la importación automática también leen el disco en hilos de trabajo. La ventana de progreso de la importación tiene Cancelar.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "el menú Foto y el menú contextual de la foto) toma la vista previa a tamaño completo y rellena los metadatos de cámara que", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "la cámara; los defectos del sensor marcados se rellenan. También se importan los archivos `.rwl` y `.raw`. Las fotos ya", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "el mismo nombre y «Sobrescribir» (o Exportar con los ajustes anteriores repitiéndolo), una ruta de salida exacta desde el canal de control", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "su carpeta lleva 30 días sin explorarse; los archivos y sus complementarios se quedan, y explorar la carpeta los muestra", + "to the camera's own JPEG.": "al JPEG de la propia cámara.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "intacta; las versiones anteriores ya no leen parte de ella como un registro dañado. Cuando esta versión ha abierto una biblioteca,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "(p. ej., un adaptador por software omitido como llvmpipe) y por qué el último procesado recurrió a la alternativa.", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "volúmenes que distinguen mayúsculas (Linux, APFS con distinción de mayúsculas) `img_1.JPG` junto a `IMG_1.JPG` es otra foto y la renombrada recibe", + "were missing, without touching anything already set.": "faltaban, sin tocar nada de lo ya definido.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "cuándo el navegador puede expulsar la biblioteca. Alojamiento: los encabezados de caché de ejemplo ya no marcan los archivos (sin hash)", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "si los originales están presentes se comprueba en un hilo de trabajo (miniaturas de la cuadrícula, el panel Información, el menú de la foto,", + "with a counterpart here come along, the rest is listed.": "con un equivalente aquí se incluyen, el resto se enumera.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "escrita por un LightCraft más reciente se rechaza con «this library was written by a newer version of LightCraft» y se deja", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "escrita por un LightCraft más reciente), LightCraft lo indica con el motivo y ofrece Reintentar, Elegir otra biblioteca…, Continuar", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-arrastrar para enderezar, ⇧G Vertical guiado, una cuadrícula al transformar." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..b50417c69 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -163,7 +163,7 @@ "{n} photo{} now · updates automatically as photos change": "{n} photo{} actuellement · mise à jour automatique lorsque les photos changent", "{pid} (missing)": "{pid} (manquant)", "{sel_n} selected · {counted}": "{sel_n} sélectionné(s) · {counted}", - "{stage}… {:.0}%": "{stage}… {:.0}%", + "{stage}… {:.0}%": "{stage}… {:.0} %", "{verb} {n} Photo{}": "{verb} {n} photo{}", "{weekday}, {day}": "{weekday} {day}", "{weekday}, {day} {month} {year}": "{weekday} {day} {month} {year}", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..0057b7b9f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -16,7 +16,7 @@ "AI Mask Model Status": "État du modèle de masques IA", "About": "À propos", "About LightCraft": "À propos de LightCraft", - "Active": "Active", + "Active": "Actif", "Activity": "Activité", "Actual size": "Taille réelle", "Add": "Ajouter", @@ -178,7 +178,7 @@ "Cancel Download": "Annuler le téléchargement", "Cancel Interaction": "Annuler l'interaction", "Cancel Preview Build": "Annuler la génération des aperçus", - "Candidate": "Candidate", + "Candidate": "Candidat", "Caption": "Légende", "Capture Date": "Date de prise de vue", "Capture date and time in your own format (directives below)": "Date et heure de prise de vue dans votre propre format (directives ci-dessous)", @@ -1412,7 +1412,7 @@ "System Info": "Informations système", "System Info…": "Informations système…", "System default": "Valeur par défaut du système", - "Table": "Table", + "Table": "Tableau", "Tags": "Balises", "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Balises : voir Balises à côté du modèle. Les fichiers sont renommés sur le disque (avec leurs fichiers annexes XMP) ; les noms existants reçoivent -1, -2…", "Take a snapshot": "Créer un instantané", @@ -1882,14 +1882,14 @@ "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", "Looking for changes": "Recherche des modifications", - "Arrange Panel": "Arrange Panel", - "Close panel": "Close panel", - "Float panel": "Float panel", - "Move to group": "Move to group", - "Panels": "Panels", - "Reset Panel Layout": "Reset Panel Layout", - "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels", + "Arrange Panel": "Disposer le panneau", + "Close panel": "Fermer le panneau", + "Float panel": "Panneau flottant", + "Move to group": "Déplacer vers le groupe", + "Panels": "Panneaux", + "Reset Panel Layout": "Réinitialiser la disposition des panneaux", + "Resize panel window": "Redimensionner la fenêtre du panneau", + "Resize panels": "Redimensionner les panneaux", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ceci est un catalogue Lightroom, pas une bibliothèque LightCraft. L’importer plutôt dans une bibliothèque LightCraft ?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte.", "Albums hold library photos: Add to My Photos first": "Les albums contiennent des photos de la bibliothèque : ajoutez-les d’abord à Mes photos", @@ -1951,5 +1951,204 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "« enregistré en mémoire mais pas écrit sur le disque », la barre supérieure affiche un avertissement et LightCraft réessaie jusqu'à ce que", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106) : un DNG est vérifié par rapport aux données raw, écrit dans un fichier temporaire, synchronisé et relu avant de recevoir son", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Aide → Informations système et Réglages → Performances indiquent pourquoi le GPU n'est pas", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(p. ex. D750, D780, D850, D7500, Z 50) ne s'affichent plus presque noirs ni avec des ombres bouchées : leur niveau de noir était", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(ticket #94) : une requête HTTP venant d'une page web ne peut plus transporter de commande dans son corps. Les lignes sont limitées à", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 sur studio-mac) ») au lieu de laisser les deux écrire et perdre en silence les retouches de l'autre. Un plantage ne laisse jamais la", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "aperçu de 1620 × 1080, et s'importent avec leurs métadonnées : date de prise de vue, appareil, objectif, exposition, GPS et XMP. Leurs données raw", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nouveaux préréglages intégrés : Portrait, Paysage, Urbain, Cuisine, Saisons, Vintage et virages N&B.", + "4 MiB and connections at 16.": "4 Mio et les connexions à 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630) : sous Windows, la fenêtre et le rendu GPU utilisent uniquement DirectX 12 et ne chargent jamais le pilote Vulkan sauf", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Une modification impossible à enregistrer sur le disque (disque plein ou débranché) n'est plus silencieuse : la commande indique", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Une bibliothèque n'est ouverte que dans un programme à la fois (ticket #99) : ouvrir une bibliothèque que LightCraft ou `lightcraft-cli` a déjà", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Un enregistrement interrompu en cours de route (disque plein, partage réseau qui se déconnecte) ne ressemble plus à un catalogue endommagé", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Un NAS en veille, un partage réseau perdu ou un disque USB qui démarre ne figent plus la fenêtre (ticket #104) :", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Masques IA avec SAM 3 (Objet et Décrire dans le panneau Masquage) : cliquez sur un objet pour le sélectionner (⌥-clic exclut une", + "Add Face Model…": "Ajouter un modèle de visages…", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Synchro auto : les retouches s'appliquent à chaque photo sélectionnée. Mélange N&B automatique. Versions automatiques.", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Géolocalisation depuis un tracé : positions GPS de vos photos tirées d'un tracé GPX, associées par date de prise de vue.", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Version navigateur (expérimentale), protection de la bibliothèque (ticket #107) : Fichier → Sauvegarder la bibliothèque… télécharge le catalogue", + "Cancel Face Model Download": "Annuler le téléchargement du modèle de visages", + "Cancel Pending Photo Action": "Annuler l'action en attente sur la photo", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Les photos CR2 Canon des EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II et 1D Mark IV (et d'autres", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Les fichiers CR3 Canon affichent leur JPEG intégré en pleine taille (p. ex. 6960 × 4640 sur un EOS R6 Mark III) au lieu de l'", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Les fichiers C-RAW du Canon EOS R5 Mark II qui s'ouvraient en « aperçu uniquement » (plus de la moitié, selon la", + "Choose Face Recognition Model": "Choisir le modèle de reconnaissance des visages", + "Choose a model below (Use) to start.": "Choisissez un modèle ci-dessous (Utiliser) pour commencer.", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Maj+R, conserve la photo et efface ses retouches.)", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtre par libellé de couleur avec plusieurs libellés à la fois ; arborescence de dossiers dépliable dans Local.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Masques de plage de couleurs : cliquez sur la photo pour échantillonner. Plages de luminance : barre de plage, lissage, carte de luminance.", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Les NEF Nikon compressés (compression sans perte et avec perte, 12 et 14 bits — p. ex. D3200, D5100, D7000, D750, D850, Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Convertir en DNG, Copier en DNG, Fusion de photos et les aperçus dynamiques n'écrivent plus directement dans le fichier final (ticket", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Statut du copyright, conditions d'utilisation des droits et URL d'informations sur le copyright dans Infos, les préréglages de métadonnées et les exports.", + "Detect Faces": "Détecter les visages", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Détecter les visages (menu Photo) en a besoin : un petit téléchargement unique.", + "Download Face Model": "Télécharger le modèle de visages", + "Drag Crop Handle": "Faire glisser une poignée de recadrage", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "le réglage Bord, et obtiennent en arrière-plan un passage plus net une fois zoomé. Le modèle s'exécute dans LightCraft en Rust pur", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Modifier dans l'éditeur externe (⇧⌘E) : une copie TIFF 16 bits, empilée, actualisée à votre retour.", + "Evaluate Face Recognition": "Évaluer la reconnaissance des visages", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Les noms de fichiers d'export utilisent les jetons de Renommer les photos ({title}, {seq:2}, {date:%Y-%m-%d}…), plus les nouveaux {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Les fichiers exportés sont écrits dans un fichier temporaire puis renommés à leur place ; ainsi un disque plein ou débranché", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Les exports et rendus n'écrasent jamais l'original d'une photo (ticket #93) : exporter dans le dossier de la photo avec", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Les exports ne sont jamais noirs à cause du GPU (ticket #78) : un rendu GPU qui manque de", + "Face Model Downloads": "Téléchargements de modèles de visages", + "Face Models": "Modèles de visages", + "Face Recognition On/Off": "Reconnaissance des visages activée/désactivée", + "Face detector": "Détecteur de visages", + "Face recognition": "Reconnaissance des visages", + "Faces": "Visages", + "Faces of a Person": "Visages d'une personne", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Exports et importations depuis une carte plus rapides, avec la même protection pour ce qui ne peut pas être recréé (ticket #134) : les exports,", + "Go back to listing them by name": "Revenir au tri par nom", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Si votre bibliothèque ne peut pas être ouverte au lancement (ouverte dans un autre programme, illisible, sur un disque non connecté,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importez des préréglages d'autres éditeurs : préréglages XMP, fichiers `.lrtemplate` classiques, « préréglages DNG » d'applications mobiles et des `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Importer → Copier vérifie chaque copie, comme Déplacer (ticket #96) : chaque fichier est écrit comme un nouveau fichier, synchronisé sur le disque et", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Importer → Déplacer : les photos vont dans la destination (avec les mêmes dossiers et le même renommage que Copier, p. ex.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Importation : copie vers n'importe quel dossier, par jour / par mois / un seul dossier, renommage à l'importation, préréglage de métadonnées, Copier en DNG.", + "Index Faces": "Indexer les visages", + "Index Faces in the Background": "Indexer les visages en arrière-plan", + "Inspect Face Model File": "Inspecter le fichier du modèle de visages", + "Install Face Model": "Installer le modèle de visages", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG de même nom (`IMG_0001.CR3` + `IMG_0001.JPG`) ne partagent plus un seul fichier annexe : le raw garde `IMG_0001.xmp`,", + "Library Folders": "Dossiers de la bibliothèque", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 et les versions antérieures la refusent (« unsupported format … v2 »).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft ne plante plus au lancement sur les PC Windows dont le pilote Vulkan est défectueux (ticket #136, p. ex. certains pilotes Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "Les fichiers DNG avec perte et les aperçus dynamiques s'ouvrent comme des photos raw.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Looks Luminar : les fichiers `.lmp` et les collections `.mplumpack` s'importent comme préréglages (groupés par collection) ; les curseurs", + "Name Face": "Nommer le visage", + "Name Faces": "Nommer les visages", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Les NEF Nikon partent d'un rendu des couleurs et des tons ajusté sur le JPEG de l'appareil, comme les ARW Sony, au lieu d'un rendu terne,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon divise en deux moitiés compressées différemment utilisent encore l'aperçu pour l'instant.)", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Les raws Panasonic et Leica (RW2, RWL et les anciens fichiers RAW, du DMC-LX1 au DC-S1R II) se développent à partir des données raw dans", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Photo. Le film fixe a aussi le menu contextuel de la photo. Ajouter de nouveau un fichier qui se trouve dans Supprimées récemment ne", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Les photos de Supprimées récemment peuvent être restaurées depuis l'application : clic droit → Restaurer (ou Supprimer définitivement), aussi dans le", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Photos) ; l'importation (y compris par glisser-déposer), Rechercher les photos manquantes, Générer / Supprimer les aperçus dynamiques, l'aperçu du renommage,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Collection rapide et album cible (B dans la grille), jeux de mots-clés (⌥1–⌥9), jeux de libellés de couleur.", + "Recognise faces (experimental)": "Reconnaître les visages (expérimental)", + "Recognition models": "Modèles de reconnaissance", + "Remove Face Model": "Retirer le modèle de visages", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Renommer le dossier… et Déplacer le dossier vers… (Local) peuvent être annulés et rétablis (ticket #97) : le dossier revient sur le disque avec", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Renommer les photos n'écrase jamais une autre photo quand seule la casse change (ticket #95) : sur les volumes sensibles à la casse", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Renommer les photos signale les fichiers qu'il n'a pas pu remettre en place après un échec (ticket #105), p. ex. quand un partage réseau se déconnecte", + "Reorder Album": "Réordonner l'album", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Enregistrer les métadonnées dans un fichier annexe XMP écrit par une autre application ne le remplace plus (ticket #92) : LightCraft fusionne ses", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Les fichiers de réglages ne sont jamais réinitialisés en silence (ticket #103) : un `prefs.json`, `presets.json` ou `view.json` endommagé est conservé", + "Show Person": "Afficher la personne", + "Slideshow, second window, All Metadata, System Info.": "Diaporama, seconde fenêtre, Toutes les métadonnées, Informations système.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Catalogues plus petits et plus rapides : les photos que vous avez seulement consultées dans Local (jamais ajoutées, notées ni retouchées) sont oubliées dès que", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Albums intelligents avec un éditeur de règles : toutes / au moins une / aucune, groupes imbriqués, 26 champs.", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Les ARW Sony antérieurs à 2017 environ (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II et leurs", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Les ARW sans perte réduits du Sony ILCE-7M4 décodent désormais leurs tuiles YCbCr sous-échantillonnées en RVB linéaire,", + "Sort Albums A–Z": "Trier les albums de A à Z", + "Sort Albums by Name": "Trier les albums par nom", + "Sort Contents A–Z": "Trier le contenu de A à Z", + "Suggest Names for Faces": "Suggérer des noms pour les visages", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Suggère qui figure sur une photo d'après les visages que vous avez nommés. Tout reste sur votre ordinateur.", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "les séries TZ/ZS, les Leica D-Lux, V-Lux et C-Lux, et la génération GH6, GH7, G9 II, S5 II et S9. Ils partent d'un", + "Test Face Model": "Tester le modèle de visages", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Le catalogue a une version de format (ticket #102). Ouvrir une bibliothèque d'une version antérieure de LightCraft la met à niveau ; une bibliothèque", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Le port de contrôle (`--control`) ferme une connexion dès qu'il reçoit autre chose qu'une requête JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Le cache des vignettes ne compte et ne supprime que ses propres fichiers (ticket #98) : une bibliothèque ouverte sur un dossier qui", + "This build cannot run recognition models: they can be added and chosen, not used.": "Cette version ne peut pas exécuter les modèles de reconnaissance : ils peuvent être ajoutés et choisis, pas utilisés.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Cette version n'a pas d'emplacement pour conserver les modèles de visages ; la liste des modèles n'est donc pas disponible. (L'application de bureau en a un.)", + "Unnamed Faces": "Visages sans nom", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Importation automatique d'un dossier surveillé ; Convertir en DNG ; Dupliquer ; Générer les aperçus standard / 1:1 / dynamiques.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "sans enregistrer et quitter (ticket #100). Elle ne démarre plus en silence une session de démonstration qui ressemblait à une bibliothèque réinitialisée et", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) avec leurs fichiers annexes XMP ; chaque original ne quitte la carte qu'une fois sa", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG` ; sur les volumes insensibles à la casse, le changement de casse est quand même appliqué.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "un disque plein) n'est plus considéré comme généré et Générer les aperçus dynamiques le remplace.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "par la suite (ticket #101) : l'écriture partielle est tronquée avant que LightCraft ne réessaie, si bien que le lancement suivant rejoue chaque", + "again. Change the period (or turn it off) in Settings → Performance.": "de nouveau. Modifiez la durée (ou désactivez-la) dans Réglages → Performances.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "par rapport aux originaux de la bibliothèque ne parcourt plus toute la bibliothèque quand rien ne se trouve à cet emplacement.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "contient déjà un dossier `thumbs/` d'autres images ne les perd plus quand le cache est réduit ou vidé.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "et vérifie le fichier avant de l'utiliser. Les masques conservent leur sélection ; ils s'affichent et s'exportent donc sans le modèle.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "et chaque photo importée dans un seul zip, et Fichier → Restaurer la bibliothèque depuis une sauvegarde… la rétablit (la bibliothèque actuelle", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "et ne fige jamais la fenêtre. Il est facultatif et ne fait pas partie de LightCraft (licence SAM de Meta, environ 3,4 Go) : la", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "sous `<name>.corrupt-<time>` et vous en êtes averti ; un fichier illisible (p. ex. verrouillé par un autre programme) est laissé tel quel", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "si on le demande. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (ou `WGPU_BACKEND` de wgpu, que le rendu GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "regroupant n'importe lesquels — des dossiers entiers d'un coup, groupés par pack. Les masques contenus dans les préréglages suivent.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "l'appareil n'est plus figé dans l'image. Les photos déjà importées en « aperçu uniquement » basculent au rechargement. (Les fichiers que", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "modification. Les catalogues contenant déjà un tel fragment se chargent entièrement. Quitter alors que le journal du catalogue ne peut pas être écrit", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "les modifications n'ont pas pu être enregistrées réessaie une fois, puis demande : Réessayer d'enregistrer, Quitter quand même ou Annuler.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "de contrôle ou de MCP, un chemin d'aperçu de fusion ou `lightcraft-cli render IMG.jpg -o IMG.jpg` est refusé avec un", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "comparé au contenu lu sur la carte. Une copie qui échoue ou diffère est supprimée et signalée comme", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "rendu des couleurs et des tons ajusté sur le JPEG de l'appareil, comme les raws Nikon et Sony, cadré au format d'image réglé sur", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "copie vérifiée et dans la bibliothèque. Les doublons et les fichiers en échec restent où ils étaient.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "ne sont pas encore décodées ; ils restent donc en aperçu uniquement. Pour les CR3 importés auparavant, Photo → Recharger depuis le disque (désormais dans", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "droit sont rognées, et les fichiers « 12 bits non compressés » ne sont plus écrêtés. La série RX100 s'affiche bien plus près", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "tous les formats que les appareils écrivent. La plupart des boîtiers s'ouvraient auparavant en aperçu uniquement : GH1–GH5, les G, GX, GF, GM, FZ, LX et", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "export. Le catalogue, les fichiers annexes XMP, les réglages, les DNG, les fusions, les aperçus dynamiques et les copies d'une importation sont toujours", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "champs et conserve le reste — p. ex. les réglages de développement et l'historique de cette application — octet par octet. Un", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "fichier (Sony ne les stocke que dans des données constructeur brouillées sur ces boîtiers), les quelques colonnes de remplissage du bord", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "première fois que vous utilisez un masque IA, LightCraft demande avant de le télécharger, affiche la progression, peut annuler et reprendre,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "pour la session au lieu d'être écrasé par les valeurs par défaut. Les réglages de l'application (`ui.json`, qui retient votre", + "gets -1, -2… instead of being replaced.": "reçoit -1, -2… au lieu d'être remplacé.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "rendu neutre verdâtre (ticket #150) ; la balance des blancs est ajustée par rapport au rendu tel que pris. Les NEF 12 bits", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "ouverte — sur cet ordinateur ou un autre partageant le dossier — indique qui l'a ouverte (« déjà ouverte dans LightCraft (processus", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "comment la restaurer ou la supprimer définitivement et l'importer à nouveau. (Pour repartir de zéro sur une photo, Réinitialiser les retouches,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ignorait auparavant) choisit le moteur graphique ; `off` effectue le rendu sur le CPU. Le GPU démarre désormais après que la fenêtre", + "immutable, and HOSTING.md describes the actual build.": "comme immuables, et HOSTING.md décrit la compilation réelle.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importation — ainsi « importation terminée » signifie que les copies sont bonnes avant de formater la carte — et un nom déjà pris", + "imported as \"preview only\" switch over on Reload.": "importées en « aperçu uniquement » basculent au rechargement.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "importées auparavant basculent avec Photo → Recharger depuis le disque.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "par petits morceaux pour que les GPU intégrés lents ne soient pas réinitialisés par leur chien de garde. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "incomplet est refait sur le CPU — le fichier contient la même image dans tous les cas. Le travail est envoyé au GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "est conservée). Un enregistrement échoué (stockage plein) affiche désormais l'avertissement de non-enregistrement et est retenté, une photo impossible à stocker", + "is refused and nothing is overwritten.": "est refusée et rien n'est écrasé.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "est affichée, et seulement si Réglages → Performances → Utiliser le GPU pour le rendu est activé ; si LightCraft s'arrête brutalement pendant", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "n'est pas ajoutée, un second onglet affiche un message au lieu d'écraser le premier, `?reset` demande d'abord, et la page indique", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "à l'autre (ticket #92). Rechercher les photos manquantes retrouve aussi les fichiers renommés par leur contenu, préfère une correspondance de contenu", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ses photos et fichiers annexes XMP, et les photos pointent de nouveau vers lui. Si quelque chose occupe désormais l'ancien emplacement, l'annulation", + "layout is read from each file instead of assumed.": "disposition est lue dans chaque fichier au lieu d'être supposée.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "ne laisse aucun DNG et conserve le raw. Les aperçus dynamiques sont écrits de la même manière ; un aperçu endommagé (interrompu par un plantage ou", + "library locked: the lock is the operating system's and goes away with the program.": "bibliothèque verrouillée : le verrou appartient au système d'exploitation et disparaît avec le programme.", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "bibliothèque) sont écrits de façon atomique et enregistrés dès que vous ouvrez une autre bibliothèque, pas seulement à la fermeture. Quitter alors que", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "se contente plus d'afficher « doublon ignoré » : il ouvre le panneau latéral sur Supprimées récemment avec la photo sélectionnée et indique", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "perdait tout à la fermeture ; une session temporaire affiche un bandeau en permanence et n'écrit jamais dans votre bibliothèque.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "mémoire, dépasse les limites de tampon du GPU, rencontre une erreur ou une réinitialisation du pilote, ou revient", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "message clair, et l'original reste intact octet par octet. Les exports antérieurs ordinaires sont toujours écrasés sur demande.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "en cours de lot : l'erreur les liste (ancien → nouveau) et la bibliothèque pointe vers leurs nouveaux noms (un renommage partiel annulable),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modèles dont le capteur commence par une rangée vert-bleu) ne sortent plus magenta (ticket #85) : la disposition du filtre coloré", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nom (sans jamais remplacer un fichier), et c'est seulement alors que la photo est reliée ou la copie raw supprimée — une écriture échouée", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "ne laissent jamais de fichier tronqué ; le fichier annexe XMP d'un export « Original » suit aussi le choix « Si le fichier existe ».", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "se développent désormais à partir des données raw au lieu du JPEG intégré de l'appareil ; ainsi un style d'image N&B ou autre réglé sur", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "sur la carte après Vérifier l'importation est signalé au lieu d'être importé avec des informations périmées. Vérifier un chemin d'export", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "un par un — ils peuvent toujours être exportés de nouveau, et sur un disque USB ou un NAS cette synchronisation fichier par fichier dominait un gros", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "à un fichier similaire de même nom et même taille, et ignore (en le signalant) les photos qu'il ne peut pas distinguer au lieu de deviner.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "partie), ou saisissez ce qu'il faut sélectionner (« ciel », « la voiture rouge », « voiture, route ») ; les deux se combinent avec d'autres masques, ont un", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "ce qui préserve la retouche RAW et l'export en pleine résolution au lieu d'utiliser les aperçus JPEG intégrés.", + "read in the wrong units.": "lu dans de mauvaises unités.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "relire la carte une troisième fois (Déplacer compare toujours octet par octet avant de supprimer une source) ; un fichier modifié", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "les rendus et les captures d'écran sont toujours écrits dans un fichier temporaire puis renommés à leur place, mais ne sont plus forcés sur le disque", + "save goes through.": "l'enregistrement aboutisse.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "scène) se développent désormais à partir des données du capteur avec tous les outils de retouche, comme les autres raws de l'appareil. Les photos", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "dérivés) ne s'ouvrent plus en vert vif (ticket #148) : leur balance des blancs telle que prise et leur niveau de noir sont lus depuis le", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "fichier annexe qui n'est pas un XMP valide est d'abord copié vers `<name>.xmp.bak-<time>`. Avec le nommage par défaut sur le nom de base, un raw et un", + "smart album value|No": "Non", + "smart album value|Yes": "Oui", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "afin qu'aucun ne paraisse manquant. Renommer l'un d'une paire raw + JPEG copie leur `IMG_0001.xmp` commun au lieu de le retirer", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "le démarrage du GPU, le lancement suivant démarre avec le rendu GPU désactivé et indique comment le réactiver.", + "still saves your queued changes in the closing snapshot.": "enregistre quand même vos modifications en attente dans l'instantané de fermeture.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronisés. Importer → Copier vérifie désormais chaque copie avec l'empreinte du contenu calculée lors de l'analyse de la carte au lieu de", + "the JPEG uses `IMG_0001.JPG.xmp`.": "le JPEG utilise `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "l'arborescence de dossiers de Local et l'importation automatique lisent aussi le disque sur des threads de travail. La fenêtre de progression de l'importation a un bouton Annuler.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "le menu Photo et le menu contextuel de la photo) récupère l'aperçu en pleine taille et complète les métadonnées de l'appareil qui leur", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "l'appareil ; les défauts du capteur répertoriés sont comblés. Les fichiers `.rwl` et `.raw` sont aussi importés. Les photos déjà", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "le même nom et « Écraser » (ou Exporter avec les réglages précédents qui le répète), un chemin de sortie exact venant du canal", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "leur dossier n'a pas été parcouru depuis 30 jours — vos fichiers et fichiers annexes restent, et parcourir le dossier les affiche", + "to the camera's own JPEG.": "du JPEG de l'appareil.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "intacte — les anciennes versions n'en lisent plus une partie comme un journal endommagé. Une fois qu'une bibliothèque a été ouverte par cette version,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "utilisé (p. ex. un adaptateur logiciel ignoré comme llvmpipe) et pourquoi le dernier rendu s'est replié.", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "(Linux, APFS sensible à la casse), `img_1.JPG` à côté de `IMG_1.JPG` est une autre photo et celle renommée reçoit", + "were missing, without touching anything already set.": "manquaient, sans toucher à ce qui est déjà renseigné.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "quand le navigateur risque d'évincer la bibliothèque. Hébergement : les en-têtes de cache d'exemple ne marquent plus les fichiers (sans empreinte)", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "la présence des originaux est vérifiée sur un thread de travail (vignettes de la grille, panneau Infos, menu de la photo, Photos", + "with a counterpart here come along, the rest is listed.": "qui ont un équivalent ici suivent, le reste est listé.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "écrite par une version plus récente de LightCraft est refusée avec « cette bibliothèque a été écrite par une version plus récente de LightCraft » et laissée", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "écrite par une version plus récente de LightCraft), LightCraft le signale avec la raison et propose Réessayer, Choisir une autre bibliothèque…, Continuer", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-glisser pour redresser, ⇧G Upright guidé, une grille pendant la transformation." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..8e95730c2 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -197,23 +197,23 @@ "{} label (click more labels to show any of them)": "{}ラベル(ほかのラベルもクリックすると、いずれかに一致する写真を表示)", ", {} star{}": "、星{}つ{:.0}", ", {} label": "、{}ラベル", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "{host} からダウンロードします。届いて確認が済むと、LightCraft がインストールし、{after}。", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "処理装置で実行中:{}。", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}。ライブラリの「denoise」フォルダーに保存します。上限に達すると、どの写真にも使われていない古いものから削除します。", + "Denoising {name}: {done} of {total} tiles{}": "{name} のノイズを除去中:{total} タイル中 {done}{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{host} から {} / {}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "ノイズ除去した画像を作成中… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "学習データ:{provenance}", + " · {queued} waiting": " · {queued} 件待機中", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "モデルの条件を表示してから、{host} から {} をダウンロードし、インストールして使用を開始します。それ以外は何も送信しません。", + "In line for AI Denoise ({ahead} ahead)…": "AIノイズ除去の順番待ち(前に {ahead} 件)…", + "Running on {}.": "{} で実行中。", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "「{parent}」の中に入れる", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..73977645c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1658,96 +1658,96 @@ "Install the SAM 3 Model": "SAM 3モデルのインストール", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。", "Open Log Folder": "ログフォルダーを開く", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "Already installed. Remove it before installing a replacement.", - "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.", - "Cache": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", - "LightCraft does not know this model, so it assumed:": "LightCraft does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "Next up for AI Denoise…", - "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works", - "The graphics card is set up when the next photo is made.": "The graphics card is set up when the next photo is made.", - "The model is kept on this computer only. LightCraft never uploads or shares it.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "This build cannot run denoise models.", - "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Back to this person": "この人物に戻る", + "AI Denoise": "AIノイズ除去", + "AI Denoise Model": "AIノイズ除去モデル", + "AI Denoise is off: press Use on a model.": "AIノイズ除去はオフです:モデルの「使用」を押してください。", + "AI Denoise needs a model. Install one in Settings.": "AIノイズ除去にはモデルが必要です。設定でインストールしてください。", + "Accept & Download": "同意してダウンロード", + "Already installed. Remove it before installing a replacement.": "インストール済みです。置き換える前に削除してください。", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "「自動」は処理装置より速い場合にグラフィックカードを使います。最初に写真を作成するとき、このコンピューターで両方の速度を計ります。", + "Cache": "キャッシュ", + "Checking and installing the model…": "モデルを確認してインストールしています…", + "Checking…": "確認中…", + "Clear cache": "キャッシュを消去", + "Denoise Models": "ノイズ除去モデル", + "Denoise Settings": "ノイズ除去の設定", + "Denoise all photos with an Amount": "適用量のあるすべての写真のノイズを除去", + "Downloaded and checked. Installing…": "ダウンロードして確認しました。インストール中…", + "Failed its last test": "前回のテストに失敗", + "Graphics card": "グラフィックカード", + "I have read these terms and accept them for my own use": "この条件を読み、自分の使用のために同意します", + "Inspect Denoise Model": "ノイズ除去モデルを調べる", + "Install Denoise Model": "ノイズ除去モデルをインストール", + "Install from file…": "ファイルからインストール…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "denoise-model.json を横に置いた独自の ONNX モデルをインストールしてください。このビルドにはモデルのダウンロードがありません。", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "ノイズ除去した画像はまだ作成されておらず、自動では作成されません(設定、AIノイズ除去)。", + "LightCraft does not know this model, so it assumed:": "LightCraft はこのモデルを知らないため、次のように仮定しました:", + "Limit": "上限", + "Make it now": "今すぐ作成", + "Make the denoised picture of the photos I am looking at": "表示中の写真のノイズ除去画像を作成", + "Next up for AI Denoise…": "次にAIノイズ除去を行います…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "同意するまで何も取得しません。モデルはこのコンピューターにのみ保存され、LightCraft がアップロードや共有をすることはありません。", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "AIノイズ除去の適用量が 0 より大きい写真だけを作成します。書き出しでは、ない場合は必ず作成します。", + "Open page": "ページを開く", + "Open the model's page": "モデルのページを開く", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "モデルのページをブラウザーで開き、詳細を読んだりファイルを自分で入手したりできます。", + "Processor": "処理装置", + "Set up AI Denoise…": "AIノイズ除去を設定…", + "Speed": "速度", + "Starting…": "開始中…", + "Tested and works on this computer": "このコンピューターでテスト済み、動作します", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "ノイズ除去モデルをインストールして使用中です:ディテールの「AIノイズ除去の適用量」スライダーが使えるようになりました", + "The graphics card is set up when the next photo is made.": "グラフィックカードは次の写真を作成するときに準備されます。", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "モデルはこのコンピューターにのみ保存されます。LightCraft がアップロードや共有をすることはありません。", + "The processor does the work.": "処理装置が処理します。", + "There is no model file to install": "インストールするモデルファイルがありません", + "This build cannot run denoise models.": "このビルドではノイズ除去モデルを実行できません。", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "このビルドにはノイズ除去モデルの保存場所がないため、AIノイズ除去は使えません。(デスクトップ版にはあります。)", + "This file cannot be used yet": "このファイルはまだ使えません", + "Tick the box to accept the model's terms first": "まずチェックボックスをオンにしてモデルの条件に同意してください", + "Try again": "再試行", + "Try the graphics card again": "グラフィックカードをもう一度試す", + "Turn off": "オフにする", + "Unknown licence": "ライセンス不明", + "Use": "使用", + "Waiting to start…": "開始を待っています…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "標準的な(ベイヤー)センサーのカメラの raw ファイルで動作します。それ以外のファイルは通常のノイズ軽減のままです。", + "cached picture": "キャッシュされた画像", + "cached pictures": "キャッシュされた画像", + "commercial use allowed": "商用利用可", "denoiseModel.accept": "denoiseModel.accept", - "in use": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "Pending photo action cancelled": "Pending photo action cancelled", - "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", - "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "in use": "使用中", + "installed": "インストール済み", + "makes AI Denoise available": "AIノイズ除去を使えるようにします", + "non-commercial use only": "非商用利用のみ", + "photo has its denoised picture.": "枚の写真のノイズ除去画像があります。", + "photo waiting": "枚の写真が待機中", + "photos have their denoised picture.": "枚の写真のノイズ除去画像があります。", + "photos waiting": "枚の写真が待機中", + "starts using it for AI Denoise": "AIノイズ除去に使い始めます", + "terms unclear": "条件が不明確", + "the graphics card": "グラフィックカード", + "the graphics card is not used": "グラフィックカードは使用されません", + "Cancel Denoise": "ノイズ除去をキャンセル", + "Cancel Denoise Model Download": "ノイズ除去モデルのダウンロードをキャンセル", + "Choose Denoise Model": "ノイズ除去モデルを選択", + "Clear Denoise Cache": "ノイズ除去キャッシュを消去", + "Denoise Model Downloads": "ノイズ除去モデルのダウンロード", + "Denoise Photos": "写真のノイズを除去", + "Denoise Pump": "ノイズ除去の処理", + "Denoise Status": "ノイズ除去の状態", + "Download Denoise Model": "ノイズ除去モデルをダウンロード", + "Remove Denoise Model": "ノイズ除去モデルを削除", + "Test Denoise Model": "ノイズ除去モデルをテスト", + "AI Denoise is enabled on the requesting photo": "要求した写真でAIノイズ除去が有効になりました", + "After installation, AI Denoise will turn on for the requesting photo.": "インストール後、要求した写真でAIノイズ除去がオンになります。", + "Cancel pending action": "保留中の操作をキャンセル", + "Install a denoise model to enable AI Denoise on this photo": "この写真でAIノイズ除去を使うには、ノイズ除去モデルをインストールしてください", + "Pending photo action cancelled": "保留中の写真操作をキャンセルしました", + "Pending photo action cancelled because its library or photo changed": "ライブラリまたは写真が変更されたため、保留中の写真操作をキャンセルしました", + "ONNX model": "ONNX モデル", + "commercial use subject to GPL conditions": "商用利用は GPL の条件に従う", "Decrease Rating": "評価を下げる", "Increase Rating": "評価を上げる", "Click to type a value": "クリックして値を入力", @@ -1953,5 +1953,202 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "「メモリーには保存されたがディスクに書き込まれていない」と報告し、上部バーに警告を表示して、LightCraft は", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106):DNG は raw データと照合し、一時ファイルに書き込み、同期して読み戻してから", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`、`gpuFallback`)、ヘルプ → システム情報、設定 → パフォーマンスに GPU が使われない", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(例:D750、D780、D850、D7500、Z 50)は、ほぼ真っ黒やシャドウのつぶれた描画にならなくなりました。黒レベルが", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(issue #94):Web ページからの HTTP リクエストが本文でコマンドを送れなくなりました。1 行の上限は", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123、studio-mac)」)、両方が書き込んで互いの編集を黙って失うことはありません。クラッシュしても", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 のプレビューを表示し、撮影日時、カメラ、レンズ、露出、GPS、XMP のメタデータとともに読み込みます。raw", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "新しい内蔵プリセット 23 種:ポートレート、風景、都市、料理、季節、ヴィンテージ、モノクロ調色。", + "4 MiB and connections at 16.": "4 MiB、接続数の上限は 16 です。", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630 ドライバーなど):Windows ではウィンドウと GPU 描画に DirectX 12 のみを使い、Vulkan ドライバーは", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "ディスクに保存できない変更(容量不足や取り外されたドライブ)を黙って失わなくなりました。コマンドは", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "ライブラリは一度に 1 つのプログラムでのみ開けます(issue #99):LightCraft または `lightcraft-cli` がすでに", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "途中で失敗した保存(ディスクの空き不足、切断されたネットワーク共有)が、壊れたカタログのように見えなくなりました", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "スリープ中の NAS、切断されたネットワーク共有、起動中の USB ドライブでウィンドウが固まらなくなりました(issue #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "SAM 3 による AI マスク(マスクパネルの「オブジェクト」と「説明」):オブジェクトをクリックして選択(⌥ クリックで", + "Add Face Model…": "顔モデルを追加…", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "自動同期:選択したすべての写真に編集を適用。自動モノクロミックス。自動バージョン。", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "トラックログから自動タグ付け:GPX トラックログから撮影日時で照合した GPS 位置を写真に付けます。", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "ブラウザー版(試験的)、ライブラリを安全に保つ(issue #107):ファイル → ライブラリをバックアップ… でカタログと", + "Cancel Face Model Download": "顔モデルのダウンロードをキャンセル", + "Cancel Pending Photo Action": "保留中の写真操作をキャンセル", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "EOS 7D、50D、60D、550D、600D、1200D、1300D、5D Mark II、1D Mark IV の Canon CR2 写真(およびその他", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Canon CR3 ファイルは、フルサイズの埋め込み JPEG(例:EOS R6 Mark III で 6960 × 4640)を表示し、", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "「プレビューのみ」で開いていた Canon EOS R5 Mark II の C-RAW ファイル(撮影シーンによっては半数以上", + "Choose Face Recognition Model": "顔認識モデルを選択", + "Choose a model below (Use) to start.": "下のモデルを選んで(使用)開始してください。", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R は写真を残して編集を消去します。)", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "複数のカラーラベルを同時に絞り込めるフィルター、「ローカル」の展開できるフォルダーツリー。", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "カラー範囲マスク:写真をクリックしてサンプリング。輝度範囲:範囲バー、滑らかさ、輝度マップ。", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "圧縮された Nikon NEF(ロスレス圧縮と非可逆圧縮、12 ビットと 14 ビット。例:D3200、D5100、D7000、D750、D850、Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "DNG に変換、DNG としてコピー、写真結合、スマートプレビューは最終ファイルに直接書き込まなくなりました(issue", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "著作権の状態、権利の使用条件、著作権情報 URL を情報パネル、メタデータプリセット、書き出しで扱えます。", + "Detect Faces": "顔を検出", + "Detect Faces (Photo menu) needs it: a small one-time download.": "「顔を検出」(写真メニュー)に必要です:一度だけの小さなダウンロードです。", + "Download Face Model": "顔モデルをダウンロード", + "Drag Crop Handle": "切り抜きハンドルをドラッグ", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "エッジ設定、拡大表示ではバックグラウンドでよりシャープな処理を行います。モデルは LightCraft 内で純粋な Rust で動作し", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "外部エディターで編集(⇧⌘E):16 ビット TIFF のコピーをスタックにし、戻ったときに更新します。", + "Evaluate Face Recognition": "顔認識を評価", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "書き出しのファイル名には「写真の名前を変更」のトークン({title}、{seq:2}、{date:%Y-%m-%d}…)に加え、新しい {num}、{folder}、{lens}、{iso}、{rating}、{creator} を使えます。", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "書き出したファイルは一時ファイルに書き込んでから名前を変えて置き換えるため、ディスクの空き不足や取り外されたドライブでも", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "書き出しと描画が写真の元画像を上書きすることはありません(issue #93):写真と同じフォルダーへ", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "GPU が原因で書き出しが真っ黒になることはありません(issue #78):GPU 描画がデバイスの", + "Face Model Downloads": "顔モデルのダウンロード", + "Face Models": "顔モデル", + "Face Recognition On/Off": "顔認識のオン/オフ", + "Face detector": "顔検出器", + "Face recognition": "顔認識", + "Faces": "顔", + "Faces of a Person": "人物の顔", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "書き出しとカードからの読み込みが速くなり、作り直せないものは同じように保護されます(issue #134):書き出し、", + "Go back to listing them by name": "名前順の一覧に戻る", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "起動時にライブラリを開けない場合(別のプログラムで開いている、読めない、接続されていないドライブにある、", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "他の編集アプリからプリセットを読み込み:XMP プリセット、従来の `.lrtemplate` ファイル、モバイルアプリの「DNG プリセット」、`.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "読み込み → コピーは「移動」と同様にすべてのコピーを検証します(issue #96):各ファイルは新しいファイルとして書き込み、ディスクに同期して", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "読み込み → 移動:写真は読み込み先へ移ります(「コピー」と同じフォルダー分けと名前変更。例:", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "読み込み:任意のフォルダーへコピー、日別/月別/1 フォルダー、読み込み時の名前変更、メタデータプリセット、DNG としてコピー。", + "Index Faces": "顔をインデックス", + "Index Faces in the Background": "バックグラウンドで顔をインデックス", + "Inspect Face Model File": "顔モデルファイルを調べる", + "Install Face Model": "顔モデルをインストール", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "同じ名前の JPEG(`IMG_0001.CR3` と `IMG_0001.JPG`)がサイドカーを共有しなくなりました:raw は `IMG_0001.xmp` のまま、", + "Library Folders": "ライブラリのフォルダー", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 以前ではこれを開けません(「unsupported format … v2」)。", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "Vulkan ドライバーが壊れている Windows PC で、LightCraft が起動時にクラッシュしなくなりました(issue #136、例:一部の Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "非可逆 DNG ファイルとスマートプレビューを raw 写真として開きます。", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar のルック:`.lmp` ファイルと `.mplumpack` コレクションをプリセットとして読み込みます(コレクションごとにグループ化)。スライダーは", + "Name Face": "顔に名前を付ける", + "Name Faces": "顔に名前を付ける", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF は、Sony ARW と同様にカメラ自身の JPEG に合わせた色と階調から始まります。くすんだ", + "Nikon splits into two differently compressed halves still use the preview for now.)": "(2 つの異なる圧縮に分かれた Nikon のファイルは、当面プレビューを使います。)", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Panasonic と Leica の raw(RW2、RWL、古い RAW ファイル、DMC-LX1 から DC-S1R II まで)は raw データから現像します", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "写真メニュー。フィルムストリップにも写真のコンテキストメニューがあります。「最近削除した写真」にあるファイルを再度追加しても", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "「最近削除した写真」の写真はアプリから復元できます:右クリック → 復元(または完全に削除)、", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "写真)、読み込み(ドラッグ&ドロップも)、見つからない写真を探す、スマートプレビューの作成/破棄、名前変更のプレビュー、", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "クイックコレクションと追加先アルバム(グリッドで B)、キーワードセット(⌥1–⌥9)、カラーラベルセット。", + "Recognise faces (experimental)": "顔を認識(試験的)", + "Recognition models": "認識モデル", + "Remove Face Model": "顔モデルを削除", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "フォルダー名を変更… とフォルダーを移動…(ローカル)を取り消し/やり直しできます(issue #97):フォルダーはディスク上で", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "「写真の名前を変更」は、大文字小文字だけが変わる場合に別の写真を上書きしません(issue #95):大文字小文字を区別する", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "「写真の名前を変更」は、失敗後に元に戻せなかったファイルを報告します(issue #105)。例:ネットワーク共有が", + "Reorder Album": "アルバムを並べ替え", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "他のアプリが書いた XMP サイドカーへのメタデータ保存で、そのファイルを置き換えなくなりました(issue #92):LightCraft は自分の", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "設定ファイルが黙ってリセットされることはありません(issue #103):壊れた `prefs.json`、`presets.json`、`view.json` は", + "Show Person": "人物を表示", + "Slideshow, second window, All Metadata, System Info.": "スライドショー、セカンドウィンドウ、すべてのメタデータ、システム情報。", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "カタログが小さく速くなりました:「ローカル」で見ただけの写真(追加、レーティング、編集をしていないもの)は、", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "条件エディター付きのスマートアルバム:すべて/いずれか/どれにも一致しない、入れ子のグループ、26 のフィールド。", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "2017 年頃より前の Sony ARW(RX100、RX100 II–V、RX10、NEX、SLT、ILCE-6000、A7 / A7 II / A7R II とその", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4 の縮小ロスレス ARW は、サブサンプリングされた YCbCr タイルをリニア RGB にデコードするようになりました。", + "Sort Albums A–Z": "アルバムを A–Z で並べ替え", + "Sort Albums by Name": "アルバムを名前順に並べ替え", + "Sort Contents A–Z": "内容を A–Z で並べ替え", + "Suggest Names for Faces": "顔の名前を提案", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "名前を付けた顔から、写真に写っている人を提案します。すべてこのコンピューター内で処理します。", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "TZ/ZS シリーズ、Leica D-Lux、V-Lux、C-Lux、そして GH6、GH7、G9 II、S5 II、S9 の世代。それらは", + "Test Face Model": "顔モデルをテスト", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "カタログにフォーマットのバージョンが付きました(issue #102)。古い LightCraft のライブラリを開くとアップグレードされます。", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "コントロールポート(`--control`)は、JSON リクエストでないものを受け取るとすぐに接続を閉じます", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "サムネールキャッシュは自分のファイルだけを数えて削除します(issue #98):", + "This build cannot run recognition models: they can be added and chosen, not used.": "このビルドでは認識モデルを実行できません:追加や選択はできますが、使えません。", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "このビルドには顔モデルの保存場所がないため、モデルの一覧は使えません。(デスクトップ版にはあります。)", + "Unnamed Faces": "名前のない顔", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "監視フォルダーの自動読み込み、DNG に変換、複製、標準/1:1/スマートプレビューの作成。", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "保存せずに終了(issue #100)。リセットされたライブラリのように見えるデモセッションを黙って開始することはなくなりました。", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`)、XMP サイドカーとともに。各元画像は", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG` になります。大文字小文字を区別しないボリュームでは、大文字小文字の変更はそのまま行われます。", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "ドライブの空き不足など)は作成済みと見なされず、「スマートプレビューを作成」が作り直します。", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "その後(issue #101):LightCraft が再試行する前に途中の書き込みを切り詰めるため、次回起動時に", + "again. Change the period (or turn it off) in Settings → Performance.": "また表示されます。期間の変更(またはオフ)は 設定 → パフォーマンス で行えます。", + "against the library's originals no longer scans the whole library when nothing is at that path.": "ライブラリの元画像と照合しても、そのパスに何もない場合はライブラリ全体を走査しなくなりました。", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "すでに他の画像の `thumbs/` フォルダーがある場合も、キャッシュの縮小や消去でそれらを失わなくなりました。", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "使用前にファイルを確認します。マスクは選択範囲を保持するため、モデルがなくても描画・書き出しできます。", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "と読み込んだすべての写真を 1 つの zip としてダウンロードし、ファイル → バックアップからライブラリを復元… で元に戻せます(現在のライブラリ", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "ウィンドウが固まることもありません。これは任意で、LightCraft には含まれません(Meta の SAM License、約 3.4 GB):", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "`<name>.corrupt-<time>` として保存され、その旨が通知されます。読めないファイル(例:別のプログラムがロック中)はそのままにして", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "指定した場合のみ読み込みます。`LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off`(または wgpu の `WGPU_BACKEND`。以前は GPU 描画で", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "これらをまとめたバンドル。フォルダー全体を一度に、パックごとにグループ化して読み込みます。プリセット内のマスクも含まれます。", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "カメラ設定が焼き込まれなくなりました。「プレビューのみ」として読み込み済みの写真は再読み込みで切り替わります。(ファイルが", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "変更。このような断片を含むカタログもすべて読み込めます。カタログのログを書き込めない状態で終了すると", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "変更を保存できなかった場合は一度再試行し、「もう一度保存」「このまま終了」「キャンセル」を尋ねます。", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "コントロールチャンネルや MCP、結合プレビューのパス、`lightcraft-cli render IMG.jpg -o IMG.jpg` は分かりやすい", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "カードから読み込んだ内容と照合します。失敗したコピーや内容が異なるコピーは削除され、失敗した", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "Nikon や Sony の raw と同様に、カメラ自身の JPEG に合わせた色と階調から始まり、カメラで設定した縦横比で", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "コピーが検証されてライブラリに入ったあとです。重複ファイルと失敗したファイルは元の場所に残ります。", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "データはまだデコードされないため、プレビューのみのままです。以前に読み込んだ CR3 は、写真 → ディスクから再読み込み(", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "端は切り取られ、「12 ビット非圧縮」のファイルはクリップされなくなりました。RX100 シリーズの描画は", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "カメラが書き出すすべての形式。以前はほとんどの機種がプレビューのみで開いていました:GH1–GH5、G、GX、GF、GM、FZ、LX、", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "書き出し。カタログ、XMP サイドカー、設定、DNG、結合、スマートプレビュー、読み込みで作るコピーは引き続き", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "を書き込み、残り(例:そのアプリの現像設定や編集履歴)はバイト単位でそのまま保持します。", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "ファイル(これらの機種では Sony がメーカーノートの暗号化されたデータにのみ保存)から読み取り、右端の数列の余白は", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "AI マスクを初めて使うとき、LightCraft はダウンロード前に確認し、進行状況を表示し、キャンセルと再開ができ、", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "セッション中はそのままにし、既定値で上書きしません。アプリの設定(`ui.json`。", + "gets -1, -2… instead of being replaced.": "-1、-2… が付き、置き換えられません。", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "緑がかった中性の描画(issue #150)の代わりに。ホワイトバランスは撮影時の見た目を基準に調整されます。12 ビット NEF", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "開いている場合(このコンピューターでもフォルダーを共有する別のコンピューターでも)、誰が開いているかを表示します(「すでに LightCraft で開いています(プロセス", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "復元方法、または完全に削除して新たに読み込む方法を表示します。(写真を最初からやり直すには、編集をリセット、", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "これまで無視されていた)でグラフィックスバックエンドを選びます。`off` は CPU で描画します。GPU はウィンドウの", + "immutable, and HOSTING.md describes the actual build.": "変更されないようになり、HOSTING.md が実際のビルドを説明しています。", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "読み込み。「読み込み完了」はカードをフォーマットする前にコピーが正常であることを意味します。使用中の名前は", + "imported as \"preview only\" switch over on Reload.": "「プレビューのみ」として読み込み済みの写真は再読み込みで切り替わります。", + "imported earlier switch over with Photo ▸ Reload from Disk.": "以前に読み込んだものは 写真 → ディスクから再読み込み で切り替わります。", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "低速な内蔵 GPU がウォッチドッグでリセットされないよう、短い単位で処理します。`ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "不完全な場合は CPU でやり直します。どちらでも同じ画像になります。処理は GPU に", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "保持されます)。保存の失敗(ストレージ不足)では未保存の警告を表示して再試行し、保存できない写真は", + "is refused and nothing is overwritten.": "拒否され、何も上書きされません。", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "表示後に起動し、設定 → パフォーマンス → 描画に GPU を使用 がオンのときだけです。LightCraft が", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "追加されません。2 つ目のタブは 1 つ目を上書きせずにメッセージを表示し、`?reset` は先に確認し、ページは", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "もう一方から奪いません(issue #92)。「見つからない写真を探す」は名前を変えたファイルも内容で見つけ、", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "写真と XMP サイドカーも戻り、写真は再びそこを指します。元の場所に別のものがある場合、取り消しは", + "layout is read from each file instead of assumed.": "配列は決め打ちせず各ファイルから読み取ります。", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "DNG を残さず raw を保持します。スマートプレビューも同じ方法で書き込まれます。壊れたもの(クラッシュで途切れた、", + "library locked: the lock is the operating system's and goes away with the program.": "ライブラリのロック:ロックはオペレーティングシステムのもので、プログラムの終了とともに解除されます。", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "ライブラリを記憶)はアトミックに書き込まれ、終了時だけでなく別のライブラリを開いたときにも保存されます。", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "「重複をスキップ」と表示するだけではなくなりました:サイドパネルで「最近削除した写真」を開いて写真を選択し、", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "終了時にすべてを失っていました。一時セッションでは常にバナーを表示し、ライブラリには書き込みません。", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "メモリーを使い切る、GPU のバッファー上限を超える、ドライバーのエラーやリセットに遭う、または", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "メッセージで拒否され、元画像はバイト単位でそのまま残ります。通常の以前の書き出しは、指定すれば引き続き上書きされます。", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "一括処理の途中:エラーに一覧(旧 → 新)を表示し、ライブラリは新しい名前を指します(取り消し可能な部分的な名前変更)。", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "緑と青の行から始まるセンサーの機種)がマゼンタにならなくなりました(issue #85):カラーフィルターの", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "名前(ファイルを置き換えません)。その後にだけ写真を再リンクするか raw のコピーを削除します。書き込みに失敗すると", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "途中で切れたファイルを残しません。「元画像」書き出しの XMP サイドカーも「ファイルが存在する場合」の選択に従います。", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "カメラに埋め込まれた JPEG ではなく raw データから現像するようになったため、カメラで設定したモノクロなどのピクチャースタイルが", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "読み込みの確認後にカードで変更されたファイルは、古い情報のまま読み込まずに報告します。書き出しパスの確認では", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "1 ファイルずつ。いつでも書き出し直せるもので、USB ドライブや NAS ではファイルごとの同期が大きな", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "同じ名前・同じサイズの似たファイルより優先し、区別できない写真は推測せずにスキップ(して報告)します。", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "部分を除外)、選択するものを入力(「空」「赤い車」「車, 道路」)。どちらも他のマスクと組み合わせられ、", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "埋め込み JPEG プレビューを使わずに、RAW 編集とフル解像度の書き出しを保ちます。", + "read in the wrong units.": "誤った単位で読み取られていました。", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "カードを 3 回目に読み込む代わりに(「移動」は元ファイルを削除する前に引き続きバイト単位で比較します)。変更された", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "描画とスクリーンショットは引き続き一時ファイルに書き込んで名前を変えて置き換えますが、ディスクへの強制書き込みはしなくなりました", + "save goes through.": "保存が完了します。", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "シーン)は、カメラの他の raw と同様に、すべての編集ツールを使ってセンサーデータから現像するようになりました。写真", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "兄弟機種)が鮮やかな緑色で開かなくなりました(issue #148):撮影時のホワイトバランスと黒レベルを", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "有効な XMP でないサイドカーは、まず `<name>.xmp.bak-<time>` にコピーします。既定のファイル名ベースの命名では、raw と", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "そのため、見つからない写真として表示されません。raw と JPEG の組の片方の名前を変えると、共有の `IMG_0001.xmp` をコピーし、", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "GPU を起動すると、次回起動時は GPU 描画をオフにして始め、元に戻す方法を表示します。", + "still saves your queued changes in the closing snapshot.": "閉じるときのスナップショットに、キューにある変更を保存します。", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "同期しました。読み込み → コピーは、カードを走査したときのコンテンツハッシュと各コピーを照合するようになりました。", + "the JPEG uses `IMG_0001.JPG.xmp`.": "JPEG は `IMG_0001.JPG.xmp` を使います。", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "「ローカル」のフォルダーツリーと自動読み込みもワーカースレッドでディスクを読みます。読み込みの進行状況ウィンドウにキャンセルがあります。", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "写真メニューと写真のコンテキストメニュー)でフルサイズのプレビューを取り込み、不足していたカメラのメタデータを補います", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "カメラ。センサーの欠陥画素は補間されます。`.rwl` と `.raw` ファイルも読み込みます。すでに読み込み済みの写真", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "同じ名前で「上書き」(または前回の設定で書き出しで繰り返す場合)、コントロールからの正確な出力パス", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "フォルダーを 30 日間閲覧していない場合は忘れます。ファイルとサイドカーは残り、フォルダーを閲覧すると", + "to the camera's own JPEG.": "カメラ自身の JPEG に近づきました。", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "変更しません。古いバージョンが一部を壊れたログとして読むことはなくなりました。このバージョンでライブラリを開いたあとは、", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "使われない理由(例:llvmpipe のようなソフトウェアアダプターをスキップ)と、直近の描画がフォールバックした理由を表示します。", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "ボリューム(Linux、大文字小文字を区別する APFS)では `IMG_1.JPG` の横の `img_1.JPG` は別の写真で、名前を変えたほうは", + "were missing, without touching anything already set.": "既に設定されているものには触れません。", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "ブラウザーがライブラリを削除する可能性があるとき。ホスティング:サンプルのキャッシュヘッダーは、(ハッシュなしの)ファイルを", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "元画像の有無はワーカースレッドで確認します(グリッドのサムネール、情報パネル、写真メニュー、見つからない", + "with a counterpart here come along, the rest is listed.": "対応するものがあれば取り込み、残りは一覧に表示します。", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "新しい LightCraft で書かれたものは「このライブラリは新しいバージョンの LightCraft で書かれました」として拒否され、", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "新しい LightCraft で書かれた)、LightCraft はその旨と理由を示し、「再試行」「別のライブラリを選択…」「続行」を提示します", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘ ドラッグで角度補正、⇧G ガイド付き遠近補正、変形中のグリッド。" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..e71a5590f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2076,14 +2076,14 @@ "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", "Looking for changes": "Пошук змін", - "Arrange Panel": "Arrange Panel", - "Close panel": "Close panel", - "Float panel": "Float panel", - "Move to group": "Move to group", - "Panels": "Panels", - "Reset Panel Layout": "Reset Panel Layout", - "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels", + "Arrange Panel": "Розташувати панель", + "Close panel": "Закрити панель", + "Float panel": "Зробити панель плаваючою", + "Move to group": "Перемістити до групи", + "Panels": "Панелі", + "Reset Panel Layout": "Скинути розташування панелей", + "Resize panel window": "Змінити розмір вікна панелі", + "Resize panels": "Змінити розміри панелей", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Це каталог Lightroom, а не бібліотека LightCraft. Імпортувати його до бібліотеки LightCraft?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки.", "Albums hold library photos: Add to My Photos first": "В альбомах лише фото з бібліотеки: спершу додайте їх до моїх фото", @@ -2145,5 +2145,10 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Файли Canon EOS R5 Mark II C-RAW, які відкривалися «лише як перегляд» (більше половини, залежно від", + "imported earlier switch over with Photo ▸ Reload from Disk.": "імпортовані раніше, переходять на них через Фото → Перезавантажити з диска.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "сцени), тепер проявляються з даних сенсора з усіма інструментами редагування, як інші raw-файли камери. Фото,", + "smart album value|No": "Ні", + "smart album value|Yes": "Так" } diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..2eaa72c9e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 21: de, es, fr, ja, uk catalogs filled; earlier: CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -35,7 +35,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | -| 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) | +| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (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** 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) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | | 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/localization-parity.md b/docs/localization-parity.md index 18aae618a..78f6daea5 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language measurement from the catalogs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-<code>.md` @@ -47,14 +47,14 @@ that is promising but is not recorded as review, so the column says `no (communi |---|---|---|---:|---|---|---|---|---:| | 1 | English | `en` | 2,304 / 2,304 (100%, source) | all; Help ▸ What's New and the shortcut sheet; no full user manual | Latin (Inter) | n/a | **full** | 0 h | | 2 | Simplified Chinese | `zh-hans` | 1,994 / 2,304 (87%) | menus, panels, dialogs, presets, profiles, dates; lower-layer errors English | Hans via craft-fonts (Noto Sans CJK SC); IME through winit; no vertical text in UI (not in target either) | no (community edits) | partial | 6–10 h | -| 3 | Spanish | `es` | 1,972 / 2,304 (86%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | +| 3 | Spanish | `es` | 2,362 / 2,398 (98%; 2026-10-12) | as zh-hans | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 4 | Hindi | `hi` | 0 (0%) | none | **Devanagari shaping missing** (egui has no complex shaping) | no | none (target: not shipped) | 50–90 h (shaping 25–50 h, shared with Arabic) | | 5 | Arabic | `ar` | 0 (0%) | none | **RTL layout and Arabic shaping missing** | no | none (target: not shipped) | 60–110 h (RTL mirroring 30–50 h + shaping, shared with Hindi) | -| 6 | French | `fr` | 2,021 / 2,304 (88%) | as zh-hans | Latin | no (community edits) | partial | 5–9 h | +| 6 | French | `fr` | 2,315 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | | 8 | Indonesian | `id` | 0 (0%) | none | Latin (Inter covers it) | no | none (target: not shipped) | 10–16 h | -| 9 | Japanese | `ja` | 1,995 / 2,304 (87%) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 6–10 h | -| 10 | German | `de` | 2,104 / 2,304 (91%) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 4–8 h | +| 9 | Japanese | `ja` | 2,383 / 2,398 (99%; 2026-10-12) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | +| 10 | German | `de` | 2,338 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | @@ -64,7 +64,7 @@ that is promising but is not recorded as review, so the column says `no (communi |---|---|---:|---|---|---|---:| | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | -| Ukrainian | `uk` | 2,287 / 2,304 (99%) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai @@ -90,3 +90,4 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | +| 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..8cf7903d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -51,7 +51,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | UI/UX fidelity | ~85% | ~70% | 40–70 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | -| Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | +| Localization (12 key languages) | 7 of 12 present | ~55% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | | Performance | ~80% | ~70% | 30–50 h | 24 MP GPU slider 3.9 ms, export 0.3 s (measured, [gpu-pipeline.md](gpu-pipeline.md)); 85k library opens in 1.7–4.7 s; 100k test pending | | Stability | — | ~60% | 30–60 h | never-crash lints and guards workspace-wide, crash-tested journal, 2,561 tests; but CI runs tests only on FreeBSD, user reports keep finding bugs in ✅ areas | | Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; Windows installer UI unverified on Windows (#79); web experimental (CPU only) | @@ -134,7 +134,7 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): | UI/UX fidelity | 8% | 70% | | File formats (non-raw) | 6% | 70% | | Hardware | 3% | 40% | -| Localization | 3% | 52% | +| Localization | 3% | 55% | | Performance | 6% | 70% | | Stability | 8% | 60% | | Platforms | 4% | 60% | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | From 3e42f2e05c5b2779e381f4117e14f53aa34d1b2f Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:33:54 -0500 Subject: [PATCH 030/315] feat(testkit): #1037 tests record why they skip libtest has no skipped status and hides the output of passing tests, so a test that returns early without a GPU, a corpus file, a craft-fonts face or a model passed silently. `lightcraft-testkit` (the testkit slot the layering table already reserves, dev-dependency only, std only) adds `skip!(Need, "reason")`: it prints the note as before and, when LIGHTCRAFT_SKIP_LOG names a file, appends one JSON line (need, reason, test, binary, file:line). Plain `cargo test` is unchanged. The 61 existing skip sites use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 12 ++ Cargo.toml | 1 + apps/lightcraft/Cargo.toml | 4 + apps/lightcraft/src/window_gpu.rs | 4 +- crates/codecs/Cargo.toml | 2 + crates/codecs/tests/corpus.rs | 8 +- crates/engine/Cargo.toml | 2 + crates/engine/src/camera_preview.rs | 18 +- crates/engine/src/cmd/convert.rs | 2 +- crates/engine/src/export.rs | 8 +- crates/engine/src/files.rs | 4 +- crates/engine/src/tests_face_models.rs | 2 +- crates/engine/src/tests_face_recognize.rs | 2 +- crates/engine/src/tests_xmp.rs | 4 +- crates/engine/tests/preview_read.rs | 2 +- crates/faces/Cargo.toml | 2 + crates/faces/src/yunet.rs | 2 +- crates/gpu/Cargo.toml | 2 + crates/gpu/src/nn.rs | 2 +- crates/gpu/tests/equivalence.rs | 2 +- crates/gpu/tests/fallback.rs | 2 +- crates/gpu/tests/memory.rs | 2 +- crates/gpu/tests/shutdown.rs | 3 +- crates/raw/Cargo.toml | 2 + crates/raw/tests/corpus.rs | 42 ++--- crates/raw/tests/cr3_corpus.rs | 4 +- crates/raw/tests/sparse_corpus.rs | 2 +- crates/segment/Cargo.toml | 4 + crates/segment/tests/reference.rs | 2 +- crates/testkit/Cargo.toml | 12 ++ crates/testkit/src/lib.rs | 190 ++++++++++++++++++++++ crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/i18n.rs | 4 +- crates/ui-egui/src/theme.rs | 2 +- 34 files changed, 297 insertions(+), 61 deletions(-) create mode 100644 crates/testkit/Cargo.toml create mode 100644 crates/testkit/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5f2609a5b..43d5f119c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3081,6 +3081,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "log", "muda", @@ -3144,6 +3145,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-heif", "lightcraft-raster", + "lightcraft-testkit", "moxcms 0.9.1", "png", "proptest", @@ -3239,6 +3241,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-testkit", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3254,6 +3257,7 @@ dependencies = [ name = "lightcraft-faces" version = "0.6.0" dependencies = [ + "lightcraft-testkit", "matrixmultiply", "ndarray", "proptest", @@ -3304,6 +3308,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", + "lightcraft-testkit", "log", "pollster 1.0.1", "wgpu", @@ -3424,6 +3429,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-meta", "lightcraft-raster", + "lightcraft-testkit", "lightcraft-tiff", "miniz_oxide 0.8.9", "proptest", @@ -3454,6 +3460,7 @@ dependencies = [ "candle-core", "candle-nn", "lightcraft-fetch", + "lightcraft-testkit", "log", "serde", "serde_json", @@ -3464,6 +3471,10 @@ dependencies = [ name = "lightcraft-sysmem" version = "0.6.0" +[[package]] +name = "lightcraft-testkit" +version = "0.6.0" + [[package]] name = "lightcraft-tiff" version = "0.6.0" @@ -3493,6 +3504,7 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-testkit", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 7163c757c..feaee1d54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } lightcraft-geocode = { path = "crates/geocode" } +lightcraft-testkit = { path = "crates/testkit" } # foundation thiserror = "2" diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index bbe495c7d..26122c3e6 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -48,3 +48,7 @@ muda = { workspace = true } objc2 = { workspace = true } objc2-app-kit = { workspace = true } objc2-foundation = { workspace = true } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 66e5a78d7..1f84ec477 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -278,11 +278,11 @@ mod tests { ..wgpu::InstanceDescriptor::new_without_display_handle() }); let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { - eprintln!("no GPU adapter: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; }; let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { - eprintln!("no GPU device: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU device"); return; }; install_error_handler(&device); diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 166fbf4dc..1ae016bc8 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"] avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # JPEG XL tests read encoded samples without our colour conversion jxl-oxide = { workspace = true } proptest = { workspace = true } diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index d3b2ceba3..9a86bc5e2 100644 --- a/crates/codecs/tests/corpus.rs +++ b/crates/codecs/tests/corpus.rs @@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec<PathBuf> { fn corpus_images_decode() { let dir = corpus_root().join("images"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let (mut ok, mut unsupported) = (0, 0); @@ -57,7 +57,7 @@ fn corpus_images_decode() { fn corpus_pngsuite() { let dir = corpus_root().join("pngsuite"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let mut n = 0; @@ -83,7 +83,7 @@ fn corpus_pngsuite() { fn corpus_raw_is_routed() { let dir = corpus_root().join("raw"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } for p in files(&dir) { @@ -103,7 +103,7 @@ fn system_icc_profiles() { use lightcraft_codecs::icc::{IccColorModel, IccKind, parse}; let dir = Path::new("/System/Library/ColorSync/Profiles"); if !dir.is_dir() { - eprintln!("skip: no system ICC profiles"); + lightcraft_testkit::skip!(System, "no system ICC profiles"); return; } let expect = [ diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2229c42a9..203135867 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -51,6 +51,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # HDR JPEG XL tests read the file's colour encoding jxl-oxide = { workspace = true } # Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on). diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..758714a75 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1828,7 +1828,7 @@ mod tests { .join("raw"); for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); @@ -1929,13 +1929,13 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/cr3-canon-r100-raw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = match lightcraft_raw::decode(&bytes) { Ok(raw) => raw, Err(lightcraft_raw::RawError::Unsupported(why)) => { - eprintln!("skip CR3 sensor decoder: {why}"); + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); return; } Err(error) => panic!("CR3 corpus failed to decode: {error}"), @@ -1964,7 +1964,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; } Err(e) => panic!("{}: {e}", path.display()), @@ -1995,7 +1995,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d7500-lossless14.nef"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2015,7 +2015,7 @@ mod tests { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -2036,7 +2036,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2055,7 +2055,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2213,7 +2213,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } Err(e) => panic!("{}: {e}", path.display()), diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..1eaec749d 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -494,7 +494,7 @@ mod tests { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); let f = root.join("raw/rw2-panasonic-gx80.rw2"); if !f.exists() { - eprintln!("skip: {} absent", f.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", f.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index f356d6e4d..4b91e5b78 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -2414,7 +2414,7 @@ mod tests { fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { use ab_glyph::Font; let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - eprintln!("skipped: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); return; }; let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); @@ -2486,7 +2486,7 @@ mod tests { compare(composed, decomposed, crate::fonts::CRAFT_FONTS); } } else { - eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); } } @@ -2510,7 +2510,7 @@ mod tests { use ab_glyph::Font; let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); if fonts.len() < 2 { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); return; } for c in "日本語の文字".chars() { @@ -3090,7 +3090,7 @@ mod tests { #[test] fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..2ab7c21f0 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1133,7 +1133,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); @@ -1484,7 +1484,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let (img, _) = load_bytes(&bytes, 1600).unwrap(); diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index f1c27c849..49532c471 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() { /// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass. fn demo_with_yunet() -> Option<Session> { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; let dir = temp("yunet"); diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index aabcb03c3..5aab8f9a4 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { /// committed) and says whether it did: the parts of a test that need to find faces run only then. pub(crate) fn install_yunet(s: &mut Session) -> bool { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked"); return false; }; let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true})); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index c768078ce..9c2b40849 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -581,7 +581,7 @@ fn convert_raw_to_dng() { let r = s.execute("photo.convertToDng", &json!({})).unwrap(); assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1)); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let nef = dir.join("shot.nef"); @@ -843,7 +843,7 @@ fn dng_export_compression_choices() { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 11ffec44e..34aae9591 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -13,7 +13,7 @@ use std::time::Instant; #[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"] fn a_preview_reads_only_the_header_and_the_preview() { let Ok(path) = std::env::var("LC_PREVIEW_RAW") else { - eprintln!("skipped: set LC_PREVIEW_RAW=<path to a raw file>"); + lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW=<path to a raw file>"); return; }; let file_len = std::fs::metadata(&path).expect("the file exists").len(); diff --git a/crates/faces/Cargo.toml b/crates/faces/Cargo.toml index 112967cee..78ea0b798 100644 --- a/crates/faces/Cargo.toml +++ b/crates/faces/Cargo.toml @@ -22,4 +22,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..56f5d6474 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -263,7 +263,7 @@ mod tests { fn detector() -> Option<Detector> { let Some(bytes) = model() else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; Some(Detector::new(&bytes).unwrap()) diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..5547c548d 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -29,5 +29,7 @@ default = [] denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } lightcraft-scenes = { workspace = true } lightcraft-raw = { workspace = true } diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 4f078ff93..5872bfaa9 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -907,7 +907,7 @@ mod tests { match runner(net, tile) { Ok(r) => Some(r), Err(e) => { - eprintln!("skipped: no GPU runner here ({e})"); + lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})"); None } } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a971..16a9263c1 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3; fn gpu() -> bool { let ok = lightcraft_gpu::available(); if !ok { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); } ok } diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index fe2ecad5e..05347b446 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) { #[test] fn failed_gpu_renders_fall_back_with_a_reason() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } assert_eq!(lightcraft_gpu::unavailable_reason(), None); diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs index 2ef958cb1..25784f001 100644 --- a/crates/gpu/tests/memory.rs +++ b/crates/gpu/tests/memory.rs @@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; #[test] fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; } let limit = 64u64 << 20; diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs index b827a0711..a8d5cbd23 100644 --- a/crates/gpu/tests/shutdown.rs +++ b/crates/gpu/tests/shutdown.rs @@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() { let req = RenderRequest::fit(w, h); let has_gpu = lightcraft_gpu::available(); if !has_gpu { - eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason()); + let why = lightcraft_gpu::unavailable_reason().unwrap_or_default(); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked"); } assert!(!lightcraft_gpu::shutting_down()); assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet"); diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..84f9fa91a 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -28,6 +28,8 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } jpeg-encoder = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..cf5bc5290 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -25,7 +25,7 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ fn corpus_raw_decodes() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect(); @@ -159,7 +159,7 @@ fn corpus_cr2_cfa_patterns() { let mut seen = 0; for (name, want) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -189,7 +189,7 @@ fn corpus_cr2_as_shot_white_balance() { "cr2-canon-80d.cr2", ] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB")); @@ -206,7 +206,7 @@ fn corpus_nef_compressed_matches_uncompressed() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef"))) else { - eprintln!("skip: D5100 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D5100 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -231,7 +231,7 @@ fn corpus_nef_compressed_matches_uncompressed() { fn corpus_nef_high_efficiency_is_named() { let dir = corpus_root().join("raw"); let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else { - eprintln!("skip: Z f samples absent"); + lightcraft_testkit::skip!(Corpus, "Z f samples absent"); return; }; for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] { @@ -248,7 +248,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) else { - eprintln!("skip: D7500 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D7500 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -278,7 +278,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { fn corpus_dngs_carry_profile_looks() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut seen = 0; @@ -327,7 +327,7 @@ fn corpus_dngs_carry_profile_looks() { fn corpus_proraw_sky_matte() { let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let t0 = Instant::now(); @@ -365,7 +365,7 @@ fn corpus_sony_pre2017_colour_metadata() { let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -397,7 +397,7 @@ fn corpus_sony_a7cr_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -464,7 +464,7 @@ fn corpus_sony_a7cm2_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -509,7 +509,7 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -567,7 +567,7 @@ fn corpus_sony_sr2_white_balance() { let mut seen = 0; for (name, [r, b]) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -585,7 +585,7 @@ fn corpus_sony_sr2_white_balance() { fn corpus_sony_a900_packed12() { let name = "arw-sony-a900-packed12.arw"; let Ok(bytes) = std::fs::read(corpus_root().join("raw").join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); return; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -607,7 +607,7 @@ fn corpus_sony_16x9_crops() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -727,7 +727,7 @@ fn corpus_panasonic_encodings() { let mut seen = 0; for (name, bits, black, (cw, ch), cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -777,7 +777,7 @@ fn corpus_orf_cfa_patterns() { let mut seen = 0; for (name, cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -812,7 +812,7 @@ fn corpus_samsung_srw() { let mut seen = 0; for (name, bits, active, crop, cfa, whole) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -941,7 +941,7 @@ fn corpus_sony_arw6_samples() { fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut checked = 0; @@ -1004,7 +1004,7 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ] { let path = root.join(name); if !path.exists() { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } let bytes = std::fs::read(&path).unwrap(); @@ -1026,7 +1026,7 @@ fn corpus_kodak_dcs520c() { let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF"; let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)]; let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { - eprintln!("skip: DCS520C sample absent"); + lightcraft_testkit::skip!(Corpus, "DCS520C sample absent"); return; }; assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index ebf669307..06f7150a8 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -22,7 +22,7 @@ fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize fn check_against(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>, reference: &str) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -118,7 +118,7 @@ fn canon_m50_craw_crop_is_the_shot_aspect() { // which is (12, 12) inside the valid sensor area. let path = corpus().join("raw").join("cr3-canon-m50-craw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap(); diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 3ab735299..4d9112bfb 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -58,7 +58,7 @@ fn partial_reads_describe_raws_as_whole_files_do() { .filter(|p| p.is_file() && !p.extension().is_some_and(|e| e == "part" || e == "txt" || e == "md")) .collect(); if paths.is_empty() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } paths.sort(); diff --git a/crates/segment/Cargo.toml b/crates/segment/Cargo.toml index 11dff5c77..b4614a81d 100644 --- a/crates/segment/Cargo.toml +++ b/crates/segment/Cargo.toml @@ -23,3 +23,7 @@ lightcraft-fetch = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] candle-core = { workspace = true, features = ["metal"] } candle-nn = { workspace = true, features = ["metal"] } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index a73fcaa7c..315d36cd1 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -38,7 +38,7 @@ fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 { #[test] fn matches_reference() { let Some((dir, r)) = setup() else { - eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); + lightcraft_testkit::skip!(Model, "set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); return; }; let mut model = Sam3::load(&dir).unwrap(); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..cefa1ef5c --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lightcraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft test tooling (dev-dependency only): a test that can't run here records why, so `cargo xtask ci` reports it" +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..7a2322245 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,190 @@ +//! Test tooling shared by the workspace's tests. A dev-dependency only: `cargo xtask layers` +//! refuses it as a normal dependency. Std only, so every crate can use it at no build cost. +//! +//! **Skips.** libtest has no "skipped" status and hides the output of passing tests, so a test that +//! needs a GPU, a corpus file, the craft-fonts faces or a model, and returns early without them, +//! passes silently: "all steps passed" could mean the GPU tests never ran. Such a test calls +//! [`skip!`] before it returns: +//! +//! ```no_run +//! # let gpu_available = false; +//! if !gpu_available { +//! lightcraft_testkit::skip!(Gpu, "no GPU adapter"); +//! return; +//! } +//! ``` +//! +//! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file +//! (`cargo xtask ci` sets it), appends one JSON line there: +//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `cargo xtask ci` lists the skips by need and reason in its summary and in +//! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records +//! the part it could not run the same way. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::Path; + +/// The environment variable naming the file skips are appended to (JSON lines). +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// What a skipped test needed and didn't find. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Need { + /// A usable GPU adapter (`lightcraft_gpu::available()`). + Gpu, + /// A file from the gitignored `corpus/` (`cargo xtask corpus --download`) or a private corpus. + Corpus, + /// Faces from craft-fonts, embedded only in builds with `CRAFT_FONTS_DIR`. + Fonts, + /// A model file named by an environment variable (SAM 3, YuNet, …). + Model, + /// An input file named by an environment variable (opt-in tests). + Input, + /// Something the operating system provides (system ICC profiles, …). + System, + /// Support this build doesn't have yet (a decoder that refuses a file variant, an optional + /// cargo feature that is off). + Feature, +} + +impl Need { + /// The name in the skip log and the CI summary. + pub fn as_str(self) -> &'static str { + match self { + Need::Gpu => "gpu", + Need::Corpus => "corpus", + Need::Fonts => "fonts", + Need::Model => "model", + Need::Input => "input", + Need::System => "system", + Need::Feature => "feature", + } + } +} + +/// Record that this test (or part of it) can't run here: `skip!(Gpu, "no GPU adapter ({why})")`. +/// The first argument names a [`Need`] variant; the rest is a `format!` message. Doesn't return +/// from the test: follow it with `return` (or `continue`) as the test needs. +#[macro_export] +macro_rules! skip { + ($need:ident, $($arg:tt)+) => { + $crate::record($crate::Need::$need, &::std::format!($($arg)+), ::std::file!(), ::std::line!()) + }; +} + +/// What [`skip!`] calls: print the note and append it to the skip log, if one is set. Never fails +/// the test: a log that can't be written loses the entry, not the run. +pub fn record(need: Need, reason: &str, file: &str, line: u32) { + eprintln!("skipped ({}): {reason}", need.as_str()); + let log = std::env::var_os(SKIP_LOG_ENV).filter(|p| !p.is_empty()); + if let Some(log) = log { + let thread = std::thread::current(); + let binary = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| binary_name(&s.to_string_lossy()).to_owned())); + let entry = entry(need, reason, thread.name().unwrap_or(""), binary.as_deref().unwrap_or(""), file, line); + append(Path::new(&log), &entry); + } +} + +/// Append one line in a single write, so lines from parallel tests don't interleave. +fn append(path: &Path, line: &str) { + let file = std::fs::OpenOptions::new().create(true).append(true).open(path); + if let Err(e) = file.and_then(|mut f| f.write_all(line.as_bytes())) { + eprintln!("{SKIP_LOG_ENV}: can't append to {}: {e}", path.display()); + } +} + +/// A test binary's name without the hash cargo appends (`equivalence-0123abcd` → `equivalence`). +fn binary_name(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((name, hash)) if !name.is_empty() && hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()) => name, + _ => stem, + } +} + +/// One skip-log line (JSON, newline-terminated). +fn entry(need: Need, reason: &str, test: &str, binary: &str, file: &str, line: u32) -> String { + let mut s = String::from("{"); + for (i, (key, value)) in [("need", need.as_str()), ("reason", reason), ("test", test), ("binary", binary)].into_iter().enumerate() { + if i > 0 { + s.push(','); + } + let _ = write!(s, "\"{key}\":\"{}\"", escape(value)); + } + let _ = writeln!(s, ",\"location\":\"{}:{line}\"}}", escape(&file.replace('\\', "/"))); + s +} + +/// A JSON string's contents. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if u32::from(c) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_entry_is_one_json_line_with_its_text_escaped() { + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1, "{line}"); + assert_eq!( + line, + "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ + \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + ); + } + + #[test] + fn binary_names_lose_cargos_hash_only() { + assert_eq!(binary_name("equivalence-0f3a9c2b1d4e5f60"), "equivalence"); + assert_eq!(binary_name("lightcraft_engine-0f3a9c2b1d4e5f60"), "lightcraft_engine"); + assert_eq!(binary_name("sparse_corpus"), "sparse_corpus"); + assert_eq!(binary_name("cr3-corpus"), "cr3-corpus"); + assert_eq!(binary_name("-0f3a9c2b1d4e5f60"), "-0f3a9c2b1d4e5f60"); + assert_eq!(binary_name("tool-cafe"), "tool-cafe"); + } + + #[test] + fn appended_lines_accumulate() { + let dir = std::env::temp_dir().join(format!("lightcraft-testkit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("skips.jsonl"); + let _ = std::fs::remove_file(&log); + let threads: Vec<_> = (0..8) + .map(|i| { + let log = log.clone(); + std::thread::spawn(move || append(&log, &entry(Need::Gpu, &format!("no GPU {i}"), "t", "b", "f.rs", i))) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let text = std::fs::read_to_string(&log).unwrap(); + assert_eq!(text.lines().count(), 8); + assert!(text.lines().all(|l| l.starts_with("{\"need\":\"gpu\"") && l.ends_with('}'))); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_missing_log_directory_does_not_fail_the_test() { + append(Path::new("/nonexistent-lightcraft-dir/skips.jsonl"), "{}\n"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 74bc80058..38322d834 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,8 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..c040492a3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -597,7 +597,7 @@ mod tests { lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); faces.dedup(); if faces.is_empty() { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } ctx.fonts_mut(|fonts| { @@ -1200,7 +1200,7 @@ mod tests { let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; for (language, sample) in samples { if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } set_language(language); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196ae..30a9823d1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -415,7 +415,7 @@ mod tests { #[test] fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { - eprintln!("no Thai face in craft-fonts: nothing to check"); + lightcraft_testkit::skip!(Fonts, "no Thai face in craft-fonts"); return; }; set_language(Locale::En); From 82045f4d2cd0c99b3763c406a10cd9caf9295e20 Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:33:54 -0500 Subject: [PATCH 031/315] feat(xtask): #1037 ci reports the machine, GPU adapters and skipped tests `cargo xtask ci` points each test step at target/ci/skips-<step>.jsonl, then probes the machine: OS, CPUs, RAM, toolchain, corpus and craft-fonts, and the GPU through the ignored `gpu_probe` test of lightcraft-gpu (run right after the workspace tests with --workspace, so it reuses their build; xtask stays without wgpu). The run ends with the skips grouped by need and reason and writes target/ci/report.json (schema 1: commit, dirty flag, machine, capabilities, step results and times, skips) and target/ci/summary.md for a PR (`--summary-md` prints it). `--hardware` also runs the lightcraft-gpu tests once per backend that has a hardware adapter. Nothing is uploaded; reasons are cut free of the workspace root and home directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/gpu/tests/gpu_probe.rs | 87 +++++ xtask/src/ci_report.rs | 599 ++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 194 +++++++++-- 3 files changed, 849 insertions(+), 31 deletions(-) create mode 100644 crates/gpu/tests/gpu_probe.rs create mode 100644 xtask/src/ci_report.rs diff --git a/crates/gpu/tests/gpu_probe.rs b/crates/gpu/tests/gpu_probe.rs new file mode 100644 index 000000000..e006fbc80 --- /dev/null +++ b/crates/gpu/tests/gpu_probe.rs @@ -0,0 +1,87 @@ +//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of +//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs; +//! xtask runs it after the workspace tests, so it costs no extra build: +//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`. +//! +//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`, +//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build +//! contains. +//! - `LIGHTCRAFT_GPU_PROBE=<backend>` (`vulkan`, `metal`, `dx12`, `gl`): every adapter that +//! backend lists, software ones (lavapipe, llvmpipe, WARP) included. One backend per process, so +//! a driver that crashes while it is enumerated (issue #136) costs only its own line. +//! +//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy. +#![cfg(not(target_arch = "wasm32"))] + +use lightcraft_gpu::backend::{self, Backends}; + +const NAMES: [(&str, Backends); 4] = [("vulkan", Backends::VULKAN), ("metal", Backends::METAL), ("dx12", Backends::DX12), ("gl", Backends::GL)]; + +#[test] +#[ignore = "the capability probe of `cargo xtask ci`"] +fn gpu_probe() { + let line = match std::env::var("LIGHTCRAFT_GPU_PROBE").ok().filter(|b| !b.is_empty()) { + None => pipeline(), + Some(name) => adapters(&name), + }; + // its own line, prefixed: libtest may have printed `test gpu_probe ... ` on this one + println!("\ngpu_probe: {line}"); +} + +fn names(set: Backends) -> String { + let list: Vec<String> = NAMES.iter().filter(|(_, b)| set.contains(*b)).map(|(n, _)| json_str(n)).collect(); + format!("[{}]", list.join(",")) +} + +fn pipeline() -> String { + let available = lightcraft_gpu::available(); + let opt = |s: Option<String>| s.map_or_else(|| "null".to_owned(), |s| json_str(&s)); + let compiled = wgpu::Instance::enabled_backend_features(); + format!( + "{{\"pipeline\":{{\"available\":{available},\"adapter\":{},\"reason\":{},\"backends\":{},\"compiled\":{}}}}}", + opt(lightcraft_gpu::adapter_name()), + opt(lightcraft_gpu::unavailable_reason()), + names(backend::compute_backends().unwrap_or(Backends::empty()) & compiled), + names(compiled), + ) +} + +fn adapters(name: &str) -> String { + let (_, set) = NAMES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown backend `{name}` (vulkan, metal, dx12, gl)")); + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = *set; + desc.backend_options = backend::backend_options(); + desc.flags = backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let adapters: Vec<String> = pollster::block_on(instance.enumerate_adapters(*set)) + .iter() + .map(|a| { + let i = a.get_info(); + format!( + "{{\"name\":{},\"type\":{},\"vendor\":\"0x{:04x}\",\"device\":\"0x{:04x}\",\"driver\":{},\"driver_info\":{}}}", + json_str(&i.name), + json_str(&format!("{:?}", i.device_type)), + i.vendor, + i.device, + json_str(&i.driver), + json_str(&i.driver_info), + ) + }) + .collect(); + format!("{{\"backend\":{},\"adapters\":[{}]}}", json_str(name), adapters.join(",")) +} + +/// A JSON string literal. +fn json_str(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/xtask/src/ci_report.rs b/xtask/src/ci_report.rs new file mode 100644 index 000000000..dfbd9f5f9 --- /dev/null +++ b/xtask/src/ci_report.rs @@ -0,0 +1,599 @@ +//! What a `cargo xtask ci` run covered on this machine: the capability probe (OS, CPUs, RAM, GPU +//! adapters per backend, corpus, craft-fonts), each step's result and time, and the tests that +//! skipped for want of hardware or data. Written to `target/ci/report.json` and +//! `target/ci/summary.md` (a block to paste into a PR) and printed after the CI summary. +//! +//! Tests record skips with `lightcraft_testkit::skip!`; each test step points +//! `LIGHTCRAFT_SKIP_LOG` at `target/ci/skips-<step>.jsonl`. The GPU adapters come from the ignored +//! `gpu_probe` test of `lightcraft-gpu` (xtask has no wgpu). Nothing here is sent anywhere. +//! +//! The report holds no host name, user name or absolute path: the workspace root and the home +//! directory are cut from skip reasons. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +/// Bumped when a field changes meaning. +pub const SCHEMA: u64 = 1; + +/// The environment variable `lightcraft-testkit` appends skips to. +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// `target/ci` under the workspace root (not `CARGO_TARGET_DIR`: one place to look, like +/// `target/bench`). +pub fn dir(root: &Path) -> PathBuf { + root.join("target").join("ci") +} + +/// Create `target/ci` and remove the previous run's skip logs, report and summary. +pub fn prepare(root: &Path) -> Result<PathBuf, String> { + let dir = dir(root); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if (name.starts_with("skips-") && name.ends_with(".jsonl")) || name == "report.json" || name == "summary.md" { + let _ = std::fs::remove_file(entry.path()); + } + } + Ok(dir) +} + +/// The skip log of one step. +pub fn skip_log(root: &Path, step: &str) -> PathBuf { + let safe: String = step.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect(); + dir(root).join(format!("skips-{safe}.jsonl")) +} + +fn output(program: &str, args: &[&str], cwd: Option<&Path>) -> Option<String> { + let mut c = Command::new(program); + c.args(args); + if let Some(cwd) = cwd { + c.current_dir(cwd); + } + let out = c.output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// Commit, branch and whether the work tree has uncommitted changes. +pub fn git(root: &Path) -> Value { + let commit = output("git", &["rev-parse", "HEAD"], Some(root)); + let branch = output("git", &["rev-parse", "--abbrev-ref", "HEAD"], Some(root)); + let dirty = output("git", &["status", "--porcelain"], Some(root)).map(|s| !s.is_empty()); + json!({"commit": commit, "branch": branch, "dirty": dirty}) +} + +/// OS, version, architecture, CPUs and RAM. +pub fn machine(ram_total_gb: Option<u64>, ram_available_mb: Option<u64>) -> Value { + json!({ + "os": std::env::consts::OS, + "os_version": os_version(), + "arch": std::env::consts::ARCH, + "cpus": std::thread::available_parallelism().map(|n| n.get()).ok(), + "ram_total_gb": ram_total_gb, + "ram_available_mb": ram_available_mb, + }) +} + +fn os_version() -> Option<String> { + if cfg!(target_os = "linux") { + let release = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| s.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=")).map(|v| v.trim_matches('"').to_owned())); + let kernel = output("uname", &["-r"], None); + match (release, kernel) { + (Some(r), Some(k)) => Some(format!("{r}, kernel {k}")), + (r, k) => r.or(k), + } + } else if cfg!(target_os = "macos") { + output("sw_vers", &["-productVersion"], None).map(|v| format!("macOS {v}")) + } else if cfg!(windows) { + output("cmd", &["/C", "ver"], None) + } else { + output("uname", &["-sr"], None) + } +} + +/// `rustc -V` of the toolchain cargo uses. +pub fn toolchain() -> Option<String> { + output(&std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()), &["-V"], None) +} + +/// Test data present: how many of the pinned raw samples, other corpus files, craft-fonts. +pub fn data(root: &Path, pinned_raw: &[&str]) -> Value { + let corpus = std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| root.join("corpus")); + let count = |sub: &str| -> usize { std::fs::read_dir(corpus.join(sub)).map(|d| d.flatten().filter(|e| e.path().is_file()).count()).unwrap_or(0) }; + let pinned = pinned_raw.iter().filter(|name| corpus.join("raw").join(name).is_file()).count(); + let fonts = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from); + json!({ + "corpus": { + "custom_dir": std::env::var_os("LIGHTCRAFT_CORPUS").is_some(), + "raw_pinned": pinned, + "raw_pinned_total": pinned_raw.len(), + "raw_files": count("raw"), + "images": count("images"), + "pngsuite": count("pngsuite"), + }, + "craft_fonts": { + "set": fonts.is_some(), + // crates/engine/build.rs embeds the fonts of `fonts/manifest.txt` (a relative dir may mean the root) + "found": fonts.as_deref().is_some_and(|d| [d.to_path_buf(), root.join(d)].iter().any(|d| d.join("fonts/manifest.txt").is_file())), + }, + }) +} + +/// Backends wgpu can probe, in the order reports list them. +const BACKENDS: [&str; 4] = ["vulkan", "metal", "dx12", "gl"]; + +/// Run `gpu_probe` (the pipeline's device), then once per backend: the backends the pipeline may +/// use, or with `all` every backend in the build (`--hardware`; on Windows that loads the Vulkan +/// driver, which the app avoids by default, issue #136). +pub fn gpu(mut probe: impl FnMut(Option<&str>) -> Result<String, String>, all: bool) -> Value { + let pipeline = match probe(None).and_then(|s| first_json(&s)) { + Ok(v) => v.get("pipeline").cloned().unwrap_or(Value::Null), + Err(e) => return json!({"error": e}), + }; + let names = |key: &str| -> Vec<String> { + pipeline[key].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default() + }; + let (compiled, used) = (names("compiled"), names("backends")); + let mut backends = Vec::new(); + for b in BACKENDS.iter().filter(|b| compiled.iter().any(|c| c == *b) && (all || used.iter().any(|u| u == *b))) { + backends.push(match probe(Some(b)).and_then(|s| first_json(&s)) { + Ok(v) => v, + Err(e) => json!({"backend": b, "adapters": [], "error": e}), + }); + } + json!({"pipeline": pipeline, "backends": backends}) +} + +/// The JSON of the `gpu_probe: {…}` line in the probe's output (libtest prints around it). +fn first_json(s: &str) -> Result<Value, String> { + let line = s.lines().find_map(|l| l.trim().strip_prefix("gpu_probe: ")).ok_or_else(|| format!("gpu_probe printed no result: {s:?}"))?; + serde_json::from_str(line).map_err(|e| format!("gpu_probe: bad JSON ({e}): {line}")) +} + +/// Backends with at least one hardware (not software) adapter: `--hardware` runs the GPU tests +/// on each. +pub fn hardware_backends(gpu: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + for b in gpu["backends"].as_array().into_iter().flatten() { + let Some(name) = b["backend"].as_str() else { continue }; + let hw = b["adapters"].as_array().into_iter().flatten().find(|a| a["type"].as_str().is_some_and(|t| t != "Cpu")); + if let Some(a) = hw { + out.push((name.to_owned(), a["name"].as_str().unwrap_or("?").to_owned())); + } + } + out +} + +/// One test that skipped, possibly in several steps. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Skip { + pub need: String, + pub reason: String, + pub test: String, + pub binary: String, + pub location: String, + pub steps: Vec<String>, +} + +/// The skips of every `skips-<step>.jsonl` in `dir`, deduplicated (the HEIF step reruns some test +/// binaries), sorted by need, then reason. Unreadable lines are dropped. +pub fn read_skips(dir: &Path, root: &Path) -> Vec<Skip> { + let mut by_key: BTreeMap<(String, String, String, String, String), Vec<String>> = BTreeMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut files: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect(); + files.sort(); + for path in files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let Some(step) = name.strip_prefix("skips-").and_then(|n| n.strip_suffix(".jsonl")) else { continue }; + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + for line in text.lines() { + let Ok(v) = serde_json::from_str::<Value>(line) else { continue }; + let field = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let key = (field("need"), sanitize(&field("reason"), root), field("test"), field("binary"), field("location")); + let steps = by_key.entry(key).or_default(); + if !steps.iter().any(|s| s == step) { + steps.push(step.to_owned()); + } + } + } + by_key.into_iter().map(|((need, reason, test, binary, location), steps)| Skip { need, reason, test, binary, location, steps }).collect() +} + +/// Cut the workspace root and the home directory from `s`, and fold `dir/../` in paths. +pub fn sanitize(s: &str, root: &Path) -> String { + let mut out = s.to_owned(); + let root = root.display().to_string(); + if !root.is_empty() { + for sep in ['/', '\\'] { + out = out.replace(&format!("{root}{sep}"), ""); + } + out = out.replace(&root, "."); + } + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(|h| h.to_string_lossy().into_owned()); + if let Some(home) = home.filter(|h| h.len() > 1) { + out = out.replace(&home, "~"); + } + out.split(' ').map(fold_parent_dirs).collect::<Vec<_>>().join(" ") +} + +/// `crates/raw/../../corpus/raw` → `corpus/raw` (lexically; leading `..` stay). +fn fold_parent_dirs(token: &str) -> String { + if !token.contains("/../") && !token.contains("\\..\\") { + return token.to_owned(); + } + let sep = if token.contains("/../") { '/' } else { '\\' }; + let mut parts: Vec<&str> = Vec::new(); + for part in token.split(sep) { + if part == ".." && parts.last().is_some_and(|p| *p != ".." && !p.is_empty()) { + parts.pop(); + } else { + parts.push(part); + } + } + parts.join(&sep.to_string()) +} + +pub fn skips_json(skips: &[Skip]) -> Value { + Value::Array( + skips + .iter() + .map(|s| json!({"need": s.need, "reason": s.reason, "test": s.test, "binary": s.binary, "location": s.location, "steps": s.steps})) + .collect(), + ) +} + +/// What an empty skip list means: no test skipped, or the tests never ran. +fn no_skips(report: &Value) -> &'static str { + let test_ran = report["steps"].as_array().into_iter().flatten().any(|s| s["name"] == "test" && s["status"] != "not run"); + if test_ran { "none recorded" } else { "unknown, the test step did not run" } +} + +fn skips_from(report: &Value) -> Vec<Skip> { + report["skips"] + .as_array() + .into_iter() + .flatten() + .map(|v| { + let f = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let steps = v["steps"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + Skip { need: f("need"), reason: f("reason"), test: f("test"), binary: f("binary"), location: f("location"), steps } + }) + .collect() +} + +/// Skip counts per need, most first: `[("corpus", 40), ("gpu", 5)]`. +pub fn counts_by_need(skips: &[Skip]) -> Vec<(String, usize)> { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for s in skips { + *counts.entry(&s.need).or_default() += 1; + } + let mut v: Vec<(String, usize)> = counts.into_iter().map(|(k, n)| (k.to_owned(), n)).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v +} + +/// What gets the skipped tests of `need` running. +fn hint(need: &str) -> &'static str { + match need { + "gpu" => "a hardware GPU adapter the pipeline accepts", + "corpus" => "the corpus (`cargo xtask corpus --download`; some files are private)", + "fonts" => "craft-fonts (`CRAFT_FONTS_DIR`, see the README)", + "model" => "the model files the reasons name", + "input" => "the input files the reasons name", + "system" => "what the operating system provides", + "feature" => "support this build doesn't have yet", + _ => "what the reasons name", + } +} + +/// The skips of `need`, grouped by reason, largest group first. +fn by_reason<'a>(skips: &'a [Skip], need: &str) -> Vec<(&'a str, Vec<&'a Skip>)> { + let mut groups: BTreeMap<&str, Vec<&Skip>> = BTreeMap::new(); + for k in skips.iter().filter(|k| k.need == need) { + groups.entry(&k.reason).or_default().push(k); + } + let mut groups: Vec<(&str, Vec<&Skip>)> = groups.into_iter().collect(); + groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0))); + groups +} + +pub fn seconds(s: f64) -> String { + if s < 60.0 { format!("{s:.1} s") } else { format!("{}m {:02}s", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } +} + +fn test_name(s: &Skip) -> String { + if s.test.is_empty() || s.test == "main" { format!("{} ({})", s.binary, s.location) } else { format!("{}::{}", s.binary, s.test) } +} + +fn adapter_line(a: &Value) -> String { + let name = a["name"].as_str().unwrap_or("?"); + let kind = a["type"].as_str().unwrap_or("?"); + let driver = [a["driver"].as_str().unwrap_or(""), a["driver_info"].as_str().unwrap_or("")] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::<Vec<_>>() + .join(" "); + if driver.is_empty() { format!("{name} ({kind})") } else { format!("{name} ({kind}; {driver})") } +} + +/// "the pipeline's device" line. +fn gpu_line(report: &Value) -> String { + let gpu = &report["capabilities"]["gpu"]; + if let Some(e) = gpu["error"].as_str() { + return format!("probe failed: {e}"); + } + let p = &gpu["pipeline"]; + match (p["available"].as_bool(), p["adapter"].as_str(), p["reason"].as_str()) { + (Some(true), Some(a), _) => a.to_owned(), + (_, _, Some(r)) => format!("none ({r})"), + _ => "not probed".to_owned(), + } +} + +fn adapters_lines(report: &Value) -> Vec<String> { + let mut out = Vec::new(); + for b in report["capabilities"]["gpu"]["backends"].as_array().into_iter().flatten() { + let name = b["backend"].as_str().unwrap_or("?"); + let list: Vec<String> = b["adapters"].as_array().into_iter().flatten().map(adapter_line).collect(); + let text = match (b["error"].as_str(), list.is_empty()) { + (Some(e), _) => format!("probe failed: {e}"), + (None, true) => "no adapter".to_owned(), + (None, false) => list.join(", "), + }; + out.push(format!("{name}: {text}")); + } + out +} + +fn machine_line(report: &Value) -> String { + let m = &report["machine"]; + let mut s = format!("{} {}", m["os"].as_str().unwrap_or("?"), m["arch"].as_str().unwrap_or("?")); + if let Some(v) = m["os_version"].as_str() { + let _ = write!(s, " ({v})"); + } + if let Some(n) = m["cpus"].as_u64() { + let _ = write!(s, ", {n} CPUs"); + } + if let Some(gb) = m["ram_total_gb"].as_u64() { + let _ = write!(s, ", {gb} GB RAM"); + } + if let Some(mb) = m["ram_available_mb"].as_u64() { + let _ = write!(s, " ({:.1} GB available)", mb as f64 / 1024.0); + } + s +} + +fn data_line(report: &Value) -> String { + let d = &report["capabilities"]["data"]; + let c = &d["corpus"]; + let n = |k: &str| c[k].as_u64().unwrap_or(0); + let fonts = match (d["craft_fonts"]["set"].as_bool(), d["craft_fonts"]["found"].as_bool()) { + (Some(true), Some(true)) => "CRAFT_FONTS_DIR set", + (Some(true), _) => "CRAFT_FONTS_DIR set but not found", + _ => "no CRAFT_FONTS_DIR", + }; + format!("corpus: {} of {} pinned raws, {} images, {} PngSuite files; {fonts}", n("raw_pinned"), n("raw_pinned_total"), n("images"), n("pngsuite")) +} + +fn commit_line(report: &Value) -> String { + let g = &report["git"]; + let commit = g["commit"].as_str().map_or("unknown commit".to_owned(), |c| c.chars().take(10).collect()); + let state = match g["dirty"].as_bool() { + Some(true) => "with uncommitted changes", + Some(false) => "clean", + None => "state unknown", + }; + format!("{commit} ({state})") +} + +/// The lines printed after the CI summary. +pub fn summary_text(report: &Value) -> String { + let mut s = String::new(); + let _ = writeln!(s, "\nMachine: {}", machine_line(report)); + let _ = writeln!(s, "GPU for the pipeline: {}", gpu_line(report)); + for line in adapters_lines(report) { + let _ = writeln!(s, " adapters on {line}"); + } + let _ = writeln!(s, "Data: {}", data_line(report)); + for h in report["hardware"].as_array().into_iter().flatten() { + let _ = writeln!( + s, + "GPU tests on {} ({}): {}", + h["backend"].as_str().unwrap_or("?"), + h["adapter"].as_str().unwrap_or("?"), + h["status"].as_str().unwrap_or("?") + ); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "Skipped tests: {}", no_skips(report)); + } else { + let counts: Vec<String> = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "Skipped tests: {} passed without running their checks ({}):", skips.len(), counts.join(", ")); + for (need, n) in counts_by_need(&skips) { + let _ = writeln!(s, " {need} ({n}): needs {}", hint(&need)); + let groups = by_reason(&skips, &need); + const SHOWN: usize = 4; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let more = if tests.len() > 1 { ", …" } else { "" }; + let _ = writeln!(s, " {:>3}× {reason} ({first}{more})", tests.len()); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, " {rest:>3}× {} other reasons (target/ci/report.json)", groups.len() - SHOWN); + } + } + } + s +} + +fn cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +/// A Markdown block for a PR description or comment. +pub fn summary_md(report: &Value) -> String { + let mut s = String::new(); + let result = report["result"].as_str().unwrap_or("?"); + let command = report["command"].as_str().unwrap_or("cargo xtask ci"); + let _ = writeln!(s, "<!-- lightcraft ci report, schema {SCHEMA} -->"); + let _ = writeln!(s, "**`{command}`: {result}** at {}\n", commit_line(report)); + let _ = writeln!(s, "| | |\n|---|---|"); + let _ = writeln!(s, "| Machine | {} |", cell(&machine_line(report))); + if let Some(t) = report["toolchain"].as_str() { + let _ = writeln!(s, "| Rust | {} |", cell(t)); + } + let _ = writeln!(s, "| GPU for the pipeline | {} |", cell(&gpu_line(report))); + let adapters = adapters_lines(report); + if !adapters.is_empty() { + let _ = writeln!(s, "| Adapters | {} |", cell(&adapters.join("; "))); + } + let _ = writeln!(s, "| Data | {} |", cell(&data_line(report))); + let _ = writeln!(s, "\n| Step | Result | Time |\n|---|---|---|"); + for step in report["steps"].as_array().into_iter().flatten() { + let time = step["seconds"].as_f64().map(seconds).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {time} |", cell(step["name"].as_str().unwrap_or("?")), step["status"].as_str().unwrap_or("?")); + } + for h in report["hardware"].as_array().into_iter().flatten() { + let time = h["seconds"].as_f64().map(seconds).unwrap_or_default(); + let name = format!("GPU tests on {} ({})", h["backend"].as_str().unwrap_or("?"), h["adapter"].as_str().unwrap_or("?")); + let _ = writeln!(s, "| {} | {} | {time} |", cell(&name), h["status"].as_str().unwrap_or("?")); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "\nSkipped tests: {}.", no_skips(report)); + } else { + let counts: Vec<String> = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "\n<details><summary>{} tests skipped ({})</summary>\n", skips.len(), counts.join(", ")); + let _ = writeln!(s, "| Need | Tests | Reason | For example |\n|---|---|---|---|"); + for (need, _) in counts_by_need(&skips) { + let groups = by_reason(&skips, &need); + const SHOWN: usize = 12; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {} | `{}` |", cell(&need), tests.len(), cell(reason), cell(&first)); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, "| {} | {rest} | {} other reasons | |", cell(&need), groups.len() - SHOWN); + } + } + let _ = writeln!(s, "\n</details>"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("xtask-ci-report-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn skips_are_read_per_step_deduplicated_and_cut_free_of_the_root() { + let root = Path::new("/work/lightcraft"); + let dir = tmp("skips"); + let gpu = + r#"{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}"#; + let corpus = r#"{"need":"corpus","reason":"skip /work/lightcraft/crates/raw/../../corpus/raw absent","test":"decodes","binary":"corpus","location":"crates/raw/tests/corpus.rs:28"}"#; + std::fs::write(dir.join("skips-heif.jsonl"), format!("{corpus}\n")).unwrap(); + std::fs::write(dir.join("skips-test.jsonl"), format!("{gpu}\n{corpus}\nnot json\n")).unwrap(); + std::fs::write(dir.join("other.jsonl"), format!("{gpu}\n")).unwrap(); + let skips = read_skips(&dir, root); + assert_eq!(skips.len(), 2, "{skips:?}"); + assert_eq!(skips[0].need, "corpus"); + assert_eq!(skips[0].reason, "skip corpus/raw absent"); + assert_eq!(skips[0].steps, ["heif", "test"]); + assert_eq!(skips[1].steps, ["test"]); + assert_eq!(counts_by_need(&skips), [("corpus".to_owned(), 1), ("gpu".to_owned(), 1)]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn prepare_clears_the_last_runs_files_only() { + let root = tmp("prepare"); + let dir = dir(&root); + std::fs::create_dir_all(&dir).unwrap(); + for f in ["skips-test.jsonl", "report.json", "summary.md", "keep.txt"] { + std::fs::write(dir.join(f), "x").unwrap(); + } + prepare(&root).unwrap(); + let left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(left, ["keep.txt"]); + assert_eq!(skip_log(&root, "gpu:dx12").file_name().unwrap(), "skips-gpu-dx12.jsonl"); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn parent_dirs_fold_in_path_tokens_only() { + assert_eq!(fold_parent_dirs("crates/raw/../../corpus/raw"), "corpus/raw"); + assert_eq!(fold_parent_dirs("../corpus/raw"), "../corpus/raw"); + assert_eq!(fold_parent_dirs("C:\\w\\crates\\raw\\..\\..\\corpus"), "C:\\w\\corpus"); + assert_eq!(fold_parent_dirs("absent"), "absent"); + } + + #[test] + fn the_probe_asks_the_pipelines_backends_or_all_with_hardware() { + let pipeline = r#"{"pipeline":{"available":false,"adapter":null,"reason":"software adapter (llvmpipe) skipped","backends":["vulkan","dx12"],"compiled":["vulkan","dx12","gl"]}}"#; + let asked = std::cell::RefCell::new(Vec::new()); + let probe = |b: Option<&str>| -> Result<String, String> { + asked.borrow_mut().push(b.map(str::to_owned)); + Ok(match b { + None => format!("running 1 test\ntest gpu_probe ... \ngpu_probe: {pipeline}\nok\n"), + Some("vulkan") => r#"gpu_probe: {"backend":"vulkan","adapters":[{"name":"llvmpipe","type":"Cpu","driver":"llvmpipe","driver_info":"Mesa"}]}"#.to_owned(), + Some("dx12") => r#"gpu_probe: {"backend":"dx12","adapters":[{"name":"Intel(R) UHD Graphics 620","type":"IntegratedGpu","driver":"","driver_info":"31.0.101"}]}"#.to_owned(), + Some(_) => return Err("crashed".into()), + }) + }; + let gpu_default = gpu(probe, false); + assert_eq!(*asked.borrow(), [None, Some("vulkan".to_owned()), Some("dx12".to_owned())]); + assert_eq!(hardware_backends(&gpu_default), [("dx12".to_owned(), "Intel(R) UHD Graphics 620".to_owned())]); + asked.borrow_mut().clear(); + let gpu_all = gpu(probe, true); + assert_eq!(asked.borrow().len(), 4, "gl too"); + assert_eq!(gpu_all["backends"][2]["error"], "crashed"); + + let report = json!({ + "command": "cargo xtask ci", + "result": "pass", + "git": {"commit": "0123456789abcdef", "dirty": false}, + "machine": {"os": "windows", "arch": "x86_64", "cpus": 8, "ram_total_gb": 16}, + "capabilities": {"gpu": gpu_all, "data": {"corpus": {"raw_pinned": 3, "raw_pinned_total": 30}, "craft_fonts": {"set": false}}}, + "steps": [{"name": "fmt", "status": "ok", "seconds": 1.25}, {"name": "test", "status": "ok", "seconds": 125.0}], + "skips": [{"need": "gpu", "reason": "no GPU | adapter", "test": "memory", "binary": "memory", "location": "x.rs:1", "steps": ["test"]}], + }); + let text = summary_text(&report); + assert!(text.contains("GPU for the pipeline: none (software adapter (llvmpipe) skipped)"), "{text}"); + assert!(text.contains("adapters on dx12: Intel(R) UHD Graphics 620 (IntegratedGpu; 31.0.101)"), "{text}"); + assert!(text.contains("adapters on gl: probe failed: crashed"), "{text}"); + assert!(text.contains("Skipped tests: 1 passed without running their checks (gpu 1)"), "{text}"); + assert!(text.contains(" gpu (1): needs a hardware GPU adapter"), "{text}"); + assert!(text.contains(" 1× no GPU | adapter (memory::memory)"), "{text}"); + assert!(text.contains("corpus: 3 of 30 pinned raws"), "{text}"); + let md = summary_md(&report); + assert!(md.contains("**`cargo xtask ci`: pass** at 0123456789 (clean)"), "{md}"); + assert!(md.contains("| test | ok | 2m 05s |"), "{md}"); + assert!(md.contains("| gpu | 1 | no GPU \\| adapter | `memory::memory` |"), "{md}"); + assert!(md.contains("1 tests skipped (gpu 1)"), "{md}"); + } + + #[test] + fn no_skips_is_not_claimed_when_the_tests_never_ran() { + let stopped = json!({"steps": [{"name": "clippy", "status": "fail"}, {"name": "test", "status": "not run"}], "skips": []}); + assert!(summary_text(&stopped).contains("Skipped tests: unknown, the test step did not run")); + let ran = json!({"steps": [{"name": "test", "status": "ok"}], "skips": []}); + assert!(summary_md(&ran).contains("Skipped tests: none recorded.")); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..0bc3631ba 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod bench; +mod ci_report; mod ico; mod layers; mod parity; @@ -33,7 +34,11 @@ commands: web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into <target>/web-site/; --serve serves it on http://127.0.0.1:<port> (default 8080) - ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure) + ci [--hardware] [--summary-md] + fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm + (stops at first failure); writes target/ci/report.json (machine, GPU adapters, data, + steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on + every backend with a hardware adapter; --summary-md prints the Markdown summary corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -51,7 +56,7 @@ fn main() -> ExitCode { Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), - Some("ci") => cmd_ci(), + Some("ci") => cmd_ci(&rest), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("-h" | "--help" | "help") | None => { @@ -240,9 +245,38 @@ fn cmd_wasm() -> Result<(), String> { if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } } -fn cmd_ci() -> Result<(), String> { - type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>); - let steps: Vec<Step> = vec![ +fn cmd_ci(args: &[&str]) -> Result<(), String> { + let hardware = args.contains(&"--hardware"); + let print_md = args.contains(&"--summary-md"); + if let Some(other) = args.iter().find(|a| !matches!(**a, "--hardware" | "--summary-md")) { + return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); + } + let root = root(); + let report_dir = ci_report::prepare(&root)?; + let started = std::time::Instant::now(); + let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + // test runs append the skips their tests record (`lightcraft_testkit::skip!`) to target/ci/skips-<step>.jsonl + let test = |cargo_args: &[&str], step: &str| { + let mut c = cargo(); + c.args(cargo_args).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step)); + run(c, &format!("cargo {}", cargo_args.join(" "))) + }; + let gpu = std::cell::RefCell::new(serde_json::Value::Null); + let hardware_runs = std::cell::RefCell::new(Vec::new()); + // What this machine offers the GPU tests, once, after the workspace tests (whose build the + // probe reuses). Never fails the run: the report says what went wrong. + let probe = || { + if gpu.borrow().is_null() { + eprintln!("\n=== ci: GPU probe ==="); + let t = std::time::Instant::now(); + let mut found = ci_report::gpu(probe_gpu, hardware); + found["seconds"] = serde_json::json!(t.elapsed().as_secs_f64()); + *gpu.borrow_mut() = found; + } + }; + + type Step<'a> = (&'static str, Box<dyn Fn() -> Result<(), String> + 'a>); + let mut steps: Vec<Step> = vec![ ( "fmt", Box::new(|| { @@ -266,46 +300,144 @@ fn cmd_ci() -> Result<(), String> { let mut c = cargo(); c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?; - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"]); - run(c, "cargo test -p lightcraft-codecs -p lightcraft-heif --features lightcraft-codecs/heif")?; + test(&["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"], "heif")?; // the engine's HEIC import test, with the decoder (the workspace run checks the error) - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]); - run(c, "cargo test -p lightcraft-engine --lib --features lightcraft-codecs/heif heic") + test(&["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"], "heif") }), ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") - }), - ), - ("parity", Box::new(|| parity::run(&root(), false))), - ("layers", Box::new(cmd_layers)), - ("assets", Box::new(|| assets::run(&root()))), - ("wasm", Box::new(cmd_wasm)), + ("test", Box::new(|| test(&["test", "--workspace"], "test"))), ]; - let mut done = Vec::new(); + if hardware { + steps.push(( + "gpu-backends", + Box::new(|| { + // the GPU tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`) + probe(); + let backends = ci_report::hardware_backends(&gpu.borrow()); + if backends.is_empty() { + eprintln!("no hardware GPU adapter on any backend: nothing to run"); + } + let mut failed = Vec::new(); + for (backend, adapter) in backends { + let step = format!("gpu-{backend}"); + let mut c = cargo(); + c.args(["test", "-p", "lightcraft-gpu", "--features", "denoise"]) + .env("LIGHTCRAFT_GPU_BACKEND", &backend) + .env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step)); + let t = std::time::Instant::now(); + let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} cargo test -p lightcraft-gpu --features denoise")).is_ok(); + hardware_runs.borrow_mut().push(serde_json::json!({ + "backend": backend, + "adapter": adapter, + "status": if ok { "ok" } else { "fail" }, + "seconds": t.elapsed().as_secs_f64(), + })); + if !ok { + failed.push(backend); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("the GPU tests failed on {}", failed.join(", "))) } + }), + )); + } + steps.push(("parity", Box::new(|| parity::run(&root, false)))); + steps.push(("layers", Box::new(cmd_layers))); + steps.push(("assets", Box::new(|| assets::run(&root)))); + steps.push(("wasm", Box::new(cmd_wasm))); + + let mut results = Vec::new(); + let mut failure = None; for (name, f) in &steps { eprintln!("\n=== ci: {name} ==="); - if let Err(e) = f() { + let t = std::time::Instant::now(); + let r = f(); + let status = if r.is_ok() { "ok" } else { "fail" }; + results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); + if *name == "test" { + // right after the workspace tests, before parity and wasm build other feature sets + probe(); + } + if let Err(e) = r { + failure = Some((*name, e)); + break; + } + } + if gpu.borrow().is_null() { + *gpu.borrow_mut() = serde_json::json!({"error": "not probed: the test step did not run"}); + } + let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); + match &failure { + Some((name, e)) => { println!("\nCI summary:"); - for d in &done { + for d in done.iter().filter(|d| *d != name) { println!(" ok {d}"); } println!(" FAIL {name}: {e}"); - for (n, _) in steps.iter().skip(done.len() + 1) { + for (n, _) in steps.iter().skip(results.len()) { println!(" skip {n}"); } - return Err(format!("ci failed at `{name}`")); } - done.push(*name); + None => println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")), } - println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")); - Ok(()) + for (n, _) in steps.iter().skip(results.len()) { + results.push(serde_json::json!({"name": n, "status": "not run"})); + } + + let skips = ci_report::read_skips(&report_dir, &root); + let ram_mb = available_ram_mb(); + let report = serde_json::json!({ + "schema": ci_report::SCHEMA, + "command": std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::<Vec<_>>().join(" "), + "result": if failure.is_some() { "fail" } else { "pass" }, + "failed_step": failure.as_ref().map(|(n, _)| *n), + "started_unix": started_unix, + "seconds": started.elapsed().as_secs_f64(), + "git": ci_report::git(&root), + "machine": ci_report::machine(total_ram_gb(), ram_mb), + "toolchain": ci_report::toolchain(), + "capabilities": {"gpu": gpu.take(), "data": ci_report::data(&root, &RAW_SAMPLES.iter().map(|s| s.0).collect::<Vec<_>>())}, + "steps": results, + "hardware": hardware_runs.take(), + "skips": ci_report::skips_json(&skips), + }); + print!("{}", ci_report::summary_text(&report)); + let md = ci_report::summary_md(&report); + let written = serde_json::to_string_pretty(&report) + .map_err(|e| e.to_string()) + .and_then(|json| std::fs::write(report_dir.join("report.json"), json + "\n").map_err(|e| e.to_string())) + .and_then(|()| std::fs::write(report_dir.join("summary.md"), &md).map_err(|e| e.to_string())); + match written { + Ok(()) => println!("Report: target/ci/report.json; to paste into a PR: target/ci/summary.md (or run with --summary-md)"), + Err(e) => eprintln!("ci: could not write the report to {}: {e}", report_dir.display()), + } + if print_md { + println!("\n{md}"); + } + match failure { + Some((name, _)) => Err(format!("ci failed at `{name}`")), + None => Ok(()), + } +} + +/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one +/// backend's adapters. `--workspace`, like the test step, so its build is reused. Its stdout, or why +/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask). +fn probe_gpu(backend: Option<&str>) -> Result<String, String> { + let mut c = cargo(); + c.args(["test", "-q", "--workspace", "--test", "gpu_probe", "--", "--ignored", "--exact", "gpu_probe", "--nocapture"]); + c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or("")); + let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect(); + return Err(format!( + "gpu_probe {}exited with {}: {}", + backend.map(|b| format!("{b} ")).unwrap_or_default(), + out.status, + tail.into_iter().rev().collect::<Vec<_>>().join(" / ") + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only). From dbf2dcbfbd3cd75e901ce2ed6b2871397c3e7811 Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:33:54 -0500 Subject: [PATCH 032/315] docs: #1037 visible skips and the ci report Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 6 +++++- CLAUDE.md | 6 +++++- README.md | 7 +++++++ docs/architecture.md | 15 +++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cd199ff6..3d615d00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,11 +125,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not in CI: it may lag behind the workspace; see README → Quick start. diff --git a/CLAUDE.md b/CLAUDE.md index 4a736f12a..5596bb95f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,11 +132,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not in CI: it may lag behind the workspace; see README → Quick start. diff --git a/README.md b/README.md index 8884f2bd3..6f9ff1cba 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,13 @@ per CPU, otherwise exhaust RAM before any test runs. GPU coverage is unchanged. `CARGO_PROFILE_DEV_DEBUG`, `CARGO_BUILD_JOBS` and `RUST_TEST_THREADS` settings override these defaults. Ordinary development commands keep their own settings. +Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when +those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip +with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU +adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and +`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the +GPU tests on every backend that has a hardware adapter. Nothing is uploaded. + **Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it): ```sh diff --git a/docs/architecture.md b/docs/architecture.md index c50066a10..7d1b7a3ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code; the full design notes stay in the local `plan/architecture.md`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`cargo xtask ci` reports the machine and skipped tests; `testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted @@ -19,7 +19,7 @@ L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower la | L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation | | L4 | `engine` (53.2k) | Session, command registry (everything is a command), history, render and export jobs, import, Lightroom catalog and preset import, faces index, view-models | | L5 | `ui-egui` (46.9k), `mcp` (2.3k) | The Lightroom-style egui interface (swappable); the MCP server | -| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (2.7k) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling | +| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (3.4k), `testkit` (0.2k, dev-dependency only) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling; test helpers (skips the CI report lists) | ## Data model @@ -63,8 +63,19 @@ dispatch by id, and headless snapshots render the UI without a window. `assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and test FreeBSD and Windows ARM64, lint packaging, and build releases. +`ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored +`gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask +has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests +that need what is missing call `lightcraft_testkit::skip!(need, reason)`, which appends a JSON line to +the file `LIGHTCRAFT_SKIP_LOG` names. The run ends with the machine and the skips by need and reason, +and writes `target/ci/report.json` (commit, dirty flag, machine, capabilities, each step's result and +time, skips) and `target/ci/summary.md` (Markdown for a PR; `--summary-md` prints it). The report holds +no host name, user name or absolute path, and nothing is uploaded. `--hardware` adds a step that runs +the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` | | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) | From 615d8a365e510e3b25e380ceb9fad8e791e79863 Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 033/315] perf(catalog): #1136 index stacks and album membership, sort keys once per photo stack_of, validate_stack and regular-album membership looked a photo up by scanning every stack or the album's list, so an "In a Stack" or "Album" smart album, or a query filtered to an album, cost photos x list length (2.7 s and 0.6-0.8 s at 100k photos). Derived indexes (never saved) answer them now: built on first use, kept current by the six ops that change stacks or album photos in apply_inner, so undo, failed-batch rollback and journal replay follow. A file with a photo in two stacks falls back to the scan. query() works out each sort key once (File Name was lower-cased twice per comparison) and tests `only` as a set; smart-album passes prepare the filter once; rules name flag, label and kind without formatting per photo. A property test checks the indexes against a scan through random op sequences, rollbacks, snapshots, replay, clones and undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../proptest-regressions/tests_index.txt | 8 + crates/catalog/src/index.rs | 101 ++++++++ crates/catalog/src/lib.rs | 44 +++- crates/catalog/src/query.rs | 71 ++++-- crates/catalog/src/rules.rs | 37 ++- crates/catalog/src/stacks.rs | 4 +- crates/catalog/src/tests_index.rs | 222 ++++++++++++++++++ crates/catalog/src/tests_scale.rs | 92 ++++++++ 8 files changed, 541 insertions(+), 38 deletions(-) create mode 100644 crates/catalog/proptest-regressions/tests_index.txt create mode 100644 crates/catalog/src/index.rs create mode 100644 crates/catalog/src/tests_index.rs create mode 100644 crates/catalog/src/tests_scale.rs diff --git a/crates/catalog/proptest-regressions/tests_index.txt b/crates/catalog/proptest-regressions/tests_index.txt new file mode 100644 index 000000000..9c827f0c0 --- /dev/null +++ b/crates/catalog/proptest-regressions/tests_index.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)] +cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)] diff --git a/crates/catalog/src/index.rs b/crates/catalog/src/index.rs new file mode 100644 index 000000000..2c347473b --- /dev/null +++ b/crates/catalog/src/index.rs @@ -0,0 +1,101 @@ +//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library +//! don't scan a list each time: which stack a photo is in, whether a regular album holds it. +//! +//! They are never saved (the on-disk format is unchanged). Each is built on first use from what +//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts +//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to +//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`, +//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the +//! rollback of a failed batch included, as those are ops too. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use crate::{AlbumId, Catalog, PhotoId, StackId}; + +#[derive(Default)] +pub(crate) struct Indexes { + /// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks + /// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts. + stacks: OnceLock<Option<HashMap<PhotoId, StackId>>>, + /// Album → its photos (regular albums; a smart album's list is empty). + albums: OnceLock<HashMap<AlbumId, HashSet<PhotoId>>>, +} + +/// A copy builds its own when asked (copies are made to be saved, not queried). +impl Clone for Indexes { + fn clone(&self) -> Self { + Indexes::default() + } +} + +/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was +/// built. +impl PartialEq for Indexes { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for Indexes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len)); + f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish() + } +} + +impl Indexes { + /// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone). + pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) { + let Some(Some(m)) = self.stacks.get_mut() else { return }; + for p in old { + if m.get(p) == Some(&id) { + m.remove(p); + } + } + for p in new { + m.insert(*p, id); + } + } + + /// Album `id` holds `photos` now (`None`: it is gone). + pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) { + let Some(m) = self.albums.get_mut() else { return }; + match photos { + Some(photos) => { + m.insert(id, photos.iter().copied().collect()); + } + None => { + m.remove(&id); + } + } + } +} + +impl Catalog { + /// The stack photo `photo` is in, by the index (built on first use). + pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option<StackId> { + let m = self.index.stacks.get_or_init(|| { + let mut m = HashMap::new(); + for s in self.stacks.values() { + for p in &s.photos { + if m.insert(*p, s.id).is_some_and(|other| other != s.id) { + return None; + } + } + } + Some(m) + }); + match m { + Some(m) => m.get(&photo).copied(), + // in id order, the first stack listing the photo has it + None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id), + } + } + + /// Whether album `album`'s own list holds `photo`, by the index (built on first use). + pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool { + let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect()); + m.get(&album).is_some_and(|s| s.contains(&photo)) + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index cc24266f2..eec22fa9c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -15,6 +15,7 @@ pub mod dates; pub mod folders; +mod index; pub mod journal; pub mod keywords; pub mod local; @@ -275,6 +276,9 @@ pub struct Catalog { /// Increments on every applied op. #[serde(skip)] pub revision: u64, + /// Lookups derived from the above (never saved), see [`index`]. + #[serde(skip)] + index: index::Indexes, } impl Catalog { @@ -357,13 +361,18 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - // guarded: a smart album testing smart albums can't loop or recurse without end - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)), - Some(a) => a.photos.contains(&p.id), + Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p), + Some(a) => self.album_lists(a.id, p.id), None => false, } } + /// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`. + fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool { + // guarded: a smart album testing smart albums can't loop or recurse without end + !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared)) + } + /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the /// rules of `from` would then change when those of `to` do). Loops in saved rules end the /// search, they don't repeat it. @@ -411,7 +420,10 @@ impl Catalog { /// The photos of an album: the stored list, or a smart album's current matches (id order). pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> { match self.albums.get(&id) { - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect() + } Some(a) => a.photos.clone(), None => Vec::new(), } @@ -421,7 +433,10 @@ impl Catalog { pub fn album_count(&self, id: AlbumId) -> usize { match self.albums.get(&id) { // counted in place: no id list is built just for its length - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count() + } Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), None => 0, } @@ -569,6 +584,7 @@ impl Catalog { } let id = album.id; self.next_album = self.next_album.max(id.0 + 1); + self.index.album_changed(id, Some(&album.photos)); self.albums.insert(id, album); Op::RemoveAlbum { id } } @@ -577,6 +593,7 @@ impl Catalog { return Err(CatalogError::Invalid("folder is not empty".into())); } let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?; + self.index.album_changed(id, None); Op::AddAlbum { album: a } } Op::RenameAlbum { id, name } => { @@ -614,13 +631,16 @@ impl Catalog { Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) } } Op::SetAlbumPhotos { id, photos } => { - let a = self.album_mut(id)?; + // the field, not `album_mut`: the index is updated while the album is borrowed + let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?; if (a.folder || a.smart.is_some()) && !photos.is_empty() { return Err(CatalogError::Invalid( if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(), )); } - Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } + let old = std::mem::replace(&mut a.photos, photos); + self.index.album_changed(id, Some(&a.photos)); + Op::SetAlbumPhotos { id, photos: old } } Op::SetAlbumCover { id, cover } => { let a = self.album_mut(id)?; @@ -641,11 +661,13 @@ impl Catalog { self.validate_stack(stack.id, &stack.photos)?; let id = stack.id; self.next_stack = self.next_stack.max(id.0 + 1); + self.index.stack_changed(id, &[], &stack.photos); self.stacks.insert(id, stack); Op::RemoveStack { id } } Op::RemoveStack { id } => { let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + self.index.stack_changed(id, &s.photos, &[]); Op::AddStack { stack: s } } Op::SetStack { id, photos, collapsed } => { @@ -654,7 +676,9 @@ impl Catalog { } self.validate_stack(id, &photos)?; let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; - let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + let old_photos = std::mem::replace(&mut s.photos, photos); + self.index.stack_changed(id, &old_photos, &s.photos); + let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed }; s.collapsed = collapsed; old } @@ -853,10 +877,14 @@ mod tests_folders; #[cfg(test)] mod tests_format_version; #[cfg(test)] +mod tests_index; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_local; #[cfg(test)] mod tests_lock; #[cfg(test)] +mod tests_scale; +#[cfg(test)] mod tests_torn_append; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 8b694a021..9d3eb90c7 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -112,6 +112,21 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 { mix64(mix64(seed) ^ id.0) } +/// What [`Filter::prepare`] works out once for a pass over many photos. +pub(crate) struct Prepared { + root: Option<String>, + only: Option<std::collections::HashSet<PhotoId>>, +} + +/// The ids of `photos` by `key`, ties by id (so the order is total and the same every time), +/// reversed when not `ascending`. Each photo's key is worked out once. +fn sorted<'a, K: Ord>(photos: &[&'a Photo], ascending: bool, key: impl Fn(&'a Photo) -> K) -> Vec<PhotoId> { + let mut keyed: Vec<(K, PhotoId)> = photos.iter().map(|p| (key(p), p.id)).collect(); + // ids differ, so there are no ties and an unstable sort gives the one order there is + keyed.sort_unstable_by(|a, b| if ascending { a.cmp(b) } else { b.cmp(a) }); + keyed.into_iter().map(|(_, id)| id).collect() +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); if let Some((field, val)) = t.split_once(':') { @@ -234,7 +249,18 @@ impl Filter { } pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { - self.matches_in(p, cat, self.library_root().as_deref()) + self.matches_in(p, cat, &self.prepare_one()) + } + + /// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed). + pub(crate) fn prepare_one(&self) -> Prepared { + Prepared { root: self.library_root(), only: None } + } + + /// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than + /// once per photo: the library folder's identity and the `only` ids as a set. + pub(crate) fn prepare(&self) -> Prepared { + Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) } } /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice. @@ -244,9 +270,9 @@ impl Filter { self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key) } - /// [`Filter::matches`] with the library folder already turned into its identity, so a query - /// over a big library does that once, not once per photo. - fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool { + /// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a + /// big library does that once, not once per photo. + pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool { if p.deleted != self.deleted { return false; } @@ -285,7 +311,7 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } - if !self.only.is_empty() && !self.only.contains(&p.id) { + if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) { return false; } if let Some(want) = &self.merged { @@ -325,7 +351,7 @@ impl Filter { { return false; } - if let Some(root) = root + if let Some(root) = prepared.root.as_deref() && !photo_in_root(p, root) { return false; @@ -362,22 +388,19 @@ impl Filter { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by id for stability). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> { - let root = filter.library_root(); - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); - v.sort_by(|a, b| { - let o = match sort.key { - SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), - SortKey::ImportDate => a.imported.cmp(&b.imported), - SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), - SortKey::Rating => a.rating.cmp(&b.rating), - SortKey::FileSize => a.file_size.cmp(&b.file_size), - SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), - } - .then(a.id.cmp(&b.id)); - if sort.ascending { o } else { o.reverse() } - }); - v.into_iter().map(|p| p.id).collect() + let prepared = filter.prepare(); + let v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, &prepared)).collect(); + let asc = sort.ascending; + match sort.key { + SortKey::CaptureDate => sorted(&v, asc, |p| (p.captured.as_deref(), p.imported.as_str())), + SortKey::ImportDate => sorted(&v, asc, |p| p.imported.as_str()), + SortKey::EditDate => sorted(&v, asc, |p| p.edited.as_deref()), + // each name lower-cased once, not twice per comparison + SortKey::FileName => sorted(&v, asc, |p| p.file_name.to_lowercase()), + SortKey::Rating => sorted(&v, asc, |p| p.rating), + SortKey::FileSize => sorted(&v, asc, |p| p.file_size), + SortKey::Random => sorted(&v, asc, |p| shuffle_rank(sort.seed, p.id)), + } } /// Year → month → day counts for the "By Date" section (newest first). @@ -430,8 +453,8 @@ impl Catalog { pub fn people_in(&self, filter: &Filter) -> Vec<Person> { let filter = Filter { person: None, ..filter.clone() }; let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default(); - let root = filter.library_root(); - for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) { + let prepared = filter.prepare(); + for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) { let mut seen: Vec<String> = Vec::new(); for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index f17f12e01..a87c9effc 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -598,6 +598,35 @@ pub fn now() -> String { } } +/// A flag as rules name it (its variant, lower case), without building a string per photo. +fn flag_name(f: crate::Flag) -> &'static str { + match f { + crate::Flag::None => "none", + crate::Flag::Pick => "pick", + crate::Flag::Reject => "reject", + } +} + +/// A colour label's colour as rules name it (lower case). +fn label_colour(l: crate::ColorLabel) -> &'static str { + match l { + crate::ColorLabel::Red => "red", + crate::ColorLabel::Yellow => "yellow", + crate::ColorLabel::Green => "green", + crate::ColorLabel::Blue => "blue", + crate::ColorLabel::Purple => "purple", + } +} + +/// A media kind as rules name it (lower case). +fn kind_name(k: crate::MediaKind) -> &'static str { + match k { + crate::MediaKind::Image => "image", + crate::MediaKind::Raw => "raw", + crate::MediaKind::Video => "video", + } +} + fn lower(v: &Value) -> String { match v { Value::String(s) => s.trim().to_lowercase(), @@ -778,16 +807,16 @@ impl Rule { match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { - let f = format!("{:?}", p.flag).to_lowercase(); + let f = flag_name(p.flag); (f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is") } "label" => { - let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into()); + let l = p.label.map_or("none", label_colour); // custom label names count too - let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want); + let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want)); (l == want || named) == (op == "is") } - "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), + "kind" => (kind_name(p.kind) == want) == (op == "is"), "edited" => yes(p.is_edited()), "hasGps" => yes(m.gps.is_some()), "virtualCopy" => yes(p.copy_of.is_some()), diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 304106aef..83dbc13ab 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -20,7 +20,7 @@ impl Catalog { } /// The stack a photo belongs to. pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { - self.stacks.values().find(|s| s.photos.contains(&photo)) + self.stack_id_of(photo).and_then(|id| self.stacks.get(&id)) } /// Photo → (stack, position in the stack), for every stacked photo. pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> { @@ -40,7 +40,7 @@ impl Catalog { return Err(CatalogError::NoPhoto(*p)); } } - if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) { return Err(CatalogError::Invalid("a photo can be in only one stack".into())); } Ok(()) diff --git a/crates/catalog/src/tests_index.rs b/crates/catalog/src/tests_index.rs new file mode 100644 index 000000000..abfd4ef51 --- /dev/null +++ b/crates/catalog/src/tests_index.rs @@ -0,0 +1,222 @@ +//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op, +//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same +//! results as before. + +use proptest::prelude::*; + +use super::*; + +const PHOTOS: u64 = 8; + +fn library() -> Catalog { + let mut c = Catalog::new(); + for i in 0..PHOTOS { + let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + c +} + +/// What the indexes must say, worked out by scanning (as the catalog did before it had them). +fn scanned(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) { + let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// What the indexes say. +fn indexed(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) { + let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail). +fn ids(bits: u16) -> Vec<PhotoId> { + (0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect() +} + +fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op { + match kind % 8 { + 0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } }, + 1 => Op::RemoveStack { id: StackId(n % 4 + 1) }, + 2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed }, + 3 => { + let mut a = Album::new(AlbumId(n % 4 + 1), "A"); + a.photos = ids(bits); + Op::AddAlbum { album: a } + } + 4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) }, + 5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + // a batch whose last op fails half the time: what it did is rolled back + 6 => Op::Batch { + ops: vec![ + Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed }, + Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } }, + ], + }, + // as the engine deletes a photo: out of its albums and stacks too, in one batch + _ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)), + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::<bool>()), 1..40)) { + let mut c = library(); + let start = c.to_snapshot(); + // built before the ops, so every op has to keep them up to date + prop_assert_eq!(indexed(&c), scanned(&c)); + let mut log = String::new(); + let mut inverses = Vec::new(); + for (kind, n, bits, collapsed) in steps { + let op = op(&c, kind, n, bits, collapsed); + if let Ok(inv) = c.apply(op.clone()) { + log.push_str(&Catalog::op_to_log_line(&op)); + inverses.push(inv); + } + prop_assert_eq!(indexed(&c), scanned(&c)); + } + // a snapshot, a replayed journal and a clone build their own + let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + prop_assert_eq!(indexed(&snap), scanned(&c)); + let mut replayed = Catalog::from_snapshot(&start).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&replayed)); + replayed.replay(&log).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&c)); + prop_assert_eq!(indexed(&c.clone()), scanned(&c)); + prop_assert!(c.clone() == c, "the indexes don't take part in equality"); + // undone step by step + for inv in inverses.into_iter().rev() { + c.apply(inv).unwrap(); + prop_assert_eq!(indexed(&c), scanned(&c)); + } + prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone"); + } +} + +#[test] +fn a_photo_in_another_stack_is_refused_by_the_index() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err(); + assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into())); + // its own stack may keep it + c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap(); + assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1))); +} + +/// The sort keys are worked out once per photo now; the order is the one the comparisons gave. +#[test] +fn sorted_queries_keep_their_order() { + let mut c = Catalog::new(); + let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"]; + for (i, n) in names.iter().enumerate() { + let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3)); + p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4)); + p.rating = (i % 3) as u8; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let all = Filter::default(); + for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random] + { + for ascending in [true, false] { + let sort = Sort { key, ascending, seed: 7, ..Default::default() }; + // the comparison the query used to sort by + let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect(); + want.sort_by(|a, b| { + let o = match key { + SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), + SortKey::ImportDate => a.imported.cmp(&b.imported), + SortKey::EditDate => a.edited.cmp(&b.edited), + SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), + SortKey::Rating => a.rating.cmp(&b.rating), + SortKey::FileSize => a.file_size.cmp(&b.file_size), + SortKey::Random => mix64(mix64(7) ^ a.id.0).cmp(&mix64(mix64(7) ^ b.id.0)), + } + .then(a.id.cmp(&b.id)); + if ascending { o } else { o.reverse() } + }); + let want: Vec<PhotoId> = want.iter().map(|p| p.id).collect(); + assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}"); + } + } + // `only` as a set gives the photos it names + let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() }; + assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]); +} + +/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave). +#[test] +fn rule_names_are_the_variants_in_lower_case() { + let mut c = Catalog::new(); + let mut n = 0; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + for label in + [None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)] + { + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + n += 1; + let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00"); + (p.flag, p.label, p.kind) = (flag, label, kind); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + } + } + c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap(); + let count = |field: &str, value: String| { + let rules: RuleSet = + serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap(); + c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len() + }; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}"); + } + assert_eq!(count("flag", "picked".into()), 18); + for label in ColorLabel::ALL { + assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}"); + } + assert_eq!(count("label", "sky".into()), 9, "a custom name"); + assert_eq!(count("label", "none".into()), 9); + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}"); + } +} + +/// A hand-edited file can put a photo in two stacks (ops refuse it): the stack index then gives +/// the answers a scan of the stacks gives, before and after either stack goes. +#[test] +fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap(); + v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]); + let mut c = Catalog::from_snapshot(&v.to_string()).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1))); + // the second stack can't be changed to keep it, as before + assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err()); + c.apply(Op::RemoveStack { id: StackId(1) }).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(2))); +} diff --git a/crates/catalog/src/tests_scale.rs b/crates/catalog/src/tests_scale.rs new file mode 100644 index 000000000..81ddccc41 --- /dev/null +++ b/crates/catalog/src/tests_scale.rs @@ -0,0 +1,92 @@ +//! What library queries cost at 100k photos (ignored: +//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`). + +use std::time::Instant; + +use serde_json::json; + +use super::*; + +/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags. +fn library(n: u64) -> (Catalog, AlbumId) { + let mut c = Catalog::new(); + let mut ops: Vec<Op> = (0..n) + .map(|i| { + let mut p = Photo::new( + PhotoId(i + 1), + Source::Demo { scene: (i % 20) as u32 }, + &format!("IMG_{:06}.jpg", mix64(i) % 1_000_000), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24)); + p.rating = (i % 6) as u8; + p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None }; + p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()]; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + ops.extend( + (0..n / 10) + .map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }), + ); + let album = AlbumId(1); + let mut a = Album::new(album, "Big"); + a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect(); + ops.push(Op::AddAlbum { album: a }); + let t = Instant::now(); + c.apply(Op::Batch { ops }).unwrap(); + eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3); + (c, album) +} + +fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId { + let mut a = Album::new(AlbumId(id), format!("Smart {id}")); + a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() })); + c.apply(Op::AddAlbum { album: a }).unwrap(); + AlbumId(id) +} + +/// Best of five runs (the machine may be busy with other work). +fn time<T>(what: &str, f: impl Fn() -> T) -> T { + let mut best = f64::MAX; + let mut r = None; + for _ in 0..5 { + let t = Instant::now(); + r = Some(f()); + best = best.min(t.elapsed().as_secs_f64() * 1e3); + } + eprintln!("{what}: {best:.1} ms"); + r.unwrap() +} + +#[test] +#[ignore] +fn scale_100k() { + let (mut c, album) = library(100_000); + let all = Filter::default(); + time("query, capture date", || c.query(&all, &Sort::default())); + time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() })); + time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default())); + time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default())); + let only: Vec<PhotoId> = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect(); + time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default())); + let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]})); + let flagged = smart( + &mut c, + 11, + json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}), + ); + let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]})); + let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]})); + let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]})); + for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] { + let n = time(&format!("smart album count, {what}"), || c.album_count(a)); + eprintln!(" = {n}"); + } + time("albums_of", || c.albums_of(PhotoId(6))); + time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default()))); + time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count()); +} From 3f3ffa541a1d3466fa3723dace04d45409792764 Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 034/315] perf(engine): #1136 album order and album commands without per-photo scans A manual album shown in its own order sorted with position() per photo (O(n^2): 2.0 s for 20k photos, now 18 ms). album.addPhotos, removePhotos and toggleTarget test membership with sets. The 100k scale test measures all three. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/library.rs | 12 +++++++++--- crates/engine/src/lib.rs | 8 ++++++-- crates/engine/src/tests_library.rs | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 2d06d73f6..9a5cb410b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1147,8 +1147,10 @@ pub fn specs() -> Vec<CommandSpec> { let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; let mut photos = al.photos.clone(); let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect(); for t in targets { - if !photos.contains(&t) { + if held.insert(t) { photos.push(t); } } @@ -1179,11 +1181,14 @@ pub fn specs() -> Vec<CommandSpec> { } }; let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t)); + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); let photos: Vec<PhotoId> = if all_in { + let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect(); al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect() + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() }; let name = al.name.clone(); let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); @@ -1317,6 +1322,7 @@ pub fn specs() -> Vec<CommandSpec> { if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); } + let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect(); let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; ok() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..09fb12f2a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -874,8 +874,12 @@ impl Session { && !al.is_smart() && self.filter == Filter::default() { - let order = al.photos.clone(); - visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); + // each photo's place looked up, not searched for (a big album sorted in O(n log n)) + let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len()); + for (i, id) in al.photos.iter().enumerate() { + place.entry(*id).or_insert(i); + } + visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..0fa72e453 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -440,6 +440,21 @@ fn scale_100k_library_queries() { m("undo", &mut || { s.execute("edit.undo", &serde_json::json!({})).unwrap(); }); + // a 20k-photo album shown in its own order (capture-date sort, no filter) + let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec<u64> = (0..n).rev().step_by(5).map(|i| i + 1).collect(); + m("add 20k to an album", &mut || { + s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap(); + }); + m("album of 20k (album order)", &mut || { + s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap(); + let _ = s.visible_cloned(); + }); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + let half: Vec<u64> = ids.iter().copied().step_by(2).collect(); + m("remove 10k from the album", &mut || { + s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap(); + }); worst.sort_by(|a, b| b.1.total_cmp(&a.1)); eprintln!("slowest: {:?}", &worst[..3]); } From 39c33a99d6b7105fea2f783238c1a74ff2c8faa8 Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 035/315] perf(ui): #1136 dialogs count once per library change, not every frame Create Smart Album ran a full query for its count and Rename Keyword scanned every photo on each frame the dialog was open; Merge Keywords recomputed suggestions. They use Caches::dialog_count (keyed by catalog revision and what is counted, and the minute for relative-date rules) and the cached suggestions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 20 ++++++++++++++------ crates/ui-egui/src/tests_keywording.rs | 21 +++++++++++++++++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..35801ba9f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1477,6 +1477,9 @@ pub struct Caches { /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). rules_view: Option<(u64, std::sync::Arc<RulesView>)>, pub rules_view_scans: usize, + /// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts. + dialog_count: Option<(u64, usize)>, + pub dialog_count_scans: usize, /// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]). smart_problems: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>>)>, pub smart_problem_scans: usize, @@ -1749,6 +1752,21 @@ impl Caches { v } + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. pub fn smart_album_problems( diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..60ccdb6e3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -430,7 +430,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let rules = app.session.view_rules(); - let n = app.session.catalog.query(&rules, &Default::default()).len(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); ui.label( egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) @@ -573,7 +579,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::RenameKeyword { from, to } => { - let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); if dialog_field(ui, "field:keywordName", to, "New name") { confirm = true; } @@ -589,11 +597,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let options: Vec<String> = app - .session - .catalog - .keyword_suggestions(from, into, 12) - .into_iter() + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() .collect(); ui.horizontal_wrapped(|ui| { for k in options { diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..17ddc6508 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -227,3 +227,24 @@ fn containing_keywords_view_lists_parents_too() { } assert!(!has(&h, "keywordChip:beach"), "the chips give way"); } + +/// Rename Keyword's "used by n photos" and Create Smart Album's live count scan the library once +/// per change to it, not every frame the dialog is open. +#[test] +fn open_dialogs_count_once_per_library_change() { + let (mut h, ids) = two_selected(); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "beach".into(), to: "beach".into() }); + let before = h.app.caches.dialog_count_scans; + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 1, "unchanged frames reuse the count"); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["sea"]}})); + h.step(); + assert_eq!(h.app.caches.dialog_count_scans, before + 2, "a library change counts again"); + h.app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: "Beach".into(), parent: None }); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once"); +} From d79dad9ab158b98f4a26925f2ae81d45288dd095 Mon Sep 17 00:00:00 2001 From: Chad Elliott <chad.d.elliott@gmail.com> Date: Sun, 11 Oct 2026 16:48:16 -0500 Subject: [PATCH 036/315] docs(gaps): #1136 gap 26 notes indexed stack and album lookups at 100k Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/gaps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..325c7939e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified; gap 26: stack and album lookups indexed at 100k photos) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -40,7 +40,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 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) | +| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 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 | From 436c56a7ad8a22c60344079cb3463a01b878258b Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:06:24 +0200 Subject: [PATCH 037/315] docs: add user docs for LightCraft User docs in docs/user/ (published at getartcraft.com/docs/lightcraft), the docs-check and docs-publish workflows, and a "User docs" rule in AGENTS.md. Modelled on the ArtCraft Launcher setup. Sources per page: - index.md: README.md, crafting-apps.ts (lightcraft entry), Cargo.toml (0.6.0) - getting-started/install.md: README Downloads section, `gh release view` v0.6.0 asset list, craft-launcher catalog.rs and install docs - guides/import-photos.md: crates/ui-egui/src/import.rs, panels/dialogs.rs, menubar.rs, menus.rs - guides/cull-photos.md: docs/library-shortcuts.md, menubar.rs, panels/compare.rs, README - guides/edit-a-photo.md: panels/edit.rs, develop/src/controls.rs, menus.rs, README - guides/export-photos.md: panels/dialogs.rs (Export dialog), menus.rs - guides/import-lightroom-catalog.md: docs/lightroom-catalog-import.md, menus.rs, issue #832 - troubleshooting/preview-only-raw.md: issue #662, widgets.rs, engine cmd/convert.rs, README - troubleshooting/raw-photos-look-dull.md: issues #860, #778, #697 - troubleshooting/get-help.md: README (logs), menus.rs Help menu, issue #860 - faq/free-and-private.md: README (local-first, license), ROADMAP link - faq/supported-files.md: README, menus.rs, export dialog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/docs-check.yml | 32 ++++++++++ .github/workflows/docs-publish.yml | 22 +++++++ AGENTS.md | 5 ++ docs/user/AGENTS.md | 62 +++++++++++++++++++ docs/user/CLAUDE.md | 1 + docs/user/faq/free-and-private.md | 20 ++++++ docs/user/faq/index.md | 8 +++ docs/user/faq/supported-files.md | 22 +++++++ docs/user/getting-started/index.md | 7 +++ docs/user/getting-started/install.md | 55 ++++++++++++++++ docs/user/guides/cull-photos.md | 40 ++++++++++++ docs/user/guides/edit-a-photo.md | 39 ++++++++++++ docs/user/guides/export-photos.md | 33 ++++++++++ docs/user/guides/import-lightroom-catalog.md | 40 ++++++++++++ docs/user/guides/import-photos.md | 33 ++++++++++ docs/user/guides/index.md | 11 ++++ docs/user/index.md | 26 ++++++++ docs/user/troubleshooting/get-help.md | 26 ++++++++ docs/user/troubleshooting/index.md | 9 +++ docs/user/troubleshooting/preview-only-raw.md | 31 ++++++++++ .../troubleshooting/raw-photos-look-dull.md | 38 ++++++++++++ 21 files changed, 560 insertions(+) create mode 100644 .github/workflows/docs-check.yml create mode 100644 .github/workflows/docs-publish.yml create mode 100644 docs/user/AGENTS.md create mode 120000 docs/user/CLAUDE.md create mode 100644 docs/user/faq/free-and-private.md create mode 100644 docs/user/faq/index.md create mode 100644 docs/user/faq/supported-files.md create mode 100644 docs/user/getting-started/index.md create mode 100644 docs/user/getting-started/install.md create mode 100644 docs/user/guides/cull-photos.md create mode 100644 docs/user/guides/edit-a-photo.md create mode 100644 docs/user/guides/export-photos.md create mode 100644 docs/user/guides/import-lightroom-catalog.md create mode 100644 docs/user/guides/import-photos.md create mode 100644 docs/user/guides/index.md create mode 100644 docs/user/index.md create mode 100644 docs/user/troubleshooting/get-help.md create mode 100644 docs/user/troubleshooting/index.md create mode 100644 docs/user/troubleshooting/preview-only-raw.md create mode 100644 docs/user/troubleshooting/raw-photos-look-dull.md diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 000000000..b89a00a88 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,32 @@ +name: docs-check + +on: + pull_request: + paths: ["docs/user/**"] + push: + branches: [main] + paths: ["docs/user/**"] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Download the shared docs checker + id: fetch + run: | + url=https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs + if curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs"; then + echo "ok=1" >> "$GITHUB_OUTPUT" + else + echo "::warning::The docs checker is not published yet; skipping." + fi + - name: Check docs/user + if: steps.fetch.outputs.ok == '1' + run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 000000000..df4a412cc --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,22 @@ +name: docs-publish + +on: + push: + branches: [main] + paths: ["docs/user/**"] + +permissions: {} + +jobs: + rebuild: + runs-on: ubuntu-latest + steps: + - name: Rebuild getartcraft.com + env: + HOOK: ${{ secrets.DOCS_BUILD_HOOK }} + run: | + if [ -z "$HOOK" ]; then + echo "DOCS_BUILD_HOOK is not set; the nightly website build will pick this up." + exit 0 + fi + curl -fsS -X POST -d '{}' "$HOOK?trigger_title=docs%20update%20from%20$GITHUB_REPOSITORY" diff --git a/AGENTS.md b/AGENTS.md index 8cd199ff6..f2082f7a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,11 @@ of it. Full standard: `../craftrules/standards/never-crash.md` Never weaken this check to make a commit pass — fix the attribution or remove the asset. - When in doubt about an asset's licence: don't use it. +## User docs +User docs live in `docs/user/` and are published at https://getartcraft.com/docs/lightcraft. If a change alters +something a user sees or does (menu or panel labels, import, export, install, errors), update the matching page in +`docs/user/` in the same commit, or add one. Rules: `docs/user/AGENTS.md`; the checker runs in `docs-check.yml`. + ## Running and looking at the app - `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`). - **Never send OS-level synthetic input** (osascript/System Events keystrokes or clicks, `cliclick`, accessibility diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 000000000..b9f904814 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,62 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/lightcraft. The file path is the URL: +`troubleshooting/raw-photos-look-dull.md` → `/docs/lightcraft/troubleshooting/raw-photos-look-dull`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists and photographers using LightCraft, Discord mods answering them, and AI assistants looking +things up for either. Write for a photographer, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install LightCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code or a release. +- Call the product "LightCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: Raw photos look dull + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.6.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/lightcraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the workspace `version` in `Cargo.toml` whenever you check a +page against the current code. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + curl -fsSL https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs -o /tmp/docs-core.mjs + node /tmp/docs-core.mjs docs/user + +CI runs the same check on every PR that touches `docs/user/`. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): <page title>`. +Review them like code: check every claim against the source. diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/docs/user/faq/free-and-private.md b/docs/user/faq/free-and-private.md new file mode 100644 index 000000000..28f7104d2 --- /dev/null +++ b/docs/user/faq/free-and-private.md @@ -0,0 +1,20 @@ +--- +title: Is LightCraft free, and does it need an account? +description: LightCraft is free and open source, needs no account or subscription, and keeps your library on your computer. +order: 10 +verified: 0.6.0 +--- + +Yes, it is free. LightCraft is open source under the MIT or Apache-2.0 licence, your choice. It needs no account, no cloud service and no subscription, and it sends no telemetry. + +## Where are my photos and edits kept? + +On your computer. Your library lives in a folder you choose, by default `~/Pictures/LightCraft Library`. Edits are non-destructive, so your original files are never touched. The catalog is a log of human-readable operations that you can back up. Use **File** > **Back Up Library…** to make a backup. + +## Which systems does it run on? + +LightCraft runs on Windows, macOS and Linux, and in a web browser. See [Install LightCraft](../getting-started/install.md). + +## Is it finished? + +No. LightCraft is in alpha. Some things from Adobe Lightroom are not there yet, such as on-screen HDR display, video and the Print, Book and Map modules. The project's [roadmap](https://github.com/storytold/lightcraft/blob/main/ROADMAP.md) lists what is missing. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 000000000..1ddafea53 --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,8 @@ +--- +title: FAQ +description: Short answers about LightCraft, its price, platforms and file support. +order: 40 +--- + +- [Is LightCraft free, and does it need an account?](free-and-private.md) +- [Which cameras and file types does LightCraft open?](supported-files.md) diff --git a/docs/user/faq/supported-files.md b/docs/user/faq/supported-files.md new file mode 100644 index 000000000..65da107ff --- /dev/null +++ b/docs/user/faq/supported-files.md @@ -0,0 +1,22 @@ +--- +title: Which cameras and file types does LightCraft open? +description: The raw and image formats LightCraft reads, what happens with raws it cannot decode yet, and how to open Lightroom catalogs. +order: 20 +verified: 0.6.0 +--- + +LightCraft reads raw files from DNG, Canon CR2 and CR3, Sony ARW, Nikon NEF, Fujifilm RAF (including X-Trans), Panasonic RW2, Leica RWL, Pentax PEF and Olympus ORF cameras. It also opens JPEG, PNG, TIFF, WebP, PSD composites and JPEG XL. + +## What if my camera is not decoded yet? + +A raw that LightCraft cannot decode opens from the camera's embedded JPEG instead, marked **Preview only**. Compressed Olympus raws and some Canon CR3 variants work this way. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). + +How closely the starting look matches your camera's own JPEG varies by model. See [Raw photos look dull or washed out](../troubleshooting/raw-photos-look-dull.md). The project's [raw support table](https://github.com/storytold/lightcraft/blob/main/docs/raw-parity.md) lists what is known per camera. + +## Can I open my Lightroom photos? + +Yes. LightCraft imports Lightroom Classic catalogs. See [Move over from Lightroom](../guides/import-lightroom-catalog.md). + +## What can I export? + +JPEG, PNG, TIFF, PSD, WebP, AVIF, JPEG XL and DNG, or the original files with XMP sidecars. See [Export photos](../guides/export-photos.md). diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 000000000..7b7cef220 --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,7 @@ +--- +title: Getting started +description: Install LightCraft on your computer and open your first library. +order: 10 +--- + +- [Install LightCraft](install.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 000000000..83b4579d2 --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,55 @@ +--- +title: Install LightCraft +description: Download LightCraft for Windows 11, macOS or Linux, install it, and open your photo library. +order: 10 +verified: 0.6.0 +--- + +Download the file for your system from the [latest release](https://github.com/storytold/lightcraft/releases/latest) and install it. In the file names, `<version>` stands for the version number, for example `0.6.0`. The [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft) also links the download. + +## Windows 11 + +1. Open the [latest release](https://github.com/storytold/lightcraft/releases/latest). +2. Download `lightcraft-<version>-windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). +3. Run the file. The installer asks where to install. The default is `C:\Program Files\LightCraft`, and upgrades stay in the folder you chose. +4. At the end, choose the option to start LightCraft. + +For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. The release also has an `x86` installer for 32-bit Windows. + +To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. + +## macOS + +1. Download `lightcraft-<version>-macos-universal.dmg`. It works on Apple silicon and Intel Macs, and is signed and notarized. +2. Open the file and install LightCraft. + +## Linux + +The release has these files for `x86_64` and `aarch64` (Arm). Replace `x86_64` with `aarch64` on Arm. + +| File | Notes | +|----------------------------------------------|----------------------------------------------------------| +| `lightcraft-<version>-linux-x86_64.AppImage` | Runs anywhere. | +| `lightcraft-<version>-linux-x86_64.flatpak` | Sandboxed. Install with `flatpak install --user <file>`. | +| `lightcraft-<version>-linux-x86_64.deb` | For Debian and Ubuntu. | +| `lightcraft-<version>-linux-x86_64.rpm` | For Fedora, RHEL and openSUSE. | +| `lightcraft-<version>-linux-x86_64.tar.gz` | Unpack anywhere. | + +RISC-V and FreeBSD builds are on the release page too. On a Wayland desktop, LightCraft runs through XWayland when it is available, because only X11 gives it file drag-and-drop. + +## Install with ArtCraft Launcher + +[ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher) lists LightCraft and can install and update it for you. On Windows it installs the release `.msi`. + +## In a web browser + +The release also has `lightcraft-web-<version>.zip`, a static site that runs LightCraft in a modern browser. Host it on any static web server. + +## First run + +1. Start LightCraft. It opens your library at `~/Pictures/LightCraft Library`. A new library starts with demo photos. +2. To bring in your own photos, see [Import photos](../guides/import-photos.md). + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). diff --git a/docs/user/guides/cull-photos.md b/docs/user/guides/cull-photos.md new file mode 100644 index 000000000..30d0b4759 --- /dev/null +++ b/docs/user/guides/cull-photos.md @@ -0,0 +1,40 @@ +--- +title: Rate, flag and label photos +description: Sort a shoot with star ratings, pick and reject flags and colour labels, from the keyboard or the Photo menu. +order: 20 +verified: 0.6.0 +--- + +Select photos in **Photo Grid** or **Square Grid**, then press a key: `1` to `5` for stars, `P` to pick, `X` to reject, `6` to `9` for a colour label. The same actions are in the **Photo** menu. + +## Steps + +1. Open the library in **View** > **Photo Grid** or **View** > **Square Grid**. +2. Click a photo to select it. Click more photos to build a selection, or use **Edit** > **Select All**. +3. Press a key: + + | Key | Action | + |--------------------|---------------------------------------------------------| + | `0` | Clear the star rating | + | `1` to `5` | Set the star rating | + | `[` and `]` | Lower or raise the rating by one, within 0 to 5 | + | `6`, `7`, `8`, `9` | Set a red, yellow, green or blue label | + | `P` | Flag as a pick | + | `X` | Flag as rejected | + | `U` | Clear the flag | + +4. To move on faster, hold `Shift` with a key. It applies the action and selects the next photo. Or turn on **Photo** > **Auto Advance** to move forward after every action. +5. For purple, or to clear a colour label, use **Photo** > **Set Color Label** or the photo's right-click menu. + +Rating, flag and label changes apply to every selected photo, and you can undo them with **Edit** > **Undo**. + +## Compare photos + +Choose **View** > **Compare** to see two photos side by side with synced zoom and pan, or **View** > **Survey** to see the selected photos tiled. The keys above then apply to the active photo only. + +## Good to know + +- **Rejected** only marks a photo for culling. It does not delete the original or remove it from the library. +- The sidebar has **Picks** for the photos you flagged. The search box understands fields, for example `rating:>3 flag:pick`. +- Choose **Help** > **Keyboard Shortcuts** to see every shortcut. +- Typing in a text field keeps the keys for typing. diff --git a/docs/user/guides/edit-a-photo.md b/docs/user/guides/edit-a-photo.md new file mode 100644 index 000000000..57c67138c --- /dev/null +++ b/docs/user/guides/edit-a-photo.md @@ -0,0 +1,39 @@ +--- +title: Edit a photo +description: Open a photo in the Edit panel, adjust Light, Color and Effects sliders, compare with the original, and undo or reset. +order: 30 +verified: 0.6.0 +--- + +Double-click a photo in the grid, then press `E` to open the **Edit** panel. Drag a slider to change the photo. Your original file is never changed, because edits are stored in the library. + +## Steps + +1. In **Photo Grid** or **Square Grid**, double-click the photo. It opens in the **Detail** view. +2. Press `E`, or choose **Window** > **Edit**, to open the **Edit** panel. +3. Click **Auto** for a first pass, or skip this step and start with the sliders. +4. Open the **Light** section and set **Exposure**, **Contrast**, **Highlights**, **Shadows**, **Whites** and **Blacks**. +5. Open the **Color** section to change **Vibrance** and **Saturation**. Open **Effects** for **Texture**, **Clarity** and **Dehaze**. The panel also has **Detail** and **Optics** sections. +6. Check your work against the original. Hold `\` to peek at the original. Press `Y` (**View** > **Compare Before and After**) to see before and after side by side. +7. To go back a step, choose **Edit** > **Undo**. To drop all edits on the photo, click **Reset** at the top of the **Edit** panel. + +## Black and white + +Click **B&W** at the top of the **Edit** panel to turn the photo monochrome. Click it again to return to colour. + +## Other edit tools + +Open them from the **Window** menu, or press the key shown next to each one: + +| Panel | Key | +|-------------------|-------------| +| **Crop & Rotate** | `C` | +| **Remove** | `H` | +| **Masking** | `M` | +| **Presets** | `Shift`+`P` | + +## Good to know + +- A raw photo that LightCraft cannot decode yet is edited from the camera's embedded JPEG. The **Edit** panel then shows **Preview only**. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). +- When you zoom in on a large raw, the view can look soft for a few seconds while LightCraft renders the full-resolution pixels. +- To save the result as a file, see [Export photos](export-photos.md). diff --git a/docs/user/guides/export-photos.md b/docs/user/guides/export-photos.md new file mode 100644 index 000000000..784deb7ba --- /dev/null +++ b/docs/user/guides/export-photos.md @@ -0,0 +1,33 @@ +--- +title: Export photos +description: Save edited photos as JPEG, PNG, TIFF and other files with the size, folder and file names you choose. +order: 40 +verified: 0.6.0 +--- + +Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **OK**. Your originals are not changed. + +## Steps + +1. Select one or more photos in the grid. +2. Choose **File** > **Export…**. The **Export** window shows how many photos it will export. +3. Set **Format**: **JPEG**, **PNG**, **TIFF**, **PSD**, **WebP**, **AVIF**, **JPEG XL**, **DNG** or **Original files**. **DNG** writes raw photos as DNG with the edits embedded. **Original files** copies the files unchanged, each with an XMP sidecar that holds its edits. +4. For **JPEG**, **AVIF** or lossy **JPEG XL**, set **Quality**. For **JPEG XL**, tick **Lossless** to keep every pixel exactly, at the cost of larger files. +5. Under the size options, tick **Full size**, or clear it and choose **Resize to** one of **Long Edge**, **Short Edge**, **Width**, **Height**, **Width × Height**, **Megapixels** or **Percentage**, then enter the value. +6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. +7. Optional: set **Sharpen** for **Screen**, **Matte** or **Glossy** output, set **Metadata** to **All**, **No camera**, **Copyright** or **None**, and tick **Remove location info** or **Watermark**. +8. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. +9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. +10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. +11. Click **OK**. + +If you did not choose a folder, the window stays open and shows an error, so your choices are not lost. + +## Reuse your settings + +- To keep a set of choices, type a name next to **Save preset** and click **Save**. Pick it again later with **Preset** at the top of the window. +- **File** > **Export with Previous** repeats your last export. It tells you to use **Export…** first if you have not exported yet. + +## Contact sheet + +To make a PDF of thumbnails, select photos and choose **File** > **Contact Sheet PDF…**. diff --git a/docs/user/guides/import-lightroom-catalog.md b/docs/user/guides/import-lightroom-catalog.md new file mode 100644 index 000000000..e06705d9b --- /dev/null +++ b/docs/user/guides/import-lightroom-catalog.md @@ -0,0 +1,40 @@ +--- +title: Move over from Lightroom +description: Import a Lightroom Classic catalog into LightCraft, and learn what comes across and what does not. +order: 50 +verified: 0.6.0 +--- + +Choose **File** > **Import Lightroom Catalog…** and pick your `.lrcat` file. LightCraft reads it directly. You do not need Lightroom installed. + +## Steps + +1. Open the library you want the photos in. To start a new one, use **File** > **New Library…**. +2. Choose **File** > **Import Lightroom Catalog…**. +3. Pick your Lightroom Classic catalog, the file that ends in `.lrcat`. You can also give **File** > **Open Library…** a Lightroom catalog folder. LightCraft then offers this import instead of making a library there. +4. Wait for the import to finish. It shows progress and runs in the background. A message reports how many photos were added. + +If you change your mind, one **Edit** > **Undo** reverses the changes to the catalog. + +## What comes across + +- Ratings, picks and rejects, and colour labels +- Keywords, including hierarchical ones, and XMP metadata +- Collections and collection sets, which become albums +- Virtual copies +- Develop settings that LightCraft can map + +Original files stay where they are, and LightCraft refers to them in place. Photos whose originals are missing stay in the library so you can relink them later. LightCraft never changes your Lightroom catalog or your originals. + +## What does not match + +- Develop settings are an approximation. Camera profiles, Adobe's AI models and some masking and retouch fields are not reproduced, and settings LightCraft cannot map are reported. +- Smart collections become regular albums that hold the photos they contain at the time of import. +- Lightroom's process version is not carried over. Imported photos use LightCraft's newest rendering. + +## Good to know + +- Before it changes anything, LightCraft tries to save your source settings, history and collections in a recovery archive in the library's `Interop` folder. This is best effort. If it fails, the photos are still imported with a warning. +- Importing the same catalog again does not duplicate virtual copies or collections. By default, edits you already made in LightCraft are kept. +- A big catalog can take a long time. In one report, 38,000 photos took about 25 minutes from a USB SSD, and **Cancel** waited for the current work to finish. +- The browser version cannot import a catalog. diff --git a/docs/user/guides/import-photos.md b/docs/user/guides/import-photos.md new file mode 100644 index 000000000..13e1b5eec --- /dev/null +++ b/docs/user/guides/import-photos.md @@ -0,0 +1,33 @@ +--- +title: Import photos +description: Bring photos from a folder or card into your LightCraft library, in place or as copies. +order: 10 +verified: 0.6.0 +--- + +Use **File** > **Import Photos…** to pick files, or **File** > **Import from Folder…** to scan a whole folder. Both open the **Import Photos** window, where you choose what to bring in and how. + +## Steps + +1. Open the **File** menu and choose **Import Photos…** or **Import from Folder…**, then pick the files or the folder. +2. Look at the thumbnails. Every photo that can be imported has a checkbox. Click a photo to check or uncheck it. Shift-click sets a range. **Check All** and **Uncheck All** change every photo at once. +3. Photos already in your library are marked **Duplicate** or **In library** and start unchecked. A file LightCraft cannot read is marked **Unreadable**. +4. Under **Transfer**, choose how the files get into the library: + - **Add in place** keeps the files where they are. The library only points to them. Nothing is copied or moved. + - **Copy** copies the files into the library's `Originals` folder, or into a folder you pick with **Choose…** next to **Copy to**. + - **Move** does the same, then removes each original from its source once the copy is verified and catalogued. +5. With **Copy** or **Move**, set **Folders** to sort the copies **By day (YYYY/YYYY-MM-DD)**, **By month (YYYY/YYYY-MM)**, **Into one folder**, or with a **Custom template…**. Use **Rename** to give the files new names, for example `{date}_{seq:3}`. Leave it empty to keep the names. With **Copy**, **Copy as DNG** converts raw files to DNG. +6. Optional: set **Album** to add the photos to an existing album or a new one, **Preset** to apply a preset to every photo, and **Keywords** (separated by commas). +7. Click the button at the bottom. It shows the number of checked photos, for example **Import 24 Photos**. With **Move** it reads **Move 24 Photos**. + +The import runs in the background, and you can keep working while it does. The whole import is one undo step. + +## Good to know + +- Importing a folder scans it for photos. It does not add the folder to **Local**. To work in a folder without importing, use **Local** > **Browse Folder…**. +- Folder names and `{date}` use the capture time. A photo without one uses today's date. +- Raw formats LightCraft reads include DNG, Canon CR2 and CR3, Sony ARW, Nikon NEF, Fujifilm RAF, Panasonic RW2, Leica RWL, Pentax PEF and Olympus ORF. Some variants open as **Preview only**. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). + +## Next + +[Rate, flag and label photos](cull-photos.md) to sort out the keepers. diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 000000000..b3c656946 --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,11 @@ +--- +title: Guides +description: How to do one thing in LightCraft. +order: 20 +--- + +- [Import photos](import-photos.md) +- [Rate, flag and label photos](cull-photos.md) +- [Edit a photo](edit-a-photo.md) +- [Export photos](export-photos.md) +- [Move over from Lightroom](import-lightroom-catalog.md) diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 000000000..33b325098 --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,26 @@ +--- +title: LightCraft +description: LightCraft is a free, open-source photo library and raw developer, an alternative to Adobe Lightroom. +verified: 0.6.0 +--- + +LightCraft is a photo library and raw developer that runs on your own computer. It is an open-source alternative to Adobe Lightroom, written in Rust, and part of the Craft App Suite from the ArtCraft team. + +You import photos, sort them with ratings, flags and albums, and edit them without touching the originals. It reads raw files from Canon, Sony, Nikon, Fujifilm, Panasonic, Leica, Pentax and Olympus cameras, and DNG. There is no account, no cloud, no telemetry and no subscription. It runs on Windows, macOS and Linux, and in a web browser. LightCraft is in alpha, so expect rough edges. + +## Start here + +- [Install LightCraft](getting-started/install.md) +- [Import photos](guides/import-photos.md) +- [Rate, flag and label photos](guides/cull-photos.md) +- [Edit a photo](guides/edit-a-photo.md) +- [Export photos](guides/export-photos.md) +- [Move over from Lightroom](guides/import-lightroom-catalog.md) +- [A raw photo opens as "Preview only"](troubleshooting/preview-only-raw.md) +- [Raw photos look dull or washed out](troubleshooting/raw-photos-look-dull.md) +- [Get help with a problem](troubleshooting/get-help.md) +- [Frequently asked questions](faq/index.md) + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). Say which system and camera you use, and your LightCraft version (**Help** > **About LightCraft**). diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md new file mode 100644 index 000000000..7764ee2cc --- /dev/null +++ b/docs/user/troubleshooting/get-help.md @@ -0,0 +1,26 @@ +--- +title: Get help with a problem +description: What to include when you ask for help with a LightCraft bug, and where to post it. +order: 30 +verified: 0.6.0 +--- + +Open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues), or ask in [Discord](https://discord.gg/artcraft). Include your version, your system, what you did, and what you expected. + +## What to include + +1. **Your version.** Choose **Help** > **About LightCraft**. +2. **Your system.** For example Windows 11, 64-bit. **Help** > **System Info…** shows details about your computer. +3. **The steps.** What you clicked, in order, and what happened. +4. **The camera and file type**, if the problem is with a photo. +5. **A sample file**, if a raw or other photo renders wrong. Maintainers ask for the original file, not an export. A link to a shared folder works. Uploading raws to [raw.pixls.us](https://raw.pixls.us) also helps, because every file there becomes a permanent test sample for that camera. +6. **A screenshot** of LightCraft next to the camera JPEG or the other app, for colour problems. +7. **The log file.** Choose **Help** > **Open Log Folder** and attach `lightcraft.log`. + +## Where the log is + +LightCraft writes its log to `logs/lightcraft.log` in its settings folder. On Windows that is `%APPDATA%\LightCraft\logs\`. Each start moves the previous log to `lightcraft.1.log`, and that one to `lightcraft.2.log`, so the log of a run that crashed survives the next start. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft). diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 000000000..b82ed2e86 --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,9 @@ +--- +title: Troubleshooting +description: Fixes for common LightCraft problems. +order: 30 +--- + +- [A raw photo opens as "Preview only"](preview-only-raw.md) +- [Raw photos look dull or washed out](raw-photos-look-dull.md) +- [Get help with a problem](get-help.md) diff --git a/docs/user/troubleshooting/preview-only-raw.md b/docs/user/troubleshooting/preview-only-raw.md new file mode 100644 index 000000000..af7720a37 --- /dev/null +++ b/docs/user/troubleshooting/preview-only-raw.md @@ -0,0 +1,31 @@ +--- +title: A raw photo opens as "Preview only" +description: Why LightCraft shows a raw photo as Preview only, and how to make it decode after an update with Reload from Disk. +order: 10 +verified: 0.6.0 +--- + +Select the photo and choose **Photo** > **Reload from Disk**. If your version can decode that raw now, LightCraft reads the sensor data again and the **Preview only** notice goes away. + +## Symptoms + +- The **Edit** panel shows **Preview only**, with the text "LightCraft can't decode this raw variant yet" and the name of the variant. +- You updated LightCraft to a version that supports your camera, and the photo still looks the same. + +## Fix + +1. In the grid, select the photos that show **Preview only**. +2. Choose **Photo** > **Reload from Disk**. The right-click menu on a photo has the same entry. +3. Open a photo again. If **Preview only** is gone, the photo is now decoded from the raw data. + +If **Preview only** stays, your version still cannot decode that raw variant. Update to the [latest release](https://github.com/storytold/lightcraft/releases/latest) and try again, or report the file as described in [Get help with a problem](get-help.md). + +## Why this happens + +When LightCraft cannot decode a raw on import, it remembers that and shows the camera's embedded JPEG. It does not check again after an update on its own. **Reload from Disk** is the way to make it check, and importing the photos again works too. Compressed Olympus raws and some Canon CR3 variants are examples of files that use the embedded preview. + +Photos shown from the preview keep the camera's picture style and white balance, because they are the camera's own JPEG. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). diff --git a/docs/user/troubleshooting/raw-photos-look-dull.md b/docs/user/troubleshooting/raw-photos-look-dull.md new file mode 100644 index 000000000..0b0883412 --- /dev/null +++ b/docs/user/troubleshooting/raw-photos-look-dull.md @@ -0,0 +1,38 @@ +--- +title: Raw photos look dull or washed out +description: What to do when a raw photo looks flat, grey or desaturated in LightCraft compared with the camera JPEG. +order: 20 +verified: 0.6.0 +--- + +Update to the latest LightCraft, then import the photo again. Colour handling for raw files changed a lot between 0.4.0, 0.5.0 and 0.6.0, and several flat-looking Sony and Fujifilm cases are fixed. Some Leica DNGs still look dull. + +## Symptoms + +- A raw photo looks flat, grey or desaturated as soon as you open it. +- The preview or thumbnail looks right, but the opened photo does not. +- Blacks look lifted, and the shadows look grey. + +## Fix + +1. Check your version in **Help** > **About LightCraft**. If it is older than 0.6.0, install the [latest release](https://github.com/storytold/lightcraft/releases/latest). See [Install LightCraft](../getting-started/install.md). +2. Import the raw photo again, or select it and choose **Photo** > **Reload from Disk**. +3. If it still looks dull, adjust it by hand in the **Edit** panel. See [Edit a photo](../guides/edit-a-photo.md). +4. If you think the starting look is wrong for your camera, report it with the original raw file. See [Get help with a problem](get-help.md). + +## Why this happens + +LightCraft develops a raw from the sensor data and tries to match the look of the camera's own JPEG. How well it matches depends on the camera model. + +- **Sony ARW:** on 0.5.0 and older, some Lossless Compressed M-size files had lifted blacks, and an A7 II file lost most of its colour. Both were fixed after 0.5.0. The A7 II colour fix is in 0.6.0. Some files still fall short on dark scenes and on in-camera Dynamic Range Optimizer shots. +- **Fujifilm X-Pro1 and Sony A7 IV:** reported dull on 0.4.0 and fine on 0.6.0. +- **Leica DNG:** these files carry a colour matrix but no tone curve, so 0.6.0 shows them darker and flatter than the camera JPEG. A fix was waiting for review at the time of writing. +- **Adobe RGB:** if the camera was set to Adobe RGB, LightCraft read the embedded JPEG as sRGB and matched to a too-dull target. The fix came after 0.6.0 and ships in a later release. + +## A soft picture after zooming in + +If a large raw looks soft right after you zoom in, wait a few seconds. LightCraft first shows the fit-size picture enlarged, then renders the zoomed area from the full-resolution raw and swaps it in. On a 61 MP file this can take a few seconds. If it stays soft at **Zoom 100%** (**View** > **Zoom 100%**), report it. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). From 675e661710620e449c4a6ea118b3544df2e76951 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:49:21 +0200 Subject: [PATCH 038/315] docs(user): apply final review fixes Items B-LightCraft 1-5 and consistency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/user/getting-started/install.md | 5 ++--- docs/user/index.md | 2 +- docs/user/troubleshooting/get-help.md | 2 +- docs/user/troubleshooting/raw-photos-look-dull.md | 8 ++++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 83b4579d2..f53bb7de0 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -14,9 +14,8 @@ Download the file for your system from the [latest release](https://github.com/s 3. Run the file. The installer asks where to install. The default is `C:\Program Files\LightCraft`, and upgrades stay in the folder you chose. 4. At the end, choose the option to start LightCraft. -For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. The release also has an `x86` installer for 32-bit Windows. - -To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. +For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. +To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. A portable build for `arm64` is on the release page too. ## macOS diff --git a/docs/user/index.md b/docs/user/index.md index 33b325098..08e631e72 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -23,4 +23,4 @@ You import photos, sort them with ratings, flags and albums, and edit them witho ## Get help -Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). Say which system and camera you use, and your LightCraft version (**Help** > **About LightCraft**). +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). Say which system and camera you use, and your LightCraft version (**Help** > **About LightCraft**; on macOS, **LightCraft** > **About LightCraft**). diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md index 7764ee2cc..0eea58be9 100644 --- a/docs/user/troubleshooting/get-help.md +++ b/docs/user/troubleshooting/get-help.md @@ -9,7 +9,7 @@ Open an issue in the [issue tracker](https://github.com/storytold/lightcraft/iss ## What to include -1. **Your version.** Choose **Help** > **About LightCraft**. +1. **Your version.** Choose **Help** > **About LightCraft** (on macOS, **LightCraft** > **About LightCraft**). 2. **Your system.** For example Windows 11, 64-bit. **Help** > **System Info…** shows details about your computer. 3. **The steps.** What you clicked, in order, and what happened. 4. **The camera and file type**, if the problem is with a photo. diff --git a/docs/user/troubleshooting/raw-photos-look-dull.md b/docs/user/troubleshooting/raw-photos-look-dull.md index 0b0883412..94c615394 100644 --- a/docs/user/troubleshooting/raw-photos-look-dull.md +++ b/docs/user/troubleshooting/raw-photos-look-dull.md @@ -15,7 +15,7 @@ Update to the latest LightCraft, then import the photo again. Colour handling fo ## Fix -1. Check your version in **Help** > **About LightCraft**. If it is older than 0.6.0, install the [latest release](https://github.com/storytold/lightcraft/releases/latest). See [Install LightCraft](../getting-started/install.md). +1. Check your version in **Help** > **About LightCraft** (on macOS, **LightCraft** > **About LightCraft**). If it is older than 0.6.0, install the [latest release](https://github.com/storytold/lightcraft/releases/latest). See [Install LightCraft](../getting-started/install.md). 2. Import the raw photo again, or select it and choose **Photo** > **Reload from Disk**. 3. If it still looks dull, adjust it by hand in the **Edit** panel. See [Edit a photo](../guides/edit-a-photo.md). 4. If you think the starting look is wrong for your camera, report it with the original raw file. See [Get help with a problem](get-help.md). @@ -24,10 +24,10 @@ Update to the latest LightCraft, then import the photo again. Colour handling fo LightCraft develops a raw from the sensor data and tries to match the look of the camera's own JPEG. How well it matches depends on the camera model. -- **Sony ARW:** on 0.5.0 and older, some Lossless Compressed M-size files had lifted blacks, and an A7 II file lost most of its colour. Both were fixed after 0.5.0. The A7 II colour fix is in 0.6.0. Some files still fall short on dark scenes and on in-camera Dynamic Range Optimizer shots. +- **Sony ARW:** on 0.5.0 and older, some Lossless Compressed M-size files had lifted blacks, and an A7 II file lost most of its colour. Both are fixed in 0.6.0. On the A7 II file, brighter midtones can still look grey-green in 0.6.0 ([issue 865](https://github.com/storytold/lightcraft/issues/865)). Some files still fall short on dark scenes and on in-camera Dynamic Range Optimizer shots. - **Fujifilm X-Pro1 and Sony A7 IV:** reported dull on 0.4.0 and fine on 0.6.0. -- **Leica DNG:** these files carry a colour matrix but no tone curve, so 0.6.0 shows them darker and flatter than the camera JPEG. A fix was waiting for review at the time of writing. -- **Adobe RGB:** if the camera was set to Adobe RGB, LightCraft read the embedded JPEG as sRGB and matched to a too-dull target. The fix came after 0.6.0 and ships in a later release. +- **Leica DNG:** these files carry a colour matrix but no tone curve, so 0.6.0 shows them darker and flatter than the camera JPEG. This is not fixed in 0.6.0 ([pull request 807](https://github.com/storytold/lightcraft/pull/807)). +- **Adobe RGB:** if the camera was set to Adobe RGB, LightCraft read the embedded JPEG as sRGB and matched to a too-dull target. The fix came after 0.6.0 and is not in a release yet. ## A soft picture after zooming in From d3690e76c4fde13dc13ff046f7f114c566b659b3 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:06:01 +0200 Subject: [PATCH 039/315] docs(user): apply final review fixes Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 6: blank line in install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 2 ++ docs/user/AGENTS.md | 3 +-- docs/user/getting-started/install.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2082f7a5..e2662e69e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,8 @@ of it. Full standard: `../craftrules/standards/never-crash.md` User docs live in `docs/user/` and are published at https://getartcraft.com/docs/lightcraft. If a change alters something a user sees or does (menu or panel labels, import, export, install, errors), update the matching page in `docs/user/` in the same commit, or add one. Rules: `docs/user/AGENTS.md`; the checker runs in `docs-check.yml`. +Pages describe the latest release. For a change that is not released yet, write that it was added after <version> +and is not in a release yet, or update the page when the release ships. ## Running and looking at the app - `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`). diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index b9f904814..3ac4f0e20 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -35,8 +35,7 @@ things up for either. Write for a photographer, not a developer. Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. -Set `verified` to the workspace `version` in `Cargo.toml` whenever you check a -page against the current code. +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show <tag>:<path>`. Pages describe the latest release users download, not unreleased code on main. ## Folders diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index f53bb7de0..689d2c876 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -15,6 +15,7 @@ Download the file for your system from the [latest release](https://github.com/s 4. At the end, choose the option to start LightCraft. For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. + To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. A portable build for `arm64` is on the release page too. ## macOS From 7fe040f66aa1ffbaf87072bd28c7e6a5aef4df60 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:13:41 +0200 Subject: [PATCH 040/315] docs: pin the docs checker to a commit and its SHA-256 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/docs-check.yml | 19 ++++++++++--------- docs/user/AGENTS.md | 5 +++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index b89a00a88..079b9d414 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -10,6 +10,12 @@ on: permissions: contents: read +env: + # Shared docs checker from storytold/artcraft-services, pinned to one commit + # and its SHA-256. To update it, change both values in a reviewed PR. + CHECKER_COMMIT: 4db0e30fe2c57a1c4c49e390e3711f90910b8d23 + CHECKER_SHA256: bd6b610a708b85fd0aa0491c459bd2800b716e0b8050516fea79e084cfb2a624 + jobs: check: runs-on: ubuntu-latest @@ -18,15 +24,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Download the shared docs checker - id: fetch + - name: Download the pinned docs checker run: | - url=https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs - if curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs"; then - echo "ok=1" >> "$GITHUB_OUTPUT" - else - echo "::warning::The docs checker is not published yet; skipping." - fi + url="https://raw.githubusercontent.com/storytold/artcraft-services/$CHECKER_COMMIT/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" + curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs" + echo "$CHECKER_SHA256 $RUNNER_TEMP/docs-core.mjs" | sha256sum -c - - name: Check docs/user - if: steps.fetch.outputs.ok == '1' run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index 3ac4f0e20..41bbdb986 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -50,10 +50,11 @@ drafts and are not published. ## Check before you push - curl -fsSL https://raw.githubusercontent.com/storytold/artcraft-services/main/frontend/apps/artcraft-website-next/scripts/docs/core.mjs -o /tmp/docs-core.mjs + commit=$(awk '/CHECKER_COMMIT:/ {print $2}' .github/workflows/docs-check.yml) + curl -fsSL "https://raw.githubusercontent.com/storytold/artcraft-services/$commit/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" -o /tmp/docs-core.mjs node /tmp/docs-core.mjs docs/user -CI runs the same check on every PR that touches `docs/user/`. +This uses the same pinned checker as CI (`.github/workflows/docs-check.yml`). ## Support PRs From 29ed7037941511ed2d6fe2714a19ef46f5cb5f72 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:09:17 +0200 Subject: [PATCH 041/315] docs: drop the docs workflows until the checker is on artcraft-services main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/docs-check.yml | 33 ------------------------------ .github/workflows/docs-publish.yml | 22 -------------------- AGENTS.md | 2 +- docs/user/AGENTS.md | 6 ++---- 4 files changed, 3 insertions(+), 60 deletions(-) delete mode 100644 .github/workflows/docs-check.yml delete mode 100644 .github/workflows/docs-publish.yml diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml deleted file mode 100644 index 079b9d414..000000000 --- a/.github/workflows/docs-check.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: docs-check - -on: - pull_request: - paths: ["docs/user/**"] - push: - branches: [main] - paths: ["docs/user/**"] - -permissions: - contents: read - -env: - # Shared docs checker from storytold/artcraft-services, pinned to one commit - # and its SHA-256. To update it, change both values in a reviewed PR. - CHECKER_COMMIT: 4db0e30fe2c57a1c4c49e390e3711f90910b8d23 - CHECKER_SHA256: bd6b610a708b85fd0aa0491c459bd2800b716e0b8050516fea79e084cfb2a624 - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Download the pinned docs checker - run: | - url="https://raw.githubusercontent.com/storytold/artcraft-services/$CHECKER_COMMIT/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" - curl -fsSL "$url" -o "$RUNNER_TEMP/docs-core.mjs" - echo "$CHECKER_SHA256 $RUNNER_TEMP/docs-core.mjs" | sha256sum -c - - - name: Check docs/user - run: node "$RUNNER_TEMP/docs-core.mjs" docs/user diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml deleted file mode 100644 index df4a412cc..000000000 --- a/.github/workflows/docs-publish.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: docs-publish - -on: - push: - branches: [main] - paths: ["docs/user/**"] - -permissions: {} - -jobs: - rebuild: - runs-on: ubuntu-latest - steps: - - name: Rebuild getartcraft.com - env: - HOOK: ${{ secrets.DOCS_BUILD_HOOK }} - run: | - if [ -z "$HOOK" ]; then - echo "DOCS_BUILD_HOOK is not set; the nightly website build will pick this up." - exit 0 - fi - curl -fsS -X POST -d '{}' "$HOOK?trigger_title=docs%20update%20from%20$GITHUB_REPOSITORY" diff --git a/AGENTS.md b/AGENTS.md index e2662e69e..8f917dd09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` ## User docs User docs live in `docs/user/` and are published at https://getartcraft.com/docs/lightcraft. If a change alters something a user sees or does (menu or panel labels, import, export, install, errors), update the matching page in -`docs/user/` in the same commit, or add one. Rules: `docs/user/AGENTS.md`; the checker runs in `docs-check.yml`. +`docs/user/` in the same commit, or add one. Rules: `docs/user/AGENTS.md`. Pages describe the latest release. For a change that is not released yet, write that it was added after <version> and is not in a release yet, or update the page when the release ships. diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md index 41bbdb986..abb8cf8f7 100644 --- a/docs/user/AGENTS.md +++ b/docs/user/AGENTS.md @@ -50,11 +50,9 @@ drafts and are not published. ## Check before you push - commit=$(awk '/CHECKER_COMMIT:/ {print $2}' .github/workflows/docs-check.yml) - curl -fsSL "https://raw.githubusercontent.com/storytold/artcraft-services/$commit/frontend/apps/artcraft-website-next/scripts/docs/core.mjs" -o /tmp/docs-core.mjs - node /tmp/docs-core.mjs docs/user + node <path to your artcraft-services checkout>/frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user -This uses the same pinned checker as CI (`.github/workflows/docs-check.yml`). +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. ## Support PRs From 358a19c755e7e3cbc5c7b0fbd4ca6d5869704c46 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:45 +0200 Subject: [PATCH 042/315] i18n: add Czech (cs) interface language Add the Czech catalog (2,056 UI messages, 246 format templates), the language-menu entry, locale detection (cs, cs-CZ, cs_CZ.UTF-8) and regression tests for switching, persistence, dates, placeholders and glyphs. Translate a few face/model, person and HDR/PDF captions through the existing tr() interface. Settings > Language now wraps its buttons, since twelve language names no longer fit a narrow window. What's New prose stays English, like most catalogs. AI-assisted, with an independent review pass; not yet proofread by a native speaker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 2 +- assets/ATTRIBUTION.md | 1 + crates/ui-egui/locales/cs-formats.json | 248 +++ crates/ui-egui/locales/cs.json | 2058 +++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 73 + crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 12 +- crates/ui-egui/src/panels/faces.rs | 32 +- crates/ui-egui/src/panels/keyword_list.rs | 4 +- crates/ui-egui/src/panels/person.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 5 +- crates/ui-egui/src/panels/unnamed.rs | 6 +- docs/localization-cs.md | 57 + docs/localization.md | 6 +- 15 files changed, 2478 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/locales/cs-formats.json create mode 100644 crates/ui-egui/locales/cs.json create mode 100644 docs/localization-cs.md diff --git a/README.md b/README.md index 8884f2bd3..f38de73f0 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..1b605f6b2 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -26,6 +26,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | | craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | +| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json new file mode 100644 index 000000000..cf08017da --- /dev/null +++ b/crates/ui-egui/locales/cs-formats.json @@ -0,0 +1,248 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} s několika podobnými soubory: použijte Najít umístění)", + " ({} in Recently Deleted)": " ({} v nedávno odstraněných)", + " · {:.0}% clipped": " · {:.0}% oříznutých tónů", + " · {failed} couldn't be rendered": " · {failed} se nepodařilo vykreslit", + " · {queued} waiting": " · {queued} čeká", + " · {skipped} skipped (file exists)": " · {skipped} přeskočeno (soubor existuje)", + " · {} duplicate{} skipped": " · přeskočené duplikáty: {}{:.0}", + " · {} failed: {}": " · {} neúspěšných: {}", + " · {} moved": " · {} přesunuto", + " · {} not readable": " · {} nečitelných", + " · {} original{} left at the source": " · originály ponechané ve zdroji: {}{:.0}", + " · {} restored from Recently Deleted": " · {} obnoveno z nedávno odstraněných", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One neimportuje seznam se znaky ; , < nebo > v klíčovém slově: {names}", + " — left out, as a keyword list can't hold them: {names}": " — vynecháno, protože seznam klíčových slov tyto údaje nemůže obsahovat: {names}", + " — not carried over: {}{}": " — nepřevzato: {}{}", + "+{n} more": "+{n} dalších", + "+{} more": "+{} dalších", + ", {failed} file{} not readable": ", nečitelné soubory: {failed}{:.0}", + ", {} label": ", štítek: {}", + ", {} star{}": ", hvězdičky: {}{:.0}", + "1 task is still running: {names}. Quitting stops it.": "Stále běží 1 úloha: {names}. Ukončení aplikace ji zastaví.", + "; {kept} already had a location": "; {kept} již mělo polohu", + "; {n} gained synonyms": "; {n} získalo synonyma", + "; {outside} outside its time range": "; {outside} mimo časový rozsah", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commitů, +{} / −{} řádků (Δ {}), +{} / −{} binárních souborů, {} – {}", + "Add {local_n} to My Photos": "Přidat {local_n} do mých fotografií", + "Add {} Photo{}": "Přidat fotografie: {}{:.0}", + "Add {} Photo{}, restore {}": "Přidat fotografie: {}{:.0}, obnovit: {}", + "Add “{}” to Favorites": "Přidat „{}“ do oblíbených", + "Added {n} keyword{}": "Přidaná klíčová slova: {n}{:.0}", + "Added {n} photo{} to My Photos": "Přidáno do mých fotografií: {n}{:.0}", + "Added {n} photo{} to “{}”": "Do „{1}“ přidáno fotografií: {n}{0:.0}", + "Added {what} to {name}": "Přidáno {what} do {name}", + "Added “{keyword}” to {n} photo{}": "K fotografiím přidáno „{keyword}“: {n}{:.0}", + "Adding photos… {} of {}": "Přidávání fotografií… {} z {}", + "All {n} selected photos move by the same amount.": "Všech {n} vybraných fotografií se posune o stejnou hodnotu.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Již v knihovně, v nedávno odstraněných: vybrané fotografie: {}{:.0} · pravé tlačítko → Obnovit, nebo Trvale odstranit pro nový import", + "Already in the library: {} photo{} selected in All Photos": "Již v knihovně: ve všech fotografiích vybráno: {}{:.0}", + "Auto ({})": "Automaticky ({})", + "Auto Import: added {} photo{}": "Automatický import: přidáno fotografií: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Automatická synchronizace: úpravy se použijí na fotografie: {n}", + "Automatic ({} MB)": "Automatické ({} MB)", + "Build 1:1 Previews ({scope})": "Vytvořit náhledy 1:1 ({scope})", + "Build Smart Previews ({scope})": "Vytvořit inteligentní náhledy ({scope})", + "Build Standard-Sized Previews ({scope})": "Vytvořit standardní náhledy ({scope})", + "Building previews for {} photos…": "Vytváření náhledů pro fotografie: {}…", + "Click {}:1": "Kliknutí {}:1", + "Couldn't open the editor: {e}": "Editor se nepodařilo otevřít: {e}", + "Create Keyword Tag Inside “{name}”…": "Vytvořit značku klíčového slova uvnitř „{name}“…", + "Create {n} Virtual Copies": "Vytvořit virtuální kopie: {n}", + "Creates {} stacks from {} of {scope}": "Vytvoří {} sad z {} v {scope}", + "Decoder: {reason}": "Dekodér: {reason}", + "Delete \"{label}\"": "Odstranit „{label}“", + "Delete \"{label}\" (and the mask)": "Odstranit „{label}“ (i masku)", + "Delete the keyword “{name}”?": "Odstranit klíčové slovo „{name}“?", + "Delete {n} Photos": "Odstranit fotografie: {n}", + "Delete “{current}”": "Odstranit „{current}“", + "Denoising {name}: {done} of {total} tiles{}": "Redukce šumu {name}: {done} z {total} dlaždic{}", + "Discard Smart Previews ({scope})": "Odstranit inteligentní náhledy ({scope})", + "Done ({what}): {done} photo{plural}": "Hotovo ({what}): fotografií: {done}{plural:.0}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Stahuje se z {host}. Po stažení a ověření jej LightCraft nainstaluje a {after}.", + "Duplicate \"{label}\"": "Duplikovat „{label}“", + "Example: {example}": "Příklad: {example}", + "Exported {n} keyword{}": "Exportovaná klíčová slova: {n}{:.0}", + "Exported {n} photo{}": "Exportováno fotografií: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Exportováno fotografií: {ok} z {total}{:.0}", + "Exported {} point curve preset{}": "Exportovaná přednastavení bodové křivky: {}{:.0}", + "Exported {} preset{}": "Exportovaná přednastavení: {}{:.0}", + "Exporting {} of {total}": "Exportování {} z {total}", + "Filter bar — {active} active filter{}": "Lišta filtrů — aktivní filtry: {active}{:.0}", + "Filtered: {label}": "Filtrováno: {label}", + "Focus {:.0}": "Zaostření {:.0}", + "Folder “{}” (and its subfolders)": "Složka „{}“ (a její podsložky)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Nalezené chybějící fotografie: {n}{:.0}; stále chybí: {left}{unsure}", + "Healed {n} dust spot{}": "Retušované prachové skvrny: {n}{:.0}", + "Import cancelled · {} photo{} added": "Import byl zrušen · přidáno fotografií: {}{:.0}", + "Import failed: {e}": "Import selhal: {e}", + "Import warnings ({n}):\n{warnings}": "Varování importu ({n}):\n{warnings}", + "Imported {n} point curve preset{}": "Importovaná přednastavení bodové křivky: {n}{:.0}", + "Imported {n} preset{}": "Importovaná přednastavení: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Importovaná přednastavení: {n}{0:.0}, nečitelné soubory: {f}{1:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importované profily: {profiles}{:.0} (Prohlížeč profilů → jejich skupiny)", + "Imported {} photo{}": "Importováno fotografií: {}{:.0}", + "In line for AI Denoise ({ahead} ahead)…": "Ve frontě na redukci šumu pomocí AI (před vámi: {ahead})…", + "Insert at the cursor (also written {})": "Vložit na pozici kurzoru (také se zapisuje {})", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Odebere se z fotografií: {count}{:.0}, včetně podřízených klíčových slov. Příkaz Zpět je obnoví.", + "Label Set: {name}": "Sada štítků: {name}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft tuto variantu RAW zatím neumí dekódovat ({}). Upravujete vložený náhled JPEG fotoaparátu, který již obsahuje obrazový styl (např. monochromatický) a vyvážení bílé.", + "LightCraft could not open its window: {e}": "LightCraft nedokázal otevřít okno: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nedokázal otevřít okno: {e}\n\nPodrobnosti najdete v souboru protokolu: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Katalog Lightroom byl prozkoumán: fotografií: {photos}, kolekcí: {collections}, chybějících: {missing}", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Geometrie Lightroom pro {path} se nezachovala; uložená transformace narovnání perspektivy s vodítky není podporována.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Geometrie Lightroom pro {path} se nezachovala; uložená transformace narovnání perspektivy není podporována.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Geometrie Lightroom pro {path} se nezachovala; uložená transformace se ignoruje, protože narovnání perspektivy je vypnuté.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometrie Lightroom pro {path} se nezachovala; {mode} se přepočítá.", + "Lightroom import complete: {imported} photos added": "Import z Lightroom byl dokončen: přidaných fotografií: {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom byl dokončen: přidaných fotografií: {imported}, neúspěšných: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom byl dokončen: přidaných fotografií: {imported}; varování indexu: {warning}", + "Lightroom import failed: {e}{warning}": "Import z Lightroom selhal: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Prohlížení katalogu Lightroom selhalo: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT NEBO Apache-2.0. Písma: {} (OFL). Ikony: původní tvorba.", + "Making the denoised picture… {}%": "Vytváření obrazu s redukovaným šumem… {}%", + "Matches: {}": "Shody: {}", + "Merging… {stage} {:.0}%": "Slučování… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Přesunout {what} do nedávno odstraněných?", + "Moved {} photo(s)": "Přesunuto fotografií: {}", + "Moved; {} photo(s) relinked": "Přesunuto; znovu propojeno fotografií: {}", + "Named ({named_n})": "Pojmenované ({named_n})", + "New Folder in “{name}”": "Nová složka v „{name}“", + "On {have} of {of} selected photos": "Na {have} z {of} vybraných fotografií", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Nanášení „{k}“: kliknutím na fotografie přidáte nebo odeberete slovo · Esc ukončí režim", + "Paste into {n} photo{} — only settings that were copied are pasted": "Vložit do fotografií: {n}{:.0} — vloží se pouze zkopírovaná nastavení", + "Preset: {name}": "Přednastavení: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Pouze náhled — úpravy vloženého JPEG z fotoaparátu ({})", + "Preview only — {}": "Pouze náhled — {}", + "Previews ready for {done} photo{plural}": "Připravené náhledy pro fotografie: {done}{plural:.0}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Kopie pro měkký nátisk ({})", + "Proof Preview · {}": "Měkký nátisk · {}", + "Put inside “{parent}”": "Vložit pod „{parent}“", + "Quick Develop · {n} photos": "Rychlé vyvolání · fotografií: {n}", + "Quick Develop: {name}": "Rychlé vyvolání: {name}", + "Rated {}": "Hodnocení: {}", + "Reading Lightroom catalog… {done} of {total}": "Načítání katalogu Lightroom… {done} z {total}", + "Reading photos… {done} of {total}": "Načítání fotografií… {done} z {total}", + "Reading photos… {} of {}": "Načítání fotografií… {} z {}", + "Redo {}": "Znovu {}", + "Remove “{name}”": "Odebrat „{name}“", + "Remove “{name}” and its {count} photo{} from the library?": "Odebrat „{name}“ a jeho fotografie ({count}{:.0}) z knihovny?", + "Remove “{}” from Favorites": "Odebrat „{}“ z oblíbených", + "Removed from {}": "Odebráno z {}", + "Removed {what} from {name}": "Odebráno {what} z {name}", + "Rename {n} Photos…": "Přejmenovat fotografie: {n}…", + "Rename “{name}”": "Přejmenovat „{name}“", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Přejmenuje „{from}“ u fotografií: {n}{:.0} (i podřízená klíčová slova). Úrovně oddělujte |, např. Cestování|Itálie. Pokud název již existuje, obě slova se sloučí.", + "Rendering on the CPU: {why}": "Vykreslování na CPU: {why}", + "Replace {} with:": "Nahradit {} za:", + "Reset {name}": "Obnovit {name}", + "Reset {} Channel": "Obnovit kanál {}", + "Restore {}": "Obnovit {}", + "Restore {} Photo{}": "Obnovit fotografie: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Obnoveno z nedávno odstraněných fotografií: {}{:.0}", + "Running on the processor: {}.": "Běží na procesoru: {}.", + "Running on {}.": "Běží na {}.", + "Saved export preset “{}”": "Přednastavení exportu „{}“ bylo uloženo", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Vyhodnotí {scope} podle zaostření a expozice a najde podobné snímky pořízené v rozmezí několika sekund (série).", + "Show {hidden} hidden location{}": "Zobrazit skrytá umístění: {hidden}{:.0}", + "Showing {n} of {}": "Zobrazeno {n} z {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Zobrazí podmínky modelu, poté stáhne {} z {host}, nainstaluje jej a začne používat. Nic dalšího se neodesílá.", + "Smart album: {}": "Inteligentní album: {}", + "Tagged {n} photo{} from the tracklog": "Poloha doplněna ze záznamu trasy u fotografií: {n}{:.0}", + "The SAM 3 download failed: {e}": "Stažení SAM 3 selhalo: {e}", + "There is a keyword set “{name}” already": "Sada klíčových slov „{name}“ již existuje", + "There is a “{name}” at the top level already. Merge the two?": "Na nejvyšší úrovni již existuje „{name}“. Sloučit obě slova?", + "Trained on: {provenance}": "Trénováno na: {provenance}", + "Undo {}": "Zpět {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Neznámé značky{s:.0} {} zůstanou beze změny{:.0} (viz Značky)", + "Updated “{name}”": "Aktualizováno: „{name}“", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Verze {} — nezávisle vytvořená knihovna fotografií a nástroj pro vyvolání RAW v čistém Rustu.", + "Version: {}": "Verze: {}", + "Virtual copy “{name}” of {of}": "Virtuální kopie „{name}“ z {of}", + "Working on {what} for {} photos…": "Zpracování {what} pro fotografie: {}…", + "XMP adjustments were not imported for {path}: {fields}.": "Úpravy XMP se pro {path} neimportovaly: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP „{profile}“ se pro {path} nepoužil; barvy se mohou lišit od Lightroom.", + "none (CPU rendering): {why}": "žádné (vykreslování na CPU): {why}", + "not added to Apple Photos: {e}": "nepřidáno do Apple Photos: {e}", + "the {n} selected photos": "vybrané fotografie: {n}", + "the {} photos in view": "fotografie v zobrazení: {}", + "these {count} photos": "tyto fotografie: {count}", + "{:.1} MB": "{:.1} MB", + "{count} smart preview{} · {:.1} MB": "Inteligentní náhledy: {count}{0:.0} · {1:.1} MB", + "{done} of {total}": "{done} z {total}", + "{done} of {total} MB": "{done} z {total} MB", + "{file} · {size}": "{file} · {size}", + "{f} folders (and their subfolders)": "složky: {f} (a jejich podsložky)", + "{label} Grading": "Tónování: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} na všech vybraných fotografiích", + "{label} {index} of {n}": "{label} {index} z {n}", + "{label}, {n} photos": "{label}, fotografií: {n}", + "{matching} of {t} photos": "fotografií: {matching} z {t}", + "{matching} photos": "fotografií: {matching}", + "{mode} spot {} of {}": "{mode}: oblast {} z {}", + "{month}": "{month}", + "{month} {year}": "{month}. {year:04}", + "{msg} ({failed} failed)": "{msg} (neúspěšných: {failed})", + "{name} Label": "Štítek {name}", + "{name} Off": "{name} vypnuto", + "{name} On": "{name} zapnuto", + "{name} opened for editing; it is stacked with the original": "{name} otevřeno k úpravám; je v sadě s originálem", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} — dvojklikem obnovíte", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Přeskočené místní fotografie: {n}{:.0}; alba obsahují fotografie z knihovny (nejprve přidat do mých fotografií)", + "{n} added to Apple Photos": "Přidáno do Apple Photos: {n}", + "{n} added to Apple Photos album “{album}”": "Přidáno do alba Apple Photos „{album}“: {n}", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Změny nelze zapsat na disk: {n}{0:.0}: {e}\nPokud nyní ukončíte aplikaci, tyto změny se ztratí.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Změny uložené v paměti, ale nezapsané na disk: {n}{0:.0}: {e}\nLightCraft zápis automaticky opakuje; při ukončení nyní se tyto změny ztratí.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Změny uložené v paměti, ale nezapsané na disk: {n}{:.0}: {e} — LightCraft zápis zopakuje", + "{n} found · {sel} selected": "Nalezeno: {n} · vybráno: {sel}", + "{n} photos": "fotografií: {n}", + "{n} photos in {} are hidden by the filters above": "Fotografie v {} skryté filtry nahoře: {n}", + "{n} photos opened for editing; each is stacked with its original": "Fotografie otevřené k úpravám: {n}; každá je v sadě se svým originálem", + "{n} photo{}": "fotografií: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "Odpovídající fotografie: {n}{:.0} · aktualizuje se automaticky při změnách fotografií", + "{n} photo{} now · updates automatically as photos change": "Aktuální fotografie: {n}{:.0} · aktualizuje se automaticky při změnách fotografií", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Podobné fotografie: {n}{:.0} · Zobrazení → Zrušit filtry zobrazí všechny", + "{n} tasks are still running: {names}. Quitting stops them.": "Stále běží úlohy: {n}: {names}. Ukončení aplikace je zastaví.", + "{n} {noun}": "{noun}: {n}", + "{pct} %": "{pct} %", + "{pid} (missing)": "{pid} (chybí)", + "{sel_n} selected · {counted}": "Vybráno: {sel_n} · {counted}", + "{size} GB": "{size} GB", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb}: fotografií: {n}{:.0}", + "{weekday}, {day}": "{weekday}, {day}.", + "{weekday}, {day} {month} {year}": "{weekday}, {day}. {month}. {year:04}", + "{what} merge added": "Přidáno sloučení {what}", + "{what} merge cancelled": "Sloučení {what} bylo zrušeno", + "{what} merge failed: {e}": "Sloučení {what} selhalo: {e}", + "{year}": "{year:04}", + "{{date:…}} directives: {}": "Direktivy {{date:…}}: {}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "{} and higher": "{} a vyšší", + "{} contributors · {} commits": "Přispěvatelů: {} · commitů: {}", + "{} failed": "{} neúspěšných", + "{} files": "Souborů: {}", + "{} files and {f} folder{}": "Souborů: {}, složek: {f}{:.0}", + "{} item{}": "Položek: {}{:.0}", + "{} label (click more labels to show any of them)": "Štítek {} (kliknutím na další štítky zobrazíte fotografie s kterýmkoli z nich)", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} ze 4 vodítek — táhněte podél čar, které mají být svislé nebo vodorovné.", + "{} of {} from {host}": "{} z {} z {host}", + "{} photos": "Fotografií: {}", + "{} photos · exposures {} EV": "Fotografií: {} · expozice {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "Fotografií: {} · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Nečitelné fotografie: {}{:.0}", + "{} sample{}": "Vzorků: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Retušované oblasti na této fotografii: {}", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Ukládají se ve složce „denoise“ knihovny; při dosažení limitu se nejprve odstraní nejstarší, které žádná fotografie nepoužívá.", + "{} — show their photos": "{} — zobrazit fotografie této osoby", + "· {dups} already in the library": "· {dups} již v knihovně", + "· {n} photo{}": "· fotografií: {n}{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} v nedávno odstraněných", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ již obsahuje „{name}“. Sloučit obě slova?", + "• {assumption}": "• {assumption}", + "… and {more} more": "… a dalších {more}" +} diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json new file mode 100644 index 000000000..925718333 --- /dev/null +++ b/crates/ui-egui/locales/cs.json @@ -0,0 +1,2058 @@ +{ + " (library default)": " (výchozí nastavení knihovny)", + " (inverted)": " (invertováno)", + " · best of its burst": " · nejlepší ze série", + " · in a burst": " · v sérii", + "% of all commits": "% všech commitů", + "& Containing": "& Obsahující", + "+ Group": "+ Skupina", + "+ Rule": "+ Pravidlo", + ", picked": ", vybráno", + ", rejected": ", odmítnuto", + "0: nothing is rejected.": "0: nic se neodmítá.", + "1 day": "1 den", + "2026-04 or 2026-04-12": "2026-04 nebo 2026-04-12", + "= exactly": "= přesně", + "A blank template keeps the original names.": "Prázdná šablona zachová původní názvy.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Fotografie v tomto zobrazení skrývá filtr: změňte jej nebo jej zrušte (Zobrazení → Zrušit filtry)", + "A folder is already being synchronized": "Již probíhá synchronizace složky", + "A folder is being synchronized": "Probíhá synchronizace složky", + "A keyword set needs a name": "Sada klíčových slov musí mít název", + "A one-time download of about": "Jednorázové stažení přibližně", + "AI Denoise": "Redukce šumu pomocí AI", + "AI Denoise Model": "Model pro redukci šumu pomocí AI", + "AI Denoise is enabled on the requesting photo": "Redukce šumu pomocí AI je u požadované fotografie zapnutá", + "AI Denoise is off: press Use on a model.": "Redukce šumu pomocí AI je vypnutá: u modelu klikněte na Použít.", + "AI Denoise needs a model. Install one in Settings.": "Redukce šumu pomocí AI vyžaduje model. Nainstalujte jej v Nastavení.", + "AI Mask Model Status": "Stav modelu pro maskování pomocí AI", + "About": "O aplikaci", + "About LightCraft": "O aplikaci LightCraft", + "Accept & Download": "Přijmout a stáhnout", + "Active": "Aktivní", + "Activity": "Aktivita", + "Actual size": "Skutečná velikost", + "Add": "Přidat", + "Add Catchlight": "Přidat odlesk do oka", + "Add Face Model…": "Přidat model rozpoznávání obličejů…", + "Add Folder to Local": "Přidat složku do místních souborů", + "Add Mask": "Přidat masku", + "Add Object Click": "Přidat bod výběru objektu", + "Add Point Color Sample": "Přidat vzorek bodové barvy", + "Add Red Eye Correction": "Přidat korekci červených očí", + "Add Remove Spot": "Přidat retušovanou oblast", + "Add Selected Photos": "Přidat vybrané fotografie", + "Add a rule (⌥-click: a group)": "Přidat pravidlo (⌥-kliknutí: skupina)", + "Add in place": "Přidat ze současného umístění", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Přidat ze současného umístění: knihovna odkazuje na soubory tam, kde jsou; nic se nekopíruje ani nepřesouvá.", + "Add keyword": "Přidat klíčové slovo", + "Add number": "Přidat číslo", + "Add to": "Přidat do", + "Add to Album": "Přidat do alba", + "Add to All Selected Photos": "Přidat ke všem vybraným fotografiím", + "Add to Apple Photos": "Přidat do Apple Photos", + "Add to Favorites": "Přidat do oblíbených", + "Add to Local": "Přidat do místních souborů", + "Add to My Photos": "Přidat do mých fotografií", + "Add to My Photos First": "Nejprve přidat do mých fotografií", + "Add to Selected Photos": "Přidat k vybraným fotografiím", + "Add to Target Album": "Přidat do cílového alba", + "Add to favorites": "Přidat mezi oblíbené", + "Add to this mask": "Přidat do této masky", + "Add/Subtract/Intersect Mask": "Přičíst/Odečíst/Průnik masky", + "Added to photos you import that don't already have it.": "Přidá se k importovaným fotografiím, které tento údaj ještě nemají.", + "Adding": "Přidávání", + "Adding Lightroom photos…": "Přidávání fotografií z Lightroom…", + "Adding to Apple Photos": "Přidávání do Apple Photos", + "Adding to Apple Photos…": "Přidávání do Apple Photos…", + "Advanced search…": "Rozšířené hledání…", + "After": "Po", + "After 30 days": "Po 30 dnech", + "After 7 days": "Po 7 dnech", + "After 90 days": "Po 90 dnech", + "After a year": "Po roce", + "After installation, AI Denoise will turn on for the requesting photo.": "Po instalaci se u požadované fotografie zapne redukce šumu pomocí AI.", + "Album": "Album", + "Albums": "Alba", + "Albums hold library photos: Add to My Photos first": "Alba obsahují fotografie z knihovny: nejprve je přidejte do mých fotografií", + "All": "Vše", + "All Metadata": "Všechna metadata", + "All Metadata…": "Všechna metadata…", + "All Photos": "Všechny fotografie", + "All rights reserved": "Všechna práva vyhrazena", + "All the photos imported from this disk are included.": "Zahrnou se všechny fotografie importované z tohoto disku.", + "All types": "Všechny typy", + "Already installed. Remove it before installing a replacement.": "Již nainstalováno. Před instalací náhrady model odeberte.", + "Alt Text": "Alternativní text", + "Always": "Vždy", + "Amount": "Míra", + "Analyzing the photo for AI masks…": "Analýza fotografie pro masky pomocí AI…", + "Angle": "Úhel", + "Any Searchable Text": "Libovolný prohledávatelný text", + "Any camera": "Libovolný fotoaparát", + "Any keyword": "Libovolné klíčové slovo", + "Any lens": "Libovolný objektiv", + "Aperture": "Clona", + "Appearance": "Vzhled", + "Appearance Mode": "Režim vzhledu", + "Appearance Settings…": "Nastavení vzhledu…", + "Application": "Aplikace", + "Apply": "Použít", + "Apply Color Label Set": "Použít sadu barevných štítků", + "Apply Filter Preset": "Použít přednastavení filtru", + "Apply Metadata Preset": "Použít přednastavení metadat", + "Apply Point Curve Preset": "Použít přednastavení bodové křivky", + "Apply Preset": "Použít přednastavení", + "Apply Settings JSON": "Použít nastavení JSON", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Použít místní mapování tónů uložené v souboru RAW (Apple ProRAW), stejně jako při vykreslení fotoaparátem", + "Approved": "Schváleno", + "Aqua": "Tyrkysová", + "Aqua Hue": "Odstín tyrkysové", + "Aqua Luminance": "Jas tyrkysové", + "Aqua Saturation": "Sytost tyrkysové", + "Arrange Panel": "Uspořádat panel", + "ArtCraft Website": "Web ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — další kreativní aplikace", + "As Shot": "Podle snímku", + "As shot": "Podle pořízení", + "Ascending": "Vzestupně", + "Aspect": "Poměr stran", + "Aspect Ratio": "Poměr stran obrazu", + "Assisted Culling": "Asistované třídění", + "Assisted Culling…": "Asistované třídění…", + "Auto": "Automaticky", + "Auto Advance": "Automatický přechod", + "Auto Advance Off": "Vypnout automatický přechod", + "Auto Advance On": "Zapnout automatický přechod", + "Auto Advance after rating or flagging": "Automatický přechod po hodnocení nebo označení příznakem", + "Auto Advance: move to the next photo after rating or flagging": "Automatický přechod: po hodnocení nebo označení příznakem přejde na další fotografii", + "Auto Align": "Automaticky zarovnat", + "Auto B&W Mix": "Automatická černobílá směs", + "Auto Crop": "Automaticky oříznout", + "Auto Import": "Automatický import", + "Auto Import Now": "Spustit automatický import", + "Auto Import Settings": "Nastavení automatického importu", + "Auto Mask": "Automatická maska", + "Auto Select": "Automaticky vybrat", + "Auto Settings": "Automatické nastavení", + "Auto Straighten": "Automaticky narovnat", + "Auto Sync": "Automatická synchronizace", + "Auto settings applied": "Automatické nastavení bylo použito", + "Auto-Stack by Capture Time": "Automaticky seskupit podle času pořízení", + "Auto-Stack by Capture Time…": "Automaticky seskupit podle času pořízení…", + "Auto-Tag Photos from Tracklog": "Doplnit polohu fotografií ze záznamu trasy", + "Auto-Tag from Tracklog": "Doplnit polohu ze záznamu trasy", + "Auto-Tag from Tracklog…": "Doplnit polohu ze záznamu trasy…", + "Automatic": "Automatické", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatický režim vykresluje detail podle velikosti zobrazení, nejvýše v rozlišení fotografie. Zvolením velikosti nastavíte limit: menší je rychlejší.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatický režim používá grafickou kartu tam, kde je rychlejší než procesor: při prvním zpracování fotografie se na tomto počítači změří obě možnosti.", + "Automatically Write Changes into XMP": "Automaticky zapisovat změny do XMP", + "Automatically write changes into XMP sidecars": "Automaticky zapisovat změny do přidružených souborů XMP", + "Autumn Gold": "Podzimní zlato", + "B&W": "Černobílé", + "B&W Mix": "Černobílá směs", + "B&W Mixer": "Černobílý směšovač", + "Back": "Zpět", + "Back Up Library…": "Zálohovat knihovnu…", + "Back to Edit": "Zpět k úpravám", + "Back to Edit (E)": "Zpět k úpravám (E)", + "Back to Grid": "Zpět do mřížky", + "Back to this person": "Zpět k této osobě", + "Background": "Pozadí", + "Balance": "Vyvážení", + "Basic": "Základní", + "Before": "Před", + "Before/After (Y)": "Před/Po (Y)", + "Before/After Settings": "Nastavení Před/Po", + "Before/After Split": "Rozdělené Před/Po", + "Before/After Split Top/Bottom": "Rozdělené Před/Po nahoře/dole", + "Before/After Top/Bottom": "Před/Po nahoře/dole", + "Begin Interaction": "Zahájit interakci", + "Best of Similar Shots": "Nejlepší z podobných snímků", + "Binary assets added": "Přidané binární soubory", + "Binary assets removed": "Odebrané binární soubory", + "Bit depth": "Bitová hloubka", + "Black & White": "Černobílá", + "Blacks": "Černé", + "Bleach Bypass": "Vynechání bělení", + "Bleached": "Vybělené", + "Blending": "Prolnutí", + "Blue": "Modrá", + "Blue Hour Boost": "Zvýraznění modré hodiny", + "Blue Hue": "Odstín modré", + "Blue Luminance": "Jas modré", + "Blue Primary": "Primární modrá", + "Blue Saturation": "Sytost modré", + "Blue channel": "Modrý kanál", + "Blue label": "Modrý štítek", + "Blue/Yellow Fringe": "Modrý/žlutý lem", + "Blurry photos below the score are flagged as rejects.": "Rozmazaným fotografiím se skóre pod limitem se nastaví příznak Odmítnuto.", + "Boundary Warp": "Deformace hranic", + "Bright & Airy": "Světlé a vzdušné", + "Bring All to Front": "Přenést všechna okna do popředí", + "Bring the library up to date with this folder and the folders inside it:": "Aktualizovat knihovnu podle této složky a jejích podsložek:", + "Browse Folder": "Procházet složku", + "Browse Folder…": "Procházet složku…", + "Browse Profiles": "Procházet profily", + "Browse…": "Procházet…", + "Brush": "Štětec", + "Brush Settings": "Nastavení štětce", + "Brush Stroke": "Tah štětce", + "Build Previews": "Vytvořit náhledy", + "Build Smart Previews": "Vytvořit inteligentní náhledy", + "Building previews": "Vytváření náhledů", + "Building smart previews": "Vytváření inteligentních náhledů", + "By Date": "Podle data", + "By day (YYYY/YYYY-MM-DD)": "Podle dne (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "Podle měsíce (YYYY/YYYY-MM)", + "CPU threads": "Vlákna CPU", + "Cache": "Mezipaměť", + "Calibration": "Kalibrace", + "Camera": "Fotoaparát", + "Camera Info": "Informace o fotoaparátu", + "Camera local tone mapping": "Místní mapování tónů fotoaparátu", + "Camera make and model": "Výrobce a model fotoaparátu", + "Cameras and Cards": "Fotoaparáty a karty", + "Cancel": "Zrušit", + "Cancel AI Mask Model Download": "Zrušit stahování modelu pro maskování pomocí AI", + "Cancel Denoise": "Zrušit redukci šumu", + "Cancel Denoise Model Download": "Zrušit stahování modelu pro redukci šumu", + "Cancel Download": "Zrušit stahování", + "Cancel Face Model Download": "Zrušit stahování modelu rozpoznávání obličejů", + "Cancel Interaction": "Zrušit interakci", + "Cancel Pending Photo Action": "Zrušit čekající akci s fotografií", + "Cancel Preview Build": "Zrušit vytváření náhledů", + "Cancel pending action": "Zrušit čekající akci", + "Candidate": "Kandidát", + "Caption": "Popisek", + "Capture Date": "Datum pořízení", + "Capture date and time in your own format (directives below)": "Datum a čas pořízení ve vlastním formátu (direktivy níže)", + "Capture date as YYYYMMDD": "Datum pořízení jako YYYYMMDD", + "Captured": "Pořízeno", + "Captured from": "Pořízeno od", + "Captured until": "Pořízeno do", + "Catalog Statistics": "Statistiky katalogu", + "Catchlight": "Odlesk v oku", + "Change": "Změnit", + "Change Component Mode": "Změnit režim součásti", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Znaky nepovolené v názvech souborů (/ \\ : * ? < > | a rovné dvojité uvozovky) se nahradí znakem -; existující název získá příponu -1, -2….", + "Charcoal": "Uhel", + "Check All": "Zaškrtnout vše", + "Check Output Path": "Zkontrolovat výstupní cestu", + "Check the files of this type": "Zaškrtnout soubory tohoto typu", + "Checking and installing the model…": "Kontrola a instalace modelu…", + "Checking names…": "Kontrola názvů…", + "Checking…": "Kontrola…", + "Choose Another Library…": "Vybrat jinou knihovnu…", + "Choose Denoise Model": "Vybrat model pro redukci šumu", + "Choose Edit Settings to Copy": "Vybrat nastavení úprav ke kopírování", + "Choose Edit Settings to Copy…": "Vybrat nastavení úprav ke kopírování…", + "Choose Face Recognition Model": "Vybrat model rozpoznávání obličejů", + "Choose Folder…": "Vybrat složku…", + "Choose Profile…": "Vybrat profil…", + "Choose a model below (Use) to start.": "Začněte výběrem modelu níže (Použít).", + "Choose a photo in the grid or filmstrip": "Vyberte fotografii v mřížce nebo filmovém pásu", + "Choose an album…": "Vybrat album…", + "Choose…": "Vybrat…", + "Cinematic": "Filmové", + "City": "Město", + "Clarity": "Zřetelnost", + "Clean Studio": "Čisté studio", + "Clear": "Vymazat", + "Clear Cache": "Vymazat mezipaměť", + "Clear Denoise Cache": "Vymazat mezipaměť redukce šumu", + "Clear Filters": "Zrušit filtry", + "Clear Guides": "Vymazat vodítka", + "Clear History": "Vymazat historii", + "Clear Preview Cache": "Vymazat mezipaměť náhledů", + "Clear Quick Collection": "Vyprázdnit rychlou kolekci", + "Clear Recent Libraries": "Vymazat nedávné knihovny", + "Clear Reference": "Zrušit referenční fotografii", + "Clear all": "Vymazat vše", + "Clear cache": "Vyprázdnit mezipaměť", + "Clear form": "Vymazat formulář", + "Clear rating": "Zrušit hodnocení", + "Clear rating and advance": "Zrušit hodnocení a přejít dál", + "Cleared History": "Historie byla vymazána", + "Click Zoom Ratio": "Poměr přiblížení po kliknutí", + "Click a shortcut, then press the new keys (Esc cancels).": "Klikněte na zkratku a stiskněte nové klávesy (Esc zruší změnu).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Kliknutím vložíte značku. Příklady platí pro IMG_0042.CR3 pořízený 14. ledna 2026 v 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Kliknutím na fotografie v mřížce přidáte nebo odeberete klíčové slovo; Esc ukončí režim", + "Click the object to select it · ⌥-click leaves a part out": "Kliknutím vyberete objekt · ⌥-kliknutím vyloučíte část", + "Click the photo to pick a colour · ⇧-click adds more": "Kliknutím na fotografii vyberete barvu · ⇧-kliknutím přidáte další", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknutím na fotografii vyberete barvu; ⇧-kliknutím přidáte další (nejvýše 5)", + "Click to clear these filters": "Kliknutím zrušíte tyto filtry", + "Click to restore · double-click to rename": "Kliknutím obnovíte · dvojklikem přejmenujete", + "Click to type a value": "Kliknutím můžete zadat hodnotu", + "Clone": "Klonovat", + "Close": "Zavřít", + "Close Panel": "Zavřít panel", + "Close Window": "Zavřít okno", + "Close all": "Zavřít vše", + "Close file": "Zavřít soubor", + "Close panel": "Zavřít boční panel", + "Cloudy": "Zataženo", + "Collapse": "Sbalit", + "Collapse All Stacks": "Sbalit všechny sady", + "Collapse Stack": "Sbalit sadu", + "Color": "Barva", + "Color Grading": "Barevné tónování", + "Color Label": "Barevný štítek", + "Color Label Names": "Názvy barevných štítků", + "Color Label Sets": "Sady barevných štítků", + "Color Mixer": "Směšovač barev", + "Color Noise Reduction": "Redukce barevného šumu", + "Color Priority": "Priorita barvy", + "Color Range": "Rozsah barev", + "Color label cleared": "Barevný štítek byl zrušen", + "Color space": "Barevný prostor", + "Color space: sRGB (AVIF)": "Barevný prostor: sRGB (AVIF)", + "Colors": "Barvy", + "Combine files…": "Sloučit soubory…", + "Command Journal": "Deník příkazů", + "Comments panel": "Panel komentářů", + "Commits": "Commity", + "Company": "Společnost", + "Compare": "Porovnat", + "Compare (Shift+C)": "Porovnat (Shift+C)", + "Compare Before and After": "Porovnat před a po", + "Compare needs at least two photos": "Porovnání vyžaduje alespoň dvě fotografie", + "Component options": "Možnosti součásti", + "Compression": "Komprese", + "Concrete Cool": "Chladný beton", + "Confirm before deleting": "Potvrdit před odstraněním", + "Confirm before moving photos to Recently Deleted": "Potvrdit před přesunem fotografií do nedávno odstraněných", + "Constrain Crop": "Omezit oříznutí", + "Content-Aware": "Podle obsahu", + "Continue": "Pokračovat", + "Continue Without Saving": "Pokračovat bez uložení", + "Contrast": "Kontrast", + "Contributors": "Přispěvatelé", + "Convert to DNG": "Převést do DNG", + "Cool Fade": "Chladné vyblednutí", + "Cool Morning": "Chladné ráno", + "Copy": "Kopírovat", + "Copy After's Settings to Before": "Kopírovat nastavení Po do Před", + "Copy Before to After": "Kopírovat Před do Po", + "Copy Before's Settings to After": "Kopírovat nastavení Před do Po", + "Copy Edit Settings": "Kopírovat nastavení úprav", + "Copy History Step Settings to Before": "Kopírovat nastavení kroku historie do Před", + "Copy Metadata": "Kopírovat metadata", + "Copy Name": "Název kopie", + "Copy as DNG": "Kopírovat jako DNG", + "Copy into the library (else use the files where they are)": "Kopírovat do knihovny (jinak použít soubory v jejich umístění)", + "Copy pages": "Kopírovat stránky", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopírovat soubory (do Originals/ v knihovně nebo do zvolené složky)", + "Copy to": "Kopírovat do", + "Copy to Clipboard": "Kopírovat do schránky", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopírovat: soubory se zkopírují do složky níže a knihovna používá kopie; originály zůstanou beze změny.", + "Copyright": "Autorská práva", + "Copyright Info URL": "URL informací o autorských právech", + "Copyright Status": "Stav autorských práv", + "Copyrighted": "Chráněno autorskými právy", + "Country": "Země", + "Create": "Vytvořit", + "Create Album": "Vytvořit album", + "Create Album…": "Vytvořit album…", + "Create Folder": "Vytvořit složku", + "Create Folder…": "Vytvořit složku…", + "Create Keyword Tag": "Vytvořit značku klíčového slova", + "Create New Mask": "Vytvořit novou masku", + "Create PDF from clipboard": "Vytvořit PDF ze schránky", + "Create PDF from file…": "Vytvořit PDF ze souboru…", + "Create PDF from images…": "Vytvořit PDF z obrázků…", + "Create Preset": "Vytvořit přednastavení", + "Create Preset…": "Vytvořit přednastavení…", + "Create Proof Copy": "Vytvořit kopii pro měkký nátisk", + "Create Smart Album": "Vytvořit inteligentní album", + "Create Smart Album from Filter…": "Vytvořit inteligentní album z filtru…", + "Create Smart Album…": "Vytvořit inteligentní album…", + "Create Stack": "Vytvořit sadu", + "Create Version": "Vytvořit verzi", + "Create Version from Step": "Vytvořit verzi z kroku", + "Create Virtual Copy": "Vytvořit virtuální kopii", + "Creator": "Autor", + "Creator metadata": "Metadata autora", + "Crisp Landscape": "Ostrá krajina", + "Crisp Vista": "Jasný výhled", + "Crop": "Oříznout", + "Crop & Rotate": "Oříznout a otočit", + "Crop & Rotate (C)": "Oříznout a otočit (C)", + "Crop Aspect": "Poměr stran ořezu", + "Crop, Rotate, Geometry": "Oříznutí, otočení, geometrie", + "Cropped": "Oříznuto", + "Cross Process": "Křížové vyvolání", + "Culling": "Třídění", + "Curve": "Křivka", + "Custom": "Vlastní", + "Custom template…": "Vlastní šablona…", + "Custom…": "Vlastní…", + "Cut": "Vyjmout", + "Cut pages": "Vyjmout stránky", + "Cycle Crop Overlay": "Přepnout překrytí ořezu", + "Cycle Crop Overlay Orientation": "Přepnout orientaci překrytí ořezu", + "Cycle Info Overlay": "Přepnout překrytí informací", + "Cycle Mask Overlay Color": "Přepnout barvu překrytí masky", + "Cycle Mask Overlay Mode": "Přepnout režim překrytí masky", + "Cylindrical": "Válcová", + "Dark & Moody": "Tmavé a náladové", + "Dark Mode": "Tmavý režim", + "Dark Theme": "Tmavý motiv", + "Darken": "Ztmavit", + "Darks": "Tmavé tóny", + "Date": "Datum", + "Date Groups": "Skupiny podle data", + "Day": "Den", + "Daylight": "Denní světlo", + "Decrease Brush Feather": "Zmenšit prolnutí štětce", + "Decrease Brush Size": "Zmenšit velikost štětce", + "Decrease Rating": "Snížit hodnocení", + "Deep Sky": "Hluboká obloha", + "Defringe": "Odstranit barevné lemy", + "Deghost Amount": "Míra odstranění duchů", + "Dehaze": "Odstranit opar", + "Delete": "Odstranit", + "Delete (⌫)": "Odstranit (⌫)", + "Delete Album": "Odstranit album", + "Delete All Masks": "Odstranit všechny masky", + "Delete Color Label Set": "Odstranit sadu barevných štítků", + "Delete Component": "Odstranit součást", + "Delete Export Preset": "Odstranit přednastavení exportu", + "Delete Filter Preset": "Odstranit přednastavení filtru", + "Delete Imported Profile": "Odstranit importovaný profil", + "Delete Keyword": "Odstranit klíčové slovo", + "Delete Keyword Set": "Odstranit sadu klíčových slov", + "Delete Keyword…": "Odstranit klíčové slovo…", + "Delete Mask": "Odstranit masku", + "Delete Metadata Preset": "Odstranit přednastavení metadat", + "Delete Permanently": "Trvale odstranit", + "Delete Photo": "Odstranit fotografii", + "Delete Photos": "Odstranit fotografie", + "Delete Point Color": "Odstranit bodovou barvu", + "Delete Point Color Sample": "Odstranit vzorek bodové barvy", + "Delete Point Curve Preset": "Odstranit přednastavení bodové křivky", + "Delete Preset": "Odstranit přednastavení", + "Delete Red Eye": "Odstranit červené oči", + "Delete Red Eye Correction": "Odstranit korekci červených očí", + "Delete Spot": "Odstranit retušovanou oblast", + "Delete Version": "Odstranit verzi", + "Delete all": "Odstranit vše", + "Delete all spots": "Odstranit všechny retušované oblasti", + "Delete mask": "Odstranit masku", + "Delete them": "Odstranit je", + "Denoise Model Downloads": "Stahování modelů pro redukci šumu", + "Denoise Models": "Modely pro redukci šumu", + "Denoise Photos": "Redukovat šum fotografií", + "Denoise Pump": "Zpracovat frontu redukce šumu", + "Denoise Settings": "Nastavení redukce šumu", + "Denoise Status": "Stav redukce šumu", + "Denoise all photos with an Amount": "Redukovat šum všech fotografií s nastavenou mírou", + "Density": "Hustota", + "Depth Range": "Rozsah hloubky", + "Descending": "Sestupně", + "Describe": "Popsat", + "Describe what to keep": "Popište, co zachovat", + "Describe what to leave out": "Popište, co vyloučit", + "Describe what to select": "Popište, co vybrat", + "Describe…": "Popsat…", + "Description": "Popis", + "Deselect": "Zrušit výběr", + "Deselect All": "Zrušit výběr všeho", + "Desert Heat": "Pouštní žár", + "Desert Warmth": "Pouštní teplo", + "Desktop": "Plocha", + "Detail": "Detail", + "Detail (D)": "Detail (D)", + "Details": "Podrobnosti", + "Detect Faces": "Rozpoznat obličeje", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Rozpoznávání obličejů (nabídka Fotografie) jej vyžaduje: stačí malé jednorázové stažení.", + "Develop": "Vyvolat", + "Diagonal": "Úhlopříčka", + "Discard Preview Cache": "Vymazat mezipaměť náhledů", + "Discarding smart previews": "Odstraňování inteligentních náhledů", + "Display P3": "Display P3", + "Display name": "Zobrazované jméno", + "Display theme": "Motiv zobrazení", + "Distortion": "Zkreslení", + "Document properties…": "Vlastnosti dokumentu…", + "Don't enlarge": "Nezvětšovat", + "Done": "Hotovo", + "Download": "Stáhnout", + "Download AI Mask Model": "Stáhnout model pro maskování pomocí AI", + "Download Denoise Model": "Stáhnout model pro redukci šumu", + "Download Face Model": "Stáhnout model rozpoznávání obličejů", + "Download Place Names (GeoNames)": "Stáhnout názvy míst (GeoNames)", + "Download the SAM 3 Model?": "Stáhnout model SAM 3?", + "Downloaded and checked. Installing…": "Staženo a ověřeno. Probíhá instalace…", + "Downloading model": "Stahování modelu", + "Downloading place names": "Stahování názvů míst", + "Downloads": "Stažené soubory", + "Drag Crop Handle": "Přetáhnout úchyt ořezu", + "Drag along the horizon; double-click for Auto": "Táhněte podél horizontu; dvojklik spustí automatické narovnání", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Přetáhněte přes oko na fotografii; zornice uvnitř se najde automaticky.", + "Draw Guides": "Nakreslit vodítka", + "Drop here for the top level": "Přetažením sem umístíte do nejvyšší úrovně", + "Duplicate": "Duplikovat", + "Duplicate Component": "Duplikovat součást", + "Duplicate Mask": "Duplikovat masku", + "Duplicate and Invert Mask": "Duplikovat a invertovat masku", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Každé / začíná další úroveň složek; značky se vyplňují pro každou fotografii zvlášť (úroveň s chybějícími metadaty je „neznámá“).", + "Edge": "Hrana", + "Edit": "Úpravy", + "Edit (E)": "Upravit (E)", + "Edit Capture Time": "Upravit čas pořízení", + "Edit Capture Time…": "Upravit čas pořízení…", + "Edit Color Label Names": "Upravit názvy barevných štítků", + "Edit Color Label Names…": "Upravit názvy barevných štítků…", + "Edit Copy for External Editor": "Upravit kopii pro externí editor", + "Edit Date": "Datum úprav", + "Edit Info": "Upravit informace", + "Edit Keyword Set": "Upravit sadu klíčových slov", + "Edit Keyword Tag": "Upravit značku klíčového slova", + "Edit Keyword Tag…": "Upravit značku klíčového slova…", + "Edit Label Names": "Upravit názvy štítků", + "Edit Label Names…": "Upravit názvy štítků…", + "Edit Mask": "Upravit masku", + "Edit Mask Component": "Upravit součást masky", + "Edit Sections": "Sekce úprav", + "Edit Set…": "Upravit sadu…", + "Edit Smart Album": "Upravit inteligentní album", + "Edit Smart Album…": "Upravit inteligentní album…", + "Edit Spot": "Upravit retušovanou oblast", + "Edit in External Editor": "Upravit v externím editoru", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Upravit v HDR: světla nad bílou SDR pro export a displeje HDR", + "Edit in Other Editor": "Upravit v jiném editoru", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Upravit v jiném editoru (⌥⌘E) otevře kopii v této aplikaci (název aplikace v macOS, jinde cesta k programu).", + "Edit in PhotoCraft": "Upravit v PhotoCraft", + "Edit settings copied": "Nastavení úprav bylo zkopírováno", + "Edited": "Upraveno", + "Edited or not": "S úpravami nebo bez nich", + "Effect": "Efekt", + "Effects": "Efekty", + "Empty Recently Deleted": "Vyprázdnit nedávno odstraněné", + "Enable Lens Corrections": "Zapnout korekce objektivu", + "Enable Profile Corrections": "Zapnout korekce podle profilu", + "End Interaction": "Ukončit interakci", + "English": "English", + "Enter Full Screen": "Přejít na celou obrazovku", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Zadejte datum jako YYYY-MM-DD HH:MM:SS", + "Erase": "Mazat", + "Evaluate Face Recognition": "Vyhodnotit rozpoznávání obličejů", + "Every EXIF, GPS and XMP field in the file": "Všechna pole EXIF, GPS a XMP v souboru", + "Existing previews": "Existující náhledy", + "Exit": "Ukončit", + "Expand": "Rozbalit", + "Expand All Stacks": "Rozbalit všechny sady", + "Expand Stack": "Rozbalit sadu", + "Expand/Collapse Stack": "Rozbalit/Sbalit sadu", + "Export": "Exportovat", + "Export Album with Preset": "Exportovat album s přednastavením", + "Export Album…": "Exportovat album…", + "Export As…": "Exportovat jako…", + "Export Containing Keywords": "Exportovat nadřazená klíčová slova", + "Export Group…": "Exportovat skupinu…", + "Export Keywords": "Exportovat klíčová slova", + "Export Keywords…": "Exportovat klíčová slova…", + "Export Now": "Exportovat nyní", + "Export Point Curve Presets": "Exportovat přednastavení bodové křivky", + "Export Point Curve Presets…": "Exportovat přednastavení bodové křivky…", + "Export Presets": "Exportovat přednastavení", + "Export Presets…": "Exportovat přednastavení…", + "Export Synonyms": "Exportovat synonyma", + "Export User Presets…": "Exportovat uživatelská přednastavení…", + "Export stopped unexpectedly": "Export byl neočekávaně přerušen", + "Export with Preset": "Exportovat s přednastavením", + "Export with Previous": "Exportovat s předchozím nastavením", + "Exporting": "Exportování", + "Exporting contact sheet": "Exportování přehledu snímků", + "Export…": "Exportovat…", + "Exposure": "Expozice", + "Exposure (shutter · aperture · ISO)": "Expozice (závěrka · clona · ISO)", + "Extended Description": "Rozšířený popis", + "External Editing": "Externí úpravy", + "External Editor": "Externí editor", + "Face Boxes": "Rámečky obličejů", + "Face Model Downloads": "Stahování modelů rozpoznávání obličejů", + "Face Models": "Modely rozpoznávání obličejů", + "Face Recognition On/Off": "Zapnout/Vypnout rozpoznávání obličejů", + "Face boxes": "Oblasti obličejů", + "Face detector": "Detektor obličejů", + "Face names written to XMP by Lightroom and other apps show up here.": "Zde se zobrazují jména u obličejů zapsaná do XMP aplikací Lightroom i jinými aplikacemi.", + "Face recognition": "Rozpoznávání obličejů", + "Faces": "Obličeje", + "Faces of a Person": "Obličeje osoby", + "Faded Matte": "Vybledlé matné", + "Faded Slide": "Vybledlý diapozitiv", + "Faded Urban": "Vybledlé město", + "Failed": "Nezdařilo se", + "Failed its last test": "Poslední test se nezdařil", + "Favorite Preset": "Přidat přednastavení mezi oblíbené", + "Favorite Profile": "Přidat profil mezi oblíbené", + "Favorites": "Oblíbené", + "Feather": "Prolnutí okrajů", + "File": "Soubor", + "File & date": "Soubor a datum", + "File Extension": "Přípona souboru", + "File Format": "Formát souboru", + "File Name": "Název souboru", + "File Path": "Cesta k souboru", + "File Size": "Velikost souboru", + "File Type": "Typ souboru", + "File name": "Název výstupního souboru", + "File types:": "Typy souborů:", + "Filename": "Jméno souboru", + "Fill": "Vyplnit", + "Fill Edges": "Vyplnit okraje", + "Fill Location": "Doplnit místo", + "Fill Location from GPS": "Doplnit místo podle GPS", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Doplnit prázdné město, kraj a zemi podle GPS (názvy míst GeoNames offline)", + "Film": "Film", + "Filmstrip": "Filmový pás", + "Filmstrip (/)": "Filmový pás (/)", + "Filter": "Filtr", + "Filter Bar": "Lišta filtrů", + "Filter Keywords": "Filtrovat klíčová slova", + "Filter Presets": "Přednastavení filtrů", + "Filter bar": "Panel filtrů", + "Filter fields": "Pole filtru", + "Find Dust Spots": "Najít prachové skvrny", + "Find Missing Photos": "Najít chybějící fotografie", + "Find Missing Photos…": "Najít chybějící fotografie…", + "Find Similar Photos": "Najít podobné fotografie", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Najít fotografie přidané do této složky na disku nebo v ní chybějící a přidružené soubory XMP změněné jinými aplikacemi", + "Find tools and commands…": "Najít nástroje a příkazy…", + "Finding faces": "Hledání obličejů", + "Find…": "Najít…", + "First": "První", + "First commit": "První commit", + "Fit": "Přizpůsobit", + "Fit (F)": "Přizpůsobit (F)", + "Fit on Screen": "Přizpůsobit obrazovce", + "Fit to width": "Přizpůsobit šířce", + "Fit visible": "Přizpůsobit viditelné oblasti", + "Five stars": "Pět hvězdiček", + "Fix the marked rules to save this album.": "Chcete-li toto album uložit, opravte označená pravidla.", + "Fixed keys": "Pevné klávesy", + "Flag": "Příznak", + "Flag as Pick": "Nastavit příznak Vybráno", + "Flag as Reject": "Nastavit příznak Odmítnuto", + "Flagged as Pick": "Nastaven příznak Vybráno", + "Flagged as Reject": "Nastaven příznak Odmítnuto", + "Flash": "Blesk", + "Flip H": "Převrátit vodorovně", + "Flip Horizontal": "Převrátit horizontálně", + "Flip Overlay (⇧O)": "Převrátit překrytí (⇧O)", + "Flip V": "Převrátit svisle", + "Flip Vertical": "Převrátit vertikálně", + "Float panel": "Uvolnit panel", + "Flow": "Tok", + "Fluorescent": "Zářivka", + "Focal Length": "Ohnisková vzdálenost", + "Focal length": "Ohnisko", + "Focus (assisted culling)": "Zaostření (asistované třídění)", + "Folder": "Složka", + "Folder name (renamed on disk; its photos follow)": "Název složky (přejmenuje se na disku; její fotografie se přesunou s ní)", + "Folders": "Složky", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Složky a {date} používají čas pořízení; fotografie bez něj použije dnešní datum.", + "Food": "Jídlo", + "Forget Unchanged Local Photos": "Zapomenout nezměněné místní fotografie", + "Forget unchanged photos": "Zapomenout nezměněné fotografie", + "Form fields panel": "Panel polí formuláře", + "Format": "Formát", + "Forward": "Vpřed", + "Found automatically": "Nalezeno automaticky", + "Fr": "Pá", + "Frame time": "Doba snímku", + "Frame update": "Aktualizace snímku", + "Free": "Volné", + "Fresh & Bright": "Svěží a jasné", + "Friday": "Pátek", + "Full": "Plné", + "Full Screen Preview": "Náhled na celé obrazovce", + "Full screen mode": "Režim celé obrazovky", + "Full size": "Plná velikost", + "GPS Track Log": "Záznam trasy GPS", + "GPU Rendering": "Vykreslování na GPU", + "GPU rendering": "Vykreslení na grafické kartě", + "General": "Obecné", + "Generated demo photo": "Generovaná ukázková fotografie", + "Geometry": "Geometrie", + "Get Develop Settings": "Získat nastavení vyvolání", + "Global Hue": "Celkový odstín", + "Global Luminance": "Celkový jas", + "Global Saturation": "Celková sytost", + "Glossy": "Lesklé", + "Go back to listing them by name": "Vrátit se k seznamu podle jmen", + "Go to History Step": "Přejít na krok historie", + "Golden": "Zlaté", + "Golden Glow": "Zlatá záře", + "Golden Hour": "Zlatá hodina", + "Grab bag": "Různorodé", + "Grain": "Zrnitost", + "Graphic": "Grafika", + "Graphics card": "Grafická karta", + "Green": "Zelená", + "Green Amount": "Míra zelené", + "Green Hue": "Odstín zelené", + "Green Hue High": "Horní mez odstínu zelené", + "Green Hue Low": "Dolní mez odstínu zelené", + "Green Luminance": "Jas zelené", + "Green Primary": "Primární zelená", + "Green Saturation": "Sytost zelené", + "Green channel": "Zelený kanál", + "Green label": "Zelený štítek", + "Grid": "Mřížka", + "Grid Info": "Informace v mřížce", + "Gritty Street": "Drsná ulice", + "Group": "Skupina", + "Group by Date: Automatic": "Seskupit podle data: automaticky", + "Group by Day": "Seskupit podle dne", + "Group by Month": "Seskupit podle měsíce", + "Group by Year": "Seskupit podle roku", + "Group by date": "Seskupování podle data", + "Group into Stack": "Seskupit do sady", + "Group name": "Název skupiny", + "Guided": "S vodítky", + "Guided Upright": "Narovnání perspektivy s vodítky", + "Guided Upright Guides": "Vodítka narovnání perspektivy", + "HDR": "HDR", + "HDR Merge": "Sloučit do HDR", + "HDR Merge Preview": "Náhled sloučení do HDR", + "HDR Panorama Merge": "Sloučit do panoramatu HDR", + "HDR Panorama Merge Preview": "Náhled sloučení do panoramatu HDR", + "HDR Panorama with Last Settings": "Panorama HDR s posledním nastavením", + "HDR Panoramas": "Panoramata HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panoramata HDR", + "HDR with Last Settings": "HDR s posledním nastavením", + "HDR…": "HDR…", + "Has Edits": "Obsahuje úpravy", + "Has GPS": "Obsahuje GPS", + "Heal": "Retušovat", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Retušuje drobné měkké tmavé skvrny, které prach na snímači zanechává v hladkých oblastech", + "Height": "Výška", + "Height (px)": "Výška (px)", + "Help": "Nápověda", + "Hide LightCraft": "Skrýt LightCraft", + "Hide Mask": "Skrýt masku", + "Hide Others": "Skrýt ostatní", + "Hide This Section": "Skrýt tuto sekci", + "Hides this shortcut only; the folder and its photos stay as they are": "Skryje pouze tohoto zástupce; složka a její fotografie zůstanou beze změny", + "High": "Vysoká", + "High Contrast B&W": "Vysoce kontrastní černobílá", + "Highlight": "Zvýraznit", + "Highlights": "Světla", + "Highlights Hue": "Odstín světel", + "Highlights Luminance": "Jas světel", + "Highlights Saturation": "Sytost světel", + "Highlights split": "Rozdělení světel", + "Histogram": "Histogram", + "History": "Historie", + "History & Activity (Y)": "Historie a aktivita (Y)", + "Home": "Domovská složka", + "Horizontal": "Vodorovně", + "Horizontal inset (% of short edge)": "Vodorovné odsazení (% kratší hrany)", + "Horizontal text": "Vodorovný text", + "Hours": "Hodiny", + "How to install the model": "Jak nainstalovat model", + "Hue": "Odstín", + "Hue Range": "Rozsah odstínu", + "I have read these terms and accept them for my own use": "Tyto podmínky jsem si přečetl(a) a přijímám je pro vlastní použití", + "ISO Speed": "Citlivost ISO", + "ISO speed": "ISO citlivost", + "If file exists": "Pokud soubor existuje", + "Image": "Obrázek", + "Import": "Importovat", + "Import Date": "Datum importu", + "Import Keywords": "Importovat klíčová slova", + "Import Keywords…": "Importovat klíčová slova…", + "Import Lightroom Catalog": "Importovat katalog Lightroom", + "Import Lightroom Catalog…": "Importovat katalog Lightroom…", + "Import Photos": "Importovat fotografie", + "Import Photos…": "Importovat fotografie…", + "Import Point Curve Presets": "Importovat přednastavení bodové křivky", + "Import Point Curve Presets…": "Importovat přednastavení bodové křivky…", + "Import Presets": "Importovat přednastavení", + "Import Presets…": "Importovat přednastavení…", + "Import Profiles": "Importovat profily", + "Import Profiles & Presets…": "Importovat profily a přednastavení…", + "Import as new": "Importovat jako nové", + "Import from Device": "Importovat ze zařízení", + "Import from Folder…": "Importovat ze složky…", + "Import new photos": "Importovat nové fotografie", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… (Cmd+Shift+I) nebo je přetáhněte sem", + "Imported": "Importováno", + "Imported Photos": "Importované fotografie", + "Importing": "Importování", + "Importing Lightroom Catalog": "Importování katalogu Lightroom", + "Importing Lightroom catalog": "Import katalogu Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Import hledá ve zdroji fotografie; nepřidává složku do místních souborů (pro práci ve složce bez importu použijte Místní soubory → Procházet složku…).", + "In Color": "Barevně", + "In Photo Grid and Square Grid: Flag as Pick and advance": "V mřížce fotografií a čtvercové mřížce: nastavit příznak Vybráno a přejít dál", + "In Photo Grid and Square Grid: expand or collapse the stack": "V mřížce fotografií a čtvercové mřížce: rozbalit nebo sbalit sadu", + "In Recently Deleted": "V nedávno odstraněných", + "In Use": "Používá se", + "In a Stack": "V sadě", + "In library": "V knihovně", + "In memory — nothing is saved": "V paměti — nic není uloženo", + "In use": "Používané", + "In-memory session: these settings last until LightCraft quits.": "Relace v paměti: tato nastavení platí do ukončení LightCraft.", + "Include on Export": "Zahrnout do exportu", + "Include subfolders": "Zahrnout podsložky", + "Increase Brush Feather": "Zvětšit prolnutí štětce", + "Increase Brush Size": "Zvětšit velikost štětce", + "Increase Rating": "Zvýšit hodnocení", + "Index Faces": "Indexovat obličeje", + "Index Faces in the Background": "Indexovat obličeje na pozadí", + "Info": "Informace", + "Info (I)": "Informace (I)", + "Info Overlay Off": "Vypnout překrytí informací", + "Info Overlay: Exposure": "Překrytí informací: expozice", + "Info Overlay: File": "Překrytí informací: soubor", + "Info overlay": "Zobrazení informací přes snímek", + "Insert at the cursor": "Vložit na pozici kurzoru", + "Inspect Denoise Model": "Prozkoumat model pro redukci šumu", + "Inspect Face Model File": "Prozkoumat soubor modelu obličejů", + "Inspect Lightroom Catalog": "Prozkoumat katalog Lightroom", + "Inspect Photo": "Prozkoumat fotografii", + "Inspecting Lightroom Catalog": "Prohlížení katalogu Lightroom", + "Install Denoise Model": "Nainstalovat model pro redukci šumu", + "Install Face Model": "Nainstalovat model rozpoznávání obličejů", + "Install a denoise model to enable AI Denoise on this photo": "Chcete-li u této fotografie zapnout redukci šumu pomocí AI, nainstalujte model", + "Install from file…": "Nainstalovat ze souboru…", + "Install the SAM 3 Model": "Nainstalovat model SAM 3", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Nainstalujte vlastní model ONNX se souborem denoise-model.json vedle něj. Toto sestavení nenabízí stahování modelů.", + "Instant ’70s": "Instantní sedmdesátky", + "Intent:": "Záměr:", + "Interface": "Rozhraní", + "Interface language": "Jazyk rozhraní", + "Intersect": "Průnik", + "Intersect with": "Průnik s", + "Into one folder": "Do jedné složky", + "Invert": "Invertovat", + "Invert Component": "Invertovat součást", + "Invert Mask": "Invertovat masku", + "It tests this album, so testing it back would loop": "Testuje toto album, takže zpětný test vytvoří smyčku", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Obraz s redukovaným šumem ještě není vytvořen a obrazy se nevytvářejí automaticky (Nastavení, Redukce šumu pomocí AI).", + "JPEG (Large)": "JPEG (velký)", + "JPEG (Small)": "JPEG (malý)", + "Join the ArtCraft Discord": "Připojit se k Discordu ArtCraft", + "Join the ArtCraft Discord…": "Připojit se k Discordu ArtCraft…", + "Join the ArtCraft community on Discord": "Připojit se ke komunitě ArtCraft na Discordu", + "Keep in Local": "Ponechat v místních souborech", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Uložte je na disk s dostatkem místa: inteligentní náhledy zabírají přibližně 1 MB na fotografii. Mezipaměť miniatur zůstává v knihovně.", + "Keyboard Shortcuts": "Klávesové zkratky", + "Keyboard shortcuts": "Zkratky klávesnice", + "Keyword": "Klíčové slovo", + "Keyword Count": "Počet klíčových slov", + "Keyword List": "Seznam klíčových slov", + "Keyword Lists": "Seznamy klíčových slov", + "Keyword Name": "Název klíčového slova", + "Keyword Painter": "Malování klíčových slov", + "Keyword Set": "Sada klíčových slov", + "Keyword Sets": "Sady klíčových slov", + "Keyword Suggestions": "Návrhy klíčových slov", + "Keyword Tag Options": "Možnosti značky klíčového slova", + "Keyword to paint": "Klíčové slovo k nanášení", + "Keywords": "Klíčová slova", + "Keywords & People": "Klíčová slova a lidé", + "Keywords (K)": "Klíčová slova (K)", + "Keywords (⌥1–⌥9)": "Klíčová slova (⌥1–⌥9)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Zde se zobrazují přidaná klíčová slova; ⌥1–⌥9 je přiřadí.", + "LUT-based": "Na základě LUT", + "Label": "Štítek", + "Label Set": "Sada štítků", + "Landscape": "Krajina", + "Landscape (wide)": "Na šířku", + "Language": "Jazyk", + "Last": "Poslední", + "Last GPU fallback": "Poslední přechod z GPU na CPU", + "Last commit": "Poslední commit", + "Last loupe render": "Poslední vykreslení lupy", + "Last view": "Poslední zobrazení", + "Lateral Chromatic Aberration": "Příčná chromatická aberace", + "Layer": "Vrstva", + "Layers": "Vrstvy", + "Leave them": "Ponechat je", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "Vlevo: standardní rozsah (SDR) až po bílou SDR. Vpravo: rozsah HDR, jeden pruh pro každý expoziční stupeň nad bílou SDR (oranžová čára: rezerva HDR). Při vizualizaci rozsahu HDR přebírají pruhy její barvy.", + "Lens": "Objektiv", + "Lens Blur": "Rozostření objektivu", + "Lens Corrections": "Korekce objektivu", + "Level": "Úroveň", + "Library": "Knihovna", + "Library Folders": "Složky knihovny", + "Library Info": "Informace o knihovně", + "Library Originals": "Originály knihovny", + "Library Preferences": "Předvolby knihovny", + "Library State": "Stav knihovny", + "Library name": "Název knihovny", + "Library saved": "Knihovna byla uložena", + "Licence:": "Licence:", + "Light": "Světlo", + "Light Mode": "Světlý režim", + "Light Theme": "Světlý motiv", + "LightCraft Default": "Výchozí LightCraft", + "LightCraft Help": "Nápověda LightCraft", + "LightCraft Preset": "Přednastavení LightCraft", + "LightCraft Presets": "Přednastavení LightCraft", + "LightCraft Website": "Webové stránky LightCraft", + "LightCraft could not start": "LightCraft se nepodařilo spustit", + "LightCraft couldn't find where to keep your library.": "LightCraft nedokázal najít místo pro uložení knihovny.", + "LightCraft couldn't open the library at": "LightCraft nedokázal otevřít knihovnu v umístění", + "LightCraft does not know this model, so it assumed:": "LightCraft tento model nezná, a proto předpokládá:", + "LightCraft on GitHub": "LightCraft na GitHubu", + "LightCraft website": "Web LightCraft", + "LightCraft — Second Window": "LightCraft — druhé okno", + "Lightroom Classic Catalog": "Katalog Lightroom Classic", + "Lightroom import cancelled": "Import z Lightroom byl zrušen", + "Lightroom inspection cancelled": "Prohlížení katalogu Lightroom bylo zrušeno", + "Lights": "Světlé tóny", + "Lights Out": "Ztlumit okolí", + "Limit": "Limit", + "Limit file size (KB, 0 = off)": "Omezit velikost souboru (KB, 0 = vypnuto)", + "Line delta": "Změna počtu řádků", + "Linear": "Lineární", + "Linear Gradient": "Lineární přechod", + "Lines +/−": "Řádky +/−", + "Lines added": "Přidané řádky", + "Lines deleted": "Odstraněné řádky", + "List Albums": "Vypsat alba", + "List Develop Controls": "Vypsat ovládací prvky vyvolání", + "List History": "Vypsat historii", + "List Presets": "Vypsat přednastavení", + "List Profiles": "Vypsat profily", + "List this folder in Local from now on": "Od nynějška zobrazovat tuto složku v místních souborech", + "Loading preview…": "Načítání náhledu…", + "Local": "Místní soubory", + "Local folders": "Místní složky", + "Local library — no cloud account needed": "Místní knihovna — bez nutnosti cloudového účtu", + "Locate Missing File…": "Najít chybějící soubor…", + "Locate Photo": "Najít umístění fotografie", + "Location": "Místo", + "Lock": "Uzamknout", + "Lock Crop Aspect": "Uzamknout poměr stran ořezu", + "Locked": "Uzamčeno", + "Long Edge": "Delší hrana", + "Looking for changes": "Hledání změn", + "Looking for photos…": "Hledání fotografií…", + "Looking up places": "Vyhledávání míst", + "Lossless": "Bezeztrátové", + "Low": "Nízká", + "Luminance": "Jas", + "Luminance Range": "Rozsah jasu", + "Lush Greens": "Svěží zeleň", + "Magenta": "Purpurová", + "Magenta Hue": "Odstín purpurové", + "Magenta Luminance": "Jas purpurové", + "Magenta Saturation": "Sytost purpurové", + "Make Candidate the Select": "Nastavit kandidáta jako vybraný snímek", + "Make it now": "Vytvořit nyní", + "Make the denoised picture of the photos I am looking at": "Vytvořit obraz s redukovaným šumem u právě zobrazených fotografií", + "Manual": "Ručně", + "Marquee zoom": "Přiblížení výběrem oblasti", + "Mask Adjustment": "Úprava masky", + "Masking": "Maskování", + "Masking (M)": "Maskování (M)", + "Masks": "Masky", + "Match": "Shoda", + "Match Total Exposures": "Sjednotit celkovou expozici", + "Matrix/TRC": "Matice/TRC", + "Matte": "Matné", + "Matte Soft": "Jemné matné", + "Med": "Střední", + "Medium Contrast": "Střední kontrast", + "Megapixels": "Megapixely", + "Memory Budget": "Paměťový limit", + "Memory Usage": "Využití paměti", + "Memory budget": "Rozpočet paměti", + "Memory for caches": "Paměť pro mezipaměti", + "Menu": "Nabídka", + "Merge": "Sloučit", + "Merge Keywords": "Sloučit klíčová slova", + "Merge into…": "Sloučit do…", + "Merged": "Sloučeno", + "Merged PRs": "Sloučené PR", + "Merging": "Slučování", + "Merging…": "Slučování…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Jde o podmínky společnosti Meta, nikoli LightCraft. Stažením je přijímáte.", + "Metadata": "Metadata", + "Metadata Preset": "Přednastavení metadat", + "Metadata Presets": "Přednastavení metadat", + "Metadata preset": "Přednastavení metadat", + "Metadata read": "Načtená metadata", + "Midnight": "Půlnoc", + "Midpoint": "Střed", + "Midtones": "Střední tóny", + "Midtones Hue": "Odstín středních tónů", + "Midtones Luminance": "Jas středních tónů", + "Midtones Saturation": "Sytost středních tónů", + "Midtones split": "Rozdělení středních tónů", + "Minimize": "Minimalizovat", + "Missing Photos": "Chybějící fotografie", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Chybějící metadata ({camera}, {title}…) zanechají prázdné místo; pokud vyjde celý název prázdný, zachová se původní název.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Chybějící fotografie se přesunou do nedávno odstraněných a lze je obnovit; soubory na disku se nemění.", + "Misty Morning": "Mlžné ráno", + "Mo": "Po", + "Mode": "Režim", + "Model": "Model", + "Models": "Modely", + "Modified Date": "Datum změny", + "Monday": "Pondělí", + "Monitor profile": "Profil monitoru", + "Mono Red Filter": "Černobílé s červeným filtrem", + "Mono Rich": "Syté černobílé", + "Mono Soft": "Jemný monochromatický", + "Monochrome": "Monochromatické", + "Month": "Měsíc", + "Moody": "Náladové", + "Moody Portrait": "Náladový portrét", + "More": "Další", + "More preset options": "Další možnosti přednastavení", + "Move": "Přesunout", + "Move Album": "Přesunout album", + "Move Down": "Přesunout dolů", + "Move Down in Stack": "Přesunout v sadě dolů", + "Move Folder": "Přesunout složku", + "Move Folder To…": "Přesunout složku do…", + "Move Mask": "Přesunout masku", + "Move Preset to Group": "Přesunout přednastavení do skupiny", + "Move Preset to New Group": "Přesunout přednastavení do nové skupiny", + "Move Selected Photos Here": "Přesunout vybrané fotografie sem", + "Move Sidebar Section": "Přesunout sekci postranního panelu", + "Move Up": "Přesunout nahoru", + "Move Up in Stack": "Přesunout v sadě nahoru", + "Move in Stack": "Přesunout v sadě", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Přesunout soubory (do Originals/ v knihovně nebo do zvolené složky) a odstranit je ze zdroje", + "Move them": "Přesunout je", + "Move to": "Přesunout do", + "Move to Folder": "Přesunout do složky", + "Move to Folder…": "Přesunout do složky…", + "Move to Group": "Přesunout do skupiny", + "Move to group": "Přemístit do skupiny", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Přesunout: soubory se přesunou do složky níže; každý originál (i přidružený soubor XMP) se ze zdroje odstraní až po ověření kopie. Duplikáty a soubory, u kterých operace selže, zůstanou na místě.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Přesune všechny fotografie importované z tohoto disku do nedávno odstraněných; žádný soubor se nemění", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Přesune soubory a jejich přidružené XMP do této složky; nikdy nepřepisuje", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Přesune fotografie importované z této složky do nedávno odstraněných; žádný soubor se nemění", + "Muted": "Tlumené", + "Muted Film": "Tlumený film", + "My Photos": "Moje fotografie", + "My Photos Panel": "Panel mých fotografií", + "Name": "Název", + "Name (A–Z)": "Název (A–Z)", + "Name Face": "Pojmenovat obličej", + "Name Faces": "Pojmenovat obličeje", + "Name of the folder the original is in": "Název složky obsahující originál", + "Named": "Pojmenované", + "Named People": "Pojmenované osoby", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Názvy se zobrazují v nabídce štítků, liště filtrů a panelu Informace a zapisují se do XMP. Prázdné = název barvy.", + "Navigator": "Navigátor", + "Needs Work": "Vyžaduje úpravy", + "Neon Dusk": "Neonový soumrak", + "Neon Night": "Neonová noc", + "Nested group": "Vnořená skupina", + "Neutral": "Neutrální", + "Never": "Nikdy", + "New": "Nové", + "New Album": "Nové album", + "New Album…": "Nové album…", + "New Folder on Disk": "Nová složka na disku", + "New Folder on Disk…": "Nová složka na disku…", + "New Folder…": "Nová složka…", + "New Group…": "Nová skupina…", + "New Library": "Nová knihovna", + "New Library: Choose Where to Put It": "Nová knihovna: vyberte umístění", + "New Library…": "Nová knihovna…", + "New Smart Album": "Nové inteligentní album", + "New Smart Album from Filter…": "Nové inteligentní album z filtru…", + "New Smart Album…": "Nové inteligentní album…", + "New album": "Vytvořit nové album", + "New album…": "Vytvořit nové album…", + "New blank PDF": "Nové prázdné PDF", + "New name": "Nový název", + "New time": "Nový čas", + "New…": "Nový…", + "Next Appearance Mode": "Další režim vzhledu", + "Next Photo": "Další fotografie", + "Next up for AI Denoise…": "Další na řadě pro redukci šumu pomocí AI…", + "Night Blue": "Noční modř", + "No": "Ne", + "No Camera or Card Found": "Nenalezen fotoaparát ani karta", + "No Date Groups": "Žádné skupiny podle data", + "No Label": "Bez štítku", + "No Recent Libraries": "Žádné nedávné knihovny", + "No Stars": "Bez hvězdiček", + "No Tool": "Žádný nástroj", + "No albums match": "Žádné album neodpovídá", + "No albums yet": "Zatím žádná alba", + "No automatic versions.": "Žádné automatické verze.", + "No camera": "Žádný fotoaparát", + "No contributor data was built into this copy.": "Do této kopie nebyly vloženy údaje o přispěvatelích.", + "No date": "Bez data", + "No dust spots found": "Nenalezeny žádné prachové skvrny", + "No edits yet.": "Zatím žádné úpravy.", + "No exposure information": "Žádné informace o expozici", + "No filter presets yet": "Zatím žádná přednastavení filtrů", + "No folders to browse": "Žádné složky k procházení", + "No imported folders yet": "Zatím žádné importované složky", + "No keywords are exported": "Neexportují se žádná klíčová slova", + "No keywords match": "Žádné klíčové slovo neodpovídá", + "No keywords yet": "Zatím žádná klíčová slova", + "No lens data embedded in this file (DNG lens opcodes only).": "V tomto souboru nejsou vložena data objektivu (pouze operační kódy objektivu DNG).", + "No masks yet. Choose a mask type above.": "Zatím žádné masky. Vyberte typ masky nahoře.", + "No matching commands": "Žádné odpovídající příkazy", + "No matching photos": "Žádné odpovídající fotografie", + "No metadata found": "Nebyla nalezena metadata", + "No model credits were built into this copy.": "Do této kopie nebyly vloženy kredity modelů.", + "No named people in these photos": "Na těchto fotografiích nejsou pojmenované osoby", + "No named people yet": "Zatím žádné pojmenované osoby", + "No new presets": "Žádná nová přednastavení", + "No photo has it; the keywords below it go too.": "Není přiřazeno žádné fotografii; odstraní se i podřízená klíčová slova.", + "No photo selected": "Není vybrána fotografie", + "No photos": "Žádné fotografie", + "No photos found": "Nebyly nalezeny fotografie", + "No photos in this folder": "V této složce nejsou fotografie", + "No photos match the active filters": "Žádné fotografie neodpovídají aktivním filtrům", + "No photos match the filters (View → Clear Filters)": "Žádné fotografie neodpovídají filtrům (Zobrazení → Zrušit filtry)", + "No photos yet": "Zatím žádné fotografie", + "No raw photos yet: cameras appear here once their photos are in the library.": "Zatím žádné fotografie RAW: fotoaparáty se zde zobrazí, jakmile budou jejich fotografie v knihovně.", + "No reference photo": "Žádná referenční fotografie", + "No usable GPU found: rendering on the CPU": "Nebylo nalezeno použitelné GPU: vykreslování na CPU", + "No versions yet. Create one to keep this look.": "Zatím žádné verze. Vytvořením verze zachováte tento vzhled.", + "Noise": "Šum", + "Noise Reduction": "Redukce šumu", + "Non-raw photos": "Fotografie mimo RAW", + "None": "Žádné", + "None (the display is treated as sRGB)": "Žádný (displej se považuje za sRGB)", + "Not Now": "Teď ne", + "Not saved": "Neuloženo", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Dokud podmínky nepřijmete, nic se nestahuje. Model zůstane pouze na tomto počítači; LightCraft jej nikdy neodesílá ani nesdílí.", + "Nothing to compare": "Není co porovnat", + "Nothing to survey": "Není co prohlížet", + "Nudge Develop Value": "Posunout hodnotu vyvolání", + "OK": "OK", + "ONNX model": "Model ONNX", + "Object": "Objekt", + "Object Mask": "Maska objektu", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Masky Objekt a Popsat používají SAM 3, segmentační model společnosti Meta. Není součástí LightCraft a vše ostatní funguje i bez něj.", + "October 2026": "Říjen 2026", + "Off": "Vypnuto", + "Offset X": "Posun X", + "Offset Y": "Posun Y", + "Only": "Pouze", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Vytvářejí se pouze fotografie s mírou redukce šumu pomocí AI nad 0. Při exportu se chybějící obraz vždy vytvoří.", + "Opacity": "Krytí", + "Opacity (%)": "Krytí (%)", + "Open Library": "Otevřít knihovnu", + "Open Library…": "Otevřít knihovnu…", + "Open Log Folder": "Otevřít složku protokolů", + "Open Recent Library": "Otevřít nedávnou knihovnu", + "Open in": "Otevřít v", + "Open in Detail": "Otevřít v detailu", + "Open page": "Otevřít stránku", + "Open the model's page": "Otevřít stránku modelu", + "Open the place in OpenStreetMap": "Otevřít místo v OpenStreetMap", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Otevře vlastní stránku modelu v prohlížeči, kde si o něm můžete přečíst nebo sami stáhnout soubor.", + "Open…": "Otevřít…", + "Optics": "Optika", + "Optimize Library": "Optimalizovat knihovnu", + "Optional name": "Volitelný název", + "Options": "Možnosti", + "Orange": "Oranžová", + "Orange Hue": "Odstín oranžové", + "Orange Luminance": "Jas oranžové", + "Orange Saturation": "Sytost oranžové", + "Orientation": "Orientace", + "Original": "Originál", + "Original + Settings": "Originál a nastavení", + "Original extension without the dot": "Původní přípona bez tečky", + "Original file name without its extension": "Původní název souboru bez přípony", + "Original files": "Původní soubory", + "Original offline · editing the smart preview": "Originál není dostupný · úpravy inteligentního náhledu", + "Original offline · no smart preview": "Originál není dostupný · bez inteligentního náhledu", + "Originals": "Originály", + "Other editor": "Jiný editor", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Ostatní obrázky (JPEG, PNG, TIFF, HEIC…)", + "Other…": "Jiné…", + "Overlay": "Překrytí", + "Overwrite": "Přepsat", + "Pages": "Stránky", + "Paint": "Malovat", + "Paint only areas like the one under the brush": "Malovat pouze oblasti podobné té pod štětcem", + "Paint over a distraction on the photo to remove it.": "Přemalujte rušivý prvek na fotografii, aby byl odstraněn.", + "Paint overlay": "Překrytí malby", + "Panels": "Panely", + "Panorama": "Panorama", + "Panorama Merge": "Sloučit do panoramatu", + "Panorama Merge Preview": "Náhled sloučení do panoramatu", + "Panorama with Last Settings": "Panorama s posledním nastavením", + "Panoramas": "Panoramata", + "Panorama…": "Panorama…", + "Paper": "Papír", + "Parametric curve": "Parametrická křivka", + "Paste": "Vložit", + "Paste Edit Settings": "Vložit nastavení úprav", + "Paste Metadata": "Vložit metadata", + "Paste Selected Settings": "Vložit vybraná nastavení", + "Paste Selected Settings…": "Vložit vybraná nastavení…", + "Paste Settings": "Vložit nastavení", + "Paste Settings from Previous": "Vložit nastavení z předchozí fotografie", + "Paste from Previous": "Vložit z předchozí fotografie", + "Paste pages": "Vložit stránky", + "Pastel Haze": "Pastelový opar", + "Pending photo action cancelled": "Čekající akce s fotografií byla zrušena", + "Pending photo action cancelled because its library or photo changed": "Čekající akce byla zrušena, protože se změnila knihovna nebo fotografie", + "People": "Lidé", + "People Count": "Počet osob", + "Percent": "Procenta", + "Percentage": "Procentuálně", + "Perceptual": "Percepční", + "Performance": "Výkon", + "Person": "Osoba", + "Perspective": "Perspektiva", + "Pet Eye": "Oko zvířete", + "Pet Eye Catchlight": "Odlesk v oku zvířete", + "Photo": "Fotografie", + "Photo Grid": "Mřížka fotografií", + "Photo Grid (G)": "Mřížka fotografií (G)", + "Photo Merge": "Sloučení fotografií", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Fotografie → Upravit v → Upravit v PhotoCraft (⇧⌘E nebo pravé tlačítko na fotografii) vykreslí kopii s vašimi úpravami, přidá ji do sady s originálem a otevře v PhotoCraft. Po uložení se kopie zde aktualizuje. Do PSD ukládá PhotoCraft přímo. Umístění ponechte prázdné, pokud nepoužíváte přenosnou kopii.", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Fotografie → Upravit v externím editoru (⇧⌘E) vykreslí 16bitovou kopii TIFF, přidá ji do sady s originálem a otevře v této aplikaci (název aplikace v macOS, jinde cesta k programu).", + "PhotoCraft": "PhotoCraft", + "Photos": "Fotografie", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Fotografie se zobrazují přes ICC profil monitoru, aby byly barvy správné na kalibrovaných displejích i displejích se širokým gamutem. Detail využívá celý gamut displeje. Histogram, mezipaměti náhledů ani export se nemění.", + "Photos per bracket (0 = auto)": "Fotografií na expoziční řadu (0 = automaticky)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotografie prohlížené v místních souborech, které nebyly přidány ani změněny, se odeberou z katalogu, pokud se jejich složka po tuto dobu neprocházela (kontroluje se při otevření knihovny). Soubory zůstanou na disku; procházením složky se znovu zobrazí.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Fotografie zůstanou ve všech fotografiích. Odstraněním složky odstraníte i její alba. Akci lze vrátit zpět.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotografie přicházející do této složky (snímání s připojeným fotoaparátem, skener, synchronizace) se přidají, jakmile jsou úplné.", + "Pick": "Vybrat", + "Pick Flag": "Příznak Vybráno", + "Pick a colour on the photo with the eyedropper.": "Vyberte barvu na fotografii kapátkem.", + "Pick a date": "Vybrat datum", + "Pick the sharpest photo of each burst": "Vybrat nejostřejší fotografii z každé série", + "Picked": "Vybráno", + "Picks": "Vybrané snímky", + "Pictures": "Obrazy", + "Pixels": "Pixely", + "Place Names Status": "Stav názvů míst", + "Place names download cancelled": "Stahování názvů míst bylo zrušeno", + "Place names downloaded": "Názvy míst byly staženy", + "Point Color": "Bodová barva", + "Point Curve": "Bodová křivka", + "Point Curve Presets": "Přednastavení bodové křivky", + "Point curve": "Křivka s body", + "Portrait": "Portrét", + "Portrait (tall)": "Na výšku", + "Position": "Pozice", + "Preferences": "Předvolby", + "Preferences…": "Předvolby…", + "Prepare AI Masks": "Připravit masky pomocí AI", + "Preset": "Přednastavení", + "Preset name": "Název přednastavení", + "Presets": "Přednastavení", + "Presets & Profiles": "Přednastavení a profily", + "Presets (Shift+P)": "Přednastavení (Shift+P)", + "Presets ▾": "Přednastavení ↓", + "Press keys…": "Stiskněte klávesy…", + "Preview": "Náhled", + "Preview Build Progress": "Průběh vytváření náhledů", + "Preview only": "Pouze náhled", + "Preview only: editing the camera's embedded JPEG": "Pouze náhled: úpravy vloženého JPEG z fotoaparátu", + "Preview size": "Velikost náhledu", + "Preview unavailable": "Náhled není dostupný", + "Previews": "Náhledy", + "Previous Photo": "Předchozí fotografie", + "Primaries (x, y)": "Primární barvy (x, y)", + "Print": "Tisk", + "Print…": "Tisk…", + "Processor": "Procesor", + "Profile": "Profil", + "Profile Browser": "Prohlížeč profilů", + "Profile Distortion": "Zkreslení podle profilu", + "Profile Menu": "Nabídka profilů", + "Profile Vignetting": "Vinětace podle profilu", + "Profile:": "Profil:", + "Profiles": "Profily", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Profily přiřazené v nastavení barev vašeho prostředí obvykle najdete v ~/.local/share/icc.", + "Progressive": "Progresivní", + "Projection": "Projekce", + "Proof copy created": "Kopie pro měkký nátisk byla vytvořena", + "Properties": "Vlastnosti", + "Public Domain": "Volné dílo", + "Public domain": "Volné dílo", + "Pupil Size": "Velikost zornice", + "Purge Unused Keywords": "Odstranit nepoužívaná klíčová slova", + "Purple": "Fialová", + "Purple Amount": "Míra fialové", + "Purple Hue": "Odstín fialové", + "Purple Hue High": "Horní mez odstínu fialové", + "Purple Hue Low": "Dolní mez odstínu fialové", + "Purple Luminance": "Jas fialové", + "Purple Saturation": "Sytost fialové", + "Put New Keywords Inside This Keyword": "Vkládat nová klíčová slova pod toto klíčové slovo", + "Put the locations you removed from Local back (no files change)": "Vrátit místa odebraná z místních souborů (žádné soubory se nemění)", + "Quality": "Kvalita", + "Query Photos": "Vyhledat fotografie dotazem", + "Quick Collection": "Rychlá kolekce", + "Quick Develop": "Rychlé vyvolání", + "Quiet Green": "Klidná zeleň", + "Quit": "Ukončit", + "Quit Anyway": "Přesto ukončit", + "Quit LightCraft": "Ukončit LightCraft", + "Quit while tasks are running?": "Ukončit aplikaci při běžících úlohách?", + "Quit with unsaved changes?": "Ukončit aplikaci s neuloženými změnami?", + "Radial": "Kruhový", + "Radial Gradient": "Kruhový přechod", + "Radius": "Poloměr", + "Random": "Náhodně", + "Range": "Rozsah", + "Rate ★": "Ohodnotit ★", + "Rate ★ and advance": "Ohodnotit ★ a přejít dál", + "Rate ★★": "Ohodnotit ★★", + "Rate ★★ and advance": "Ohodnotit ★★ a přejít dál", + "Rate ★★★": "Ohodnotit ★★★", + "Rate ★★★ and advance": "Ohodnotit ★★★ a přejít dál", + "Rate ★★★★": "Ohodnotit ★★★★", + "Rate ★★★★ and advance": "Ohodnotit ★★★★ a přejít dál", + "Rate ★★★★★": "Ohodnotit ★★★★★", + "Rate ★★★★★ and advance": "Ohodnotit ★★★★★ a přejít dál", + "Rating": "Hodnocení", + "Rating cleared": "Hodnocení bylo zrušeno", + "Ratings & flags": "Hodnocení a příznaky", + "Raw": "RAW", + "Raw default": "Výchozí nastavení RAW", + "Raw defaults": "Výchozí nastavení souborů RAW", + "Raw files": "Soubory RAW", + "Raw photos": "Fotografie RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Fotografie RAW jako DNG s vloženými úpravami. Volby velikosti, barev a výstupu se nepoužijí.", + "Read Metadata from File": "Načíst metadata ze souboru", + "Read metadata updates from XMP sidecars": "Načíst změny metadat z přidružených souborů XMP", + "Read mode": "Režim čtení", + "Read the SAM License": "Přečíst licenci SAM", + "Reading": "Načítání", + "Reading Lightroom catalog": "Načítání katalogu Lightroom", + "Reading Lightroom catalog…": "Načítání katalogu Lightroom…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Načtení přidruženého souboru nahradí hodnocení, štítky, metadata a úpravy fotografie údaji z tohoto souboru.", + "Reading folder": "Načítání složky", + "Reading folder…": "Načítání složky…", + "Real name": "Skutečné jméno", + "Recent": "Nedávné", + "Recent Keywords": "Nedávná klíčová slova", + "Recent Keywords is built in: choose another name": "Nedávná klíčová slova je vestavěná sada: zvolte jiný název", + "Recent Libraries": "Nedávné knihovny", + "Recently Added": "Nedávno přidané", + "Recently Deleted": "Nedávno odstraněné", + "Recognise faces (experimental)": "Rozpoznávat obličeje (experimentální)", + "Recognition models": "Modely rozpoznávání", + "Red": "Červená", + "Red Eye": "Červené oči", + "Red Hue": "Odstín červené", + "Red Luminance": "Jas červené", + "Red Primary": "Primární červená", + "Red Saturation": "Sytost červené", + "Red channel": "Červený kanál", + "Red label": "Červený štítek", + "Red/Cyan Fringe": "Červený/azurový lem", + "Redo": "Znovu", + "Reference": "Reference", + "Reference View": "Referenční zobrazení", + "Reference the files where they are": "Odkazovat na soubory v jejich umístění", + "Refine": "Zpřesnit", + "Refine AI Mask Detail": "Zpřesnit detaily masky AI", + "Refine Edges": "Zpřesnit okraje", + "Refine Mask Edges": "Zpřesnit okraje masky", + "Refine Saturation": "Zpřesnit sytost", + "Refining the mask's detail…": "Zpřesňování detailů masky…", + "Refresh Source": "Obnovit zdroj", + "Refresh Source (/)": "Obnovit zdroj (/)", + "Reject": "Odmítnout", + "Reject below focus": "Odmítnout pod limitem zaostření", + "Rejected": "Odmítnuto", + "Rejects": "Odmítnuté snímky", + "Relative": "Relativní", + "Relink Photo": "Znovu propojit fotografii", + "Relink photos whose file was renamed or moved": "Znovu propojit fotografie s přejmenovaným nebo přesunutým souborem", + "Relinked": "Znovu propojeno", + "Reload Changed Files": "Znovu načíst změněné soubory", + "Reload from Disk": "Znovu načíst z disku", + "Remove": "Odebrat", + "Remove (H)": "Odstranit (H)", + "Remove CA": "Odstranit chromatickou aberaci", + "Remove Chromatic Aberration": "Odstranit chromatickou aberaci", + "Remove Denoise Model": "Odebrat model pro redukci šumu", + "Remove Disk from Library": "Odebrat disk z knihovny", + "Remove Disk from Library…": "Odebrat disk z knihovny…", + "Remove Face Box": "Odebrat rámeček obličeje", + "Remove Face Model": "Odebrat model rozpoznávání obličejů", + "Remove Folder from Library": "Odebrat složku z knihovny", + "Remove a filter above, or choose Clear all": "Odeberte filtr nahoře nebo zvolte Vymazat vše", + "Remove from Album": "Odebrat z alba", + "Remove from Favorites": "Odebrat z oblíbených", + "Remove from Library…": "Odebrat z knihovny…", + "Remove from Local": "Odebrat z místních souborů", + "Remove from Photo": "Odebrat z fotografie", + "Remove from Selected Photos": "Odebrat z vybraných fotografií", + "Remove from Stack": "Odebrat ze sady", + "Remove from Target Album": "Odebrat z cílového alba", + "Remove from favorites": "Odebrat z oblíbených", + "Remove from survey": "Odebrat z přehledu", + "Remove location info": "Odebrat informace o poloze", + "Remove missing photos from the library": "Odebrat chybějící fotografie z knihovny", + "Remove shortcut": "Odebrat zkratku", + "Remove this face box (undo with Edit ▸ Undo)": "Odebrat tento rámeček obličeje (vrátit přes Úpravy → Zpět)", + "Remove this filter": "Odebrat tento filtr", + "Remove this group": "Odebrat tuto skupinu", + "Remove this rule": "Odebrat toto pravidlo", + "Removed": "Odebráno", + "Rename": "Přejmenovat", + "Rename Album": "Přejmenovat album", + "Rename Component": "Přejmenovat součást", + "Rename Folder": "Přejmenovat složku", + "Rename Folder…": "Přejmenovat složku…", + "Rename Keyword": "Přejmenovat klíčové slovo", + "Rename Keyword…": "Přejmenovat klíčové slovo…", + "Rename Mask": "Přejmenovat masku", + "Rename Photos": "Přejmenovat fotografie", + "Rename Photos…": "Přejmenovat fotografie…", + "Rename Preset": "Přejmenovat přednastavení", + "Rename Preview": "Náhled přejmenování", + "Rename Template Tags": "Značky šablony přejmenování", + "Rename Version": "Přejmenovat verzi", + "Rename…": "Přejmenovat…", + "Rendering": "Vykreslování", + "Rendering…": "Vykreslování…", + "Reorder Album": "Změnit pořadí alba", + "Reorder Masks": "Změnit pořadí masek", + "Reset": "Obnovit", + "Reset All": "Obnovit vše", + "Reset All Curves": "Obnovit všechny křivky", + "Reset All Keyboard Shortcuts": "Obnovit všechny klávesové zkratky", + "Reset Before to Import State": "Obnovit Před do stavu při importu", + "Reset Control": "Obnovit ovládací prvek", + "Reset Crop": "Obnovit oříznutí", + "Reset Edits": "Obnovit úpravy", + "Reset Panel Layout": "Obnovit rozložení panelů", + "Reset Parametric Curve": "Obnovit parametrickou křivku", + "Reset Section": "Obnovit sekci", + "Reset Sidebar Sections": "Obnovit sekce postranního panelu", + "Reset Tone Curve": "Obnovit tónovou křivku", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Obnoví všechny křivky: bodové (všechny kanály) i parametrickou (dvojklik na kanál obnoví pouze tento kanál)", + "Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)", + "Reshuffle": "Znovu promíchat", + "Resize Face Box": "Změnit velikost rámečku obličeje", + "Resize panel window": "Změnit velikost okna panelu", + "Resize panels": "Změnit velikost panelů", + "Resize to": "Změnit velikost na", + "Resolution (ppi)": "Rozlišení (ppi)", + "Restore": "Obnovit", + "Restore (no shortcut)": "Obnovit (bez zkratky)", + "Restore Library from Backup…": "Obnovit knihovnu ze zálohy…", + "Restore Version": "Obnovit verzi", + "Restore keeps their edits; Import as new deletes the old record first.": "Obnovení zachová úpravy; import jako nové nejprve odstraní starý záznam.", + "Restore them": "Obnovit je", + "Retouch": "Retušování", + "Reverse the order": "Obrátit pořadí", + "Revert": "Vrátit změny", + "Review": "Zkontrolovat", + "Review Import": "Zkontrolovat import", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Pravé tlačítko na fotografii → Nastavit jako referenční fotografii, pak Zobrazení → Referenční zobrazení (Shift+R)", + "Right-click a preset to delete it": "Přednastavení odstraníte kliknutím pravým tlačítkem", + "Rights Usage Terms": "Podmínky použití práv", + "Rotate": "Otočit", + "Rotate Crop": "Otočit ořez", + "Rotate Crop Aspect": "Otočit poměr stran ořezu", + "Rotate Left": "Otočit doleva", + "Rotate Right": "Otočit doprava", + "Roughness": "Drsnost", + "Roundness": "Kulatost", + "Rules": "Pravidla", + "SAM 3 download stopped: it resumes where it left off next time.": "Stahování SAM 3 bylo zastaveno: příště naváže tam, kde skončilo.", + "SDR": "SDR", + "Sa": "So", + "Same as raw default": "Stejné jako výchozí nastavení RAW", + "Sample Mask Color": "Odebrat vzorek barvy masky", + "Sample a colour on the photo": "Vyberte vzorek barvy na fotografii", + "Saturation": "Sytost", + "Saturation Range": "Rozsah sytosti", + "Saturday": "Sobota", + "Save": "Uložit", + "Save As…": "Uložit jako…", + "Save Color Label Set": "Uložit sadu barevných štítků", + "Save Current Filter as Preset…": "Uložit aktuální filtr jako přednastavení…", + "Save Current Keywords as Set…": "Uložit aktuální klíčová slova jako sadu…", + "Save Export Preset": "Uložit přednastavení exportu", + "Save Filter Preset": "Uložit přednastavení filtru", + "Save Keyword Set": "Uložit sadu klíčových slov", + "Save Metadata Preset": "Uložit přednastavení metadat", + "Save Metadata Preset…": "Uložit přednastavení metadat…", + "Save Metadata to File": "Uložit metadata do souboru", + "Save Point Curve Preset": "Uložit přednastavení bodové křivky", + "Save Point Curve…": "Uložit bodovou křivku…", + "Save as Preset": "Uložit jako přednastavení", + "Save as Set…": "Uložit jako sadu…", + "Save as Smart Album…": "Uložit jako inteligentní album…", + "Save as a new set": "Uložit jako novou sadu", + "Save as set": "Uložit jako sadu", + "Save as…": "Uložit jako…", + "Save preset": "Uložit přednastavení", + "Saving Lightroom import index…": "Ukládání indexu importu z Lightroom…", + "Scale": "Měřítko", + "Scan failed": "Prohledávání selhalo", + "Scanning": "Prohledávání", + "Scanning folder": "Prohledávání složky", + "Scanning…": "Prohledávání…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Skóre zůstávají u fotografií: můžete filtrovat nebo tvořit inteligentní alba podle zaostření a nejlepších z podobných snímků.", + "Screen": "Obrazovka", + "Scroll menu down": "Posunout nabídku dolů", + "Scroll menu up": "Posunout nabídku nahoru", + "Search": "Hledat", + "Search Photos": "Hledat fotografie", + "Search albums": "Hledat alba", + "Search commands or keys": "Hledat příkazy nebo klávesy", + "Search…": "Hledat…", + "Seasons": "Roční období", + "Second Window": "Druhé okno", + "Select": "Vybrat", + "Select All": "Vybrat vše", + "Select Luminance Range": "Vybrat rozsah jasu", + "Select Mask": "Vybrat masku", + "Select Photos": "Vybrat fotografie", + "Select Spot": "Vybrat retušovanou oblast", + "Select a photo to edit": "Vyberte fotografii k úpravám", + "Select by": "Vybrat podle", + "Select by Flag, Rating or Label": "Vybrat podle příznaku, hodnocení nebo štítku", + "Select photos to give them keywords.": "Vyberte fotografie, kterým chcete přiřadit klíčová slova.", + "Select photos, then choose View → Survey (N)": "Vyberte fotografie a zvolte Zobrazení → Přehled (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Vyberte dvě fotografie a zvolte Zobrazení → Porovnat (Shift+C)", + "Selected": "Vybrané", + "Selecting…": "Vybírání…", + "Selenium Tone": "Selenové tónování", + "Send Feedback…": "Odeslat zpětnou vazbu…", + "Separated by commas": "Oddělené čárkami", + "Sepia Tone": "Sépiové tónování", + "Sequence number zero-padded to N digits (1–9)": "Pořadové číslo doplněné nulami na N číslic (1–9)", + "Sequence number, counting from the start number": "Pořadové číslo od počáteční hodnoty", + "Services": "Služby", + "Set": "Nastavit", + "Set Album Cover": "Nastavit obálku alba", + "Set Before": "Nastavit Před", + "Set Color Label": "Nastavit barevný štítek", + "Set Crop": "Nastavit oříznutí", + "Set Develop Values": "Nastavit hodnoty vyvolání", + "Set Flag": "Nastavit příznak", + "Set Image Zoom and Pan": "Nastavit přiblížení a posun obrázku", + "Set Keyboard Shortcut": "Nastavit klávesovou zkratku", + "Set Mask Adjustments": "Nastavit úpravy masky", + "Set Point Curve": "Nastavit bodovou křivku", + "Set Profile": "Nastavit profil", + "Set Rating": "Nastavit hodnocení", + "Set Top of Stack": "Nastavit vrchní fotografii sady", + "Set as Album Cover": "Nastavit jako obálku alba", + "Set as Before": "Nastavit jako Před", + "Set as Reference Photo": "Nastavit jako referenční fotografii", + "Set as Target Album": "Nastavit jako cílové album", + "Set as Target Album (B adds to it)": "Nastavit jako cílové album (B do něj přidává)", + "Set as Top of Stack": "Nastavit jako vrchní fotografii sady", + "Set date & time": "Nastavit datum a čas", + "Set name": "Název sady", + "Set the mix from the photo's colours": "Nastavit směs podle barev fotografie", + "Set up AI Denoise…": "Nastavit redukci šumu pomocí AI…", + "Settings": "Nastavení", + "Settings file problem": "Problém souboru nastavení", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Výchozí nastavení nových fotografií RAW. Jejich změna neovlivní fotografie, které již jsou v knihovně.", + "Settings pasted": "Nastavení bylo vloženo", + "Settings to include": "Zahrnout nastavení", + "Settings…": "Nastavení…", + "Shade": "Ve stínu", + "Shadow": "Stín", + "Shadows": "Stíny", + "Shadows Hue": "Odstín stínů", + "Shadows Luminance": "Jas stínů", + "Shadows Saturation": "Sytost stínů", + "Shadows Tint": "Zabarvení stínů", + "Shadows split": "Rozdělení stínů", + "Sharpen": "Doostřit", + "Sharpening": "Doostření", + "Sharpness": "Ostrost", + "Shift": "Posun", + "Shift by": "Posunout o", + "Short Edge": "Kratší hrana", + "Show": "Zobrazit", + "Show All": "Zobrazit vše", + "Show Clipping": "Zobrazit ořezání tónů", + "Show Deghost Overlay": "Zobrazit překrytí odstranění duchů", + "Show Hidden Local Locations": "Zobrazit skrytá místní umístění", + "Show Luminance Map": "Zobrazit mapu jasu", + "Show Mask": "Zobrazit masku", + "Show Mask Overlay": "Zobrazit překrytí masky", + "Show Mask Pins": "Zobrazit body masek", + "Show Original": "Zobrazit originál", + "Show Person": "Zobrazit osobu", + "Show Photo Counts": "Zobrazit počty fotografií", + "Show Photos with Keyword": "Zobrazit fotografie s klíčovým slovem", + "Show Pins": "Zobrazit body", + "Show Sidebar Section": "Zobrazit sekci postranního panelu", + "Show Source": "Zobrazit zdroj", + "Show Thumbnails": "Zobrazit miniatury", + "Show commands without a shortcut": "Zobrazit příkazy bez zkratky", + "Show destination gamut warning": "Zobrazit varování gamutu cíle", + "Show display gamut warning": "Zobrazit varování gamutu displeje", + "Show file names": "Zobrazit názvy souborů", + "Show in Explorer": "Zobrazit v Průzkumníku", + "Show in File Manager": "Zobrazit ve správci souborů", + "Show in Finder": "Zobrazit ve Finderu", + "Show less": "Zobrazit méně", + "Show on Map": "Zobrazit na mapě", + "Show overlay (O)": "Zobrazit překrytí (O)", + "Show ratings, flags and edit badges": "Zobrazit hodnocení, příznaky a odznaky úprav", + "Show the Navigator while zoomed in": "Zobrazit navigátor při přiblížení", + "Show the template tags; click one to insert it": "Zobrazit značky šablony; kliknutím jednu vložíte", + "Show/Hide Mask": "Zobrazit/Skrýt masku", + "Show/hide My Photos panel": "Zobrazit/Skrýt panel mých fotografií", + "Shutter Speed": "Čas závěrky", + "Shutter speed": "Expoziční čas", + "Side panels": "Boční panely", + "Sidebar Sections": "Sekce postranního panelu", + "Sidecar names": "Názvy přidružených souborů", + "Silver": "Stříbrné", + "Simulate Paper & Ink": "Simulovat papír a inkoust", + "Size": "Velikost", + "Size (% of short edge)": "Velikost (% kratší hrany)", + "Size limit": "Limit velikosti", + "Skip": "Přeskočit", + "Skipped: files already in the library elsewhere": "Přeskočeno: soubory již v knihovně na jiném místě", + "Skipped: files that can't be read": "Přeskočeno: soubory, které nelze načíst", + "Sky": "Obloha", + "Slideshow": "Prezentace", + "Slideshow paused": "Prezentace byla pozastavena", + "Slideshow resumed": "Prezentace pokračuje", + "Slideshow · Space pauses · Esc ends": "Prezentace · mezerník pozastaví · Esc ukončí", + "Smart Album": "Inteligentní album", + "Smart Album Rule Fields": "Pole pravidel inteligentního alba", + "Smart Preview Status": "Stav inteligentních náhledů", + "Smart Previews Location": "Umístění inteligentních náhledů", + "Smart preview available": "Inteligentní náhled je dostupný", + "Smart previews": "Inteligentní náhledy", + "Smart previews folder changed": "Složka inteligentních náhledů byla změněna", + "Smoothness": "Plynulost", + "Soft B&W": "Jemné černobílé", + "Soft Pastel": "Jemné pastelové", + "Soft Proofing": "Měkký nátisk", + "Soft Skin": "Jemná pleť", + "Some rules need fixing (Edit Smart Album…):": "Některá pravidla je třeba opravit (Upravit inteligentní album…):", + "Sort": "Seřadit", + "Sort Albums A–Z": "Seřadit alba A–Z", + "Sort Albums by Name": "Seřadit alba podle názvu", + "Sort Contents A–Z": "Seřadit obsah A–Z", + "Sort by": "Řadit podle", + "Source": "Zdroj", + "Source code on GitHub": "Zdrojový kód na GitHubu", + "Speed": "Rychlost", + "Spherical": "Sférická", + "Spiral": "Spirála", + "Split Stack": "Rozdělit sadu", + "Spring Fresh": "Jarní svěžest", + "Square": "Čtverec", + "Square Grid": "Čtvercová mřížka", + "Square Grid (G toggles)": "Čtvercová mřížka (G přepíná)", + "Square Grid: show file names and formats": "Čtvercová mřížka: zobrazit názvy souborů a formáty", + "Stack": "Sada", + "Stack photos taken within this time of each other:": "Seskupit fotografie pořízené v tomto časovém odstupu:", + "Stacks": "Sady", + "Standard": "Standardní", + "Star rating (0–5)": "Hodnocení hvězdičkami (0–5)", + "Start at": "Začít od", + "Start number": "Počáteční číslo", + "Starting": "Spouštění", + "Starting…": "Spouštění…", + "Startup": "Spuštění", + "State / Province": "Kraj / provincie", + "Stop": "Zastavit", + "Stop Building Previews": "Zastavit vytváření náhledů", + "Stop Using as Target Album": "Přestat používat jako cílové album", + "Stopped": "Zastaveno", + "Stopped by an error (see the log)": "Zastaveno kvůli chybě (viz protokol)", + "Stopping…": "Zastavování…", + "Straighten": "Narovnat", + "Straighten Tool": "Nástroj narovnání", + "Strong Contrast": "Silný kontrast", + "Style": "Styl", + "Su": "Ne", + "Subfolder": "Podsložka", + "Subfolder name": "Název podsložky", + "Subject": "Subjekt", + "Subtract": "Odečíst", + "Subtract from this mask": "Odečíst z této masky", + "Suggest Names for Faces": "Navrhnout jména obličejů", + "Suggestions": "Návrhy", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Navrhuje osoby na fotografii podle obličejů, které jste pojmenovali. Vše zůstává na vašem počítači.", + "Summer Haze": "Letní opar", + "Sunday": "Neděle", + "Survey": "Přehled", + "Survey (N)": "Přehled (N)", + "Swap": "Prohodit", + "Swap Aspect (X)": "Prohodit poměr stran (X)", + "Swap Before and After": "Prohodit Před a Po", + "Swap Before and After Settings": "Prohodit nastavení Před a Po", + "Swap Compare Photos": "Prohodit porovnávané fotografie", + "Switch light / dark theme": "Přepnout světlý/tmavý motiv", + "Sync Settings": "Synchronizovat nastavení", + "Sync with System": "Synchronizovat se systémem", + "Synchronize": "Synchronizovat", + "Synchronize Folder": "Synchronizovat složku", + "Synchronize Folder…": "Synchronizovat složku…", + "Synchronizing folder": "Synchronizace složky", + "Synchronizing…": "Synchronizace…", + "Synonyms": "Synonyma", + "System": "Systém", + "System Info": "Informace o systému", + "System Info…": "Informace o systému…", + "System default": "Výchozí systémové nastavení", + "Table": "Tabulka", + "Tags": "Značky", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Značky: viz Značky vedle šablony. Soubory se přejmenují na disku (s přidruženými XMP); existující názvy získají -1, -2…", + "Take a snapshot": "Pořídit snímek stavu", + "Targeted Adjustment": "Cílená úprava", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Cílená úprava: tažením nahoru/dolů po fotografii upravíte barvy v daném místě", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Cílená úprava: tažením nahoru/dolů po fotografii upravíte křivku v daném místě", + "Teal & Amber": "Petrolejová a jantarová", + "Teal & Orange": "Petrolejová a oranžová", + "Temp": "Teplota", + "Temperature": "Teplota", + "Template": "Šablona", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Dočasná relace — vaše knihovna není otevřená a nic, co zde uděláte, se neukládá.", + "Test Denoise Model": "Otestovat model pro redukci šumu", + "Test Face Model": "Otestovat model rozpoznávání obličejů", + "Tested and works on this computer": "Otestováno a funkční na tomto počítači", + "Text": "Text", + "Text direction": "Směr textu", + "Texture": "Textura", + "Th": "Čt", + "That includes the photos in the folders inside it.": "Zahrnuje i fotografie v podsložkách.", + "That key can't be used as a shortcut": "Tuto klávesu nelze použít jako zkratku", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 je nainstalovaný: masky Objekt a Popsat jsou připravené.", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model pro redukci šumu je nainstalovaný a používá se: posuvník Míra redukce šumu pomocí AI v Detailu nyní funguje", + "The download didn't work:": "Stažení se nezdařilo:", + "The first time, macOS asks whether LightCraft may control Photos.": "Při prvním použití se macOS zeptá, zda LightCraft smí ovládat Photos.", + "The graphics card is set up when the next photo is made.": "Grafická karta se nastaví při zpracování další fotografie.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Import načte katalog bez jeho změny a přidá fotografie, úpravy, klíčová slova a kolekce do právě otevřené knihovny.", + "The library is up to date with this folder.": "Knihovna odpovídá aktuálnímu obsahu této složky.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model zůstane pouze na tomto počítači. LightCraft jej nikdy neodesílá ani nesdílí.", + "The number at the end of the original name (IMG_0042 → 0042)": "Číslo na konci původního názvu (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Původní přípona se vždy přidá; {ext} ji navíc vloží i do názvu.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Původní soubory beze změny, každý s přidruženým XMP obsahujícím úpravy.", + "The photo has these settings": "Fotografie má tato nastavení", + "The processor does the work.": "Zpracování provádí procesor.", + "Their photos and the keywords below them come together under one keyword.": "Jejich fotografie i podřízená klíčová slova se spojí pod jedním klíčovým slovem.", + "Theme": "Motiv", + "There is no model file to install": "Není k dispozici soubor modelu k instalaci", + "They can be restored from Recently Deleted until it is emptied.": "Lze je obnovit z nedávno odstraněných, dokud se tato sekce nevyprázdní.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Přesunou se do nedávno odstraněných a lze je obnovit; soubory na disku se nemění.", + "Thirds": "Třetiny", + "This Computer": "Tento počítač", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Toto sestavení zatím neumí model stáhnout. Pro ruční instalaci vložte model.safetensors, vocab.json a merges.txt z facebook/sam3 společnosti Meta do složky výše: Objekt a Popsat budou fungovat, jakmile tam soubory budou.", + "This build cannot run denoise models.": "Toto sestavení neumí spouštět modely pro redukci šumu.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Toto sestavení neumí spouštět modely rozpoznávání: lze je přidat a vybrat, ale nelze je použít.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Toto sestavení nemá kam ukládat modely pro redukci šumu, takže redukce šumu pomocí AI není dostupná. (Desktopová aplikace tuto možnost má.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Toto sestavení nemá kam ukládat modely obličejů, takže seznam modelů není dostupný. (Desktopová aplikace tuto možnost má.)", + "This file cannot be used yet": "Tento soubor zatím nelze použít", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Tato složka není dostupná (odpojený disk?). Inteligentní náhledy se nevytvářejí ani nepoužívají, dokud nebude znovu dostupná nebo nezvolíte jinou složku.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Tato složka neexistuje: byla přesunuta či přejmenována nebo její disk není připojený.", + "This group is empty: add a rule or remove it.": "Tato skupina je prázdná: přidejte pravidlo nebo ji odeberte.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Toto je katalog Lightroom, nikoli knihovna LightCraft. Chcete jej místo toho importovat do knihovny LightCraft?", + "This is the album you're editing": "Toto je album, které právě upravujete", + "This photo can't be opened": "Tuto fotografii nelze otevřít", + "Threshold": "Práh", + "Thumbnail cache": "Mezipaměť miniatur", + "Thursday": "Čtvrtek", + "Tick the box to accept the model's terms first": "Nejprve zaškrtnutím přijměte podmínky modelu", + "Time zone": "Časové pásmo", + "Tint": "Odstín", + "Title": "Titul", + "Title metadata": "Metadata titulu", + "Toggle Keyword from Set": "Přepnout klíčové slovo ze sady", + "Toggle Mask": "Přepnout masku", + "Toggle Section": "Přepnout sekci", + "Toggle Zoom": "Přepnout přiblížení", + "Tone Curve": "Tónová křivka", + "Tools": "Nástroje", + "Top Level": "Nejvyšší úroveň", + "Transfer": "Přenos", + "Treatment": "Zpracování", + "Triangle": "Trojúhelník", + "Try Again": "Zkusit znovu", + "Try Saving Again": "Zkusit uložit znovu", + "Try again": "Opakovat pokus", + "Try the graphics card again": "Znovu zkusit grafickou kartu", + "Tu": "Út", + "Tuesday": "Úterý", + "Tungsten": "Žárovka", + "Turn Off": "Vypnout", + "Turn off": "Vypnout", + "Turn on Include subfolders, or pick another folder under Local": "Zapněte Zahrnout podsložky nebo vyberte jinou složku v místních souborech", + "Type": "Typ", + "Uncheck All": "Zrušit zaškrtnutí všeho", + "Uncheck the files of this type": "Zrušit zaškrtnutí souborů tohoto typu", + "Undo": "Zpět", + "Unedited": "Bez úprav", + "Unedited raws": "Neupravené soubory RAW", + "Unflag": "Zrušit příznak", + "Unflagged": "Bez příznaku", + "Ungroup Stack": "Zrušit seskupení sady", + "Unknown": "Neznámé", + "Unknown Date": "Neznámé datum", + "Unknown licence": "Neznámá licence", + "Unknown tags stay as typed — check the preview for a {typo}.": "Neznámé značky zůstanou tak, jak jsou napsané — zkontrolujte náhled, zda neobsahuje {typo}.", + "Unnamed Faces": "Nepojmenované obličeje", + "Unrated": "Bez hodnocení", + "Unreadable": "Nečitelné", + "Update": "Aktualizovat", + "Update Mask Shape": "Aktualizovat tvar masky", + "Update Preset with Current Settings": "Aktualizovat přednastavení podle aktuálního nastavení", + "Update Rules from Current Filter": "Aktualizovat pravidla podle aktuálního filtru", + "Update Version": "Aktualizovat verzi", + "Update Version with Current Settings": "Aktualizovat verzi podle aktuálního nastavení", + "Update with Current Settings": "Aktualizovat podle aktuálního nastavení", + "Updated the edits saved in the external editor": "Úpravy uložené v externím editoru byly aktualizovány", + "Upright": "Narovnání perspektivy", + "Urban": "Městská krajina", + "Use": "Použít", + "Use Keyword Set": "Použít sadu klíčových slov", + "Use Library Folder": "Použít složku knihovny", + "Use Screen Profile": "Použít profil obrazovky", + "Use camera-specific defaults": "Použít výchozí nastavení pro konkrétní fotoaparát", + "Use sRGB": "Použít sRGB", + "Use the GPU for rendering": "Použít GPU pro vykreslování", + "User Presets": "Uživatelská přednastavení", + "Username": "Uživatelské jméno", + "Value": "Hodnota", + "Variance": "Rozptyl", + "Version": "Verze", + "Version name": "Název verze", + "Versions": "Verze", + "Versions (Shift+V)": "Verze (Shift+V)", + "Vertical": "Svisle", + "Vertical inset (% of short edge)": "Svislé odsazení (% kratší hrany)", + "Vertical text": "Svislý text", + "Vibrance": "Živost", + "Video": "Video", + "Video Duration": "Délka videa", + "Videos": "Videa", + "View": "Zobrazení", + "Vignette": "Viněta", + "Vignetting": "Vinětace", + "Vintage": "Retro", + "Virtual Copy": "Virtuální kopie", + "Visible": "Viditelné", + "Visualize Spots": "Zvýraznit retušované oblasti", + "Visualize Spots (A)": "Zvýraznit retušované oblasti (A)", + "Visualize range": "Zobrazit rozsah", + "Vivid": "Živé", + "Vivid Pop": "Živé zvýraznění", + "Wait for the import to finish": "Počkejte na dokončení importu", + "Waiting to start…": "Čekání na spuštění…", + "Warm Film": "Teplý film", + "Warm Glow": "Teplá záře", + "Warm Print": "Teplý tisk", + "Warm Table": "Teplý stůl", + "Watched folder": "Sledovaná složka", + "Watermark": "Vodoznak", + "We": "St", + "Wednesday": "Středa", + "What's New": "Co je nového", + "When rated or hovered": "Při hodnocení nebo najetí myší", + "White Balance": "Vyvážení bílé", + "White Balance Selector": "Kapátko vyvážení bílé", + "White Balance Selector (W)": "Kapátko vyvážení bílé (W)", + "Whites": "Bílé", + "Width": "Šířka", + "Width (% of photo)": "Šířka (% fotografie)", + "Width (px)": "Šířka (px)", + "Width × Height": "Šířka × výška", + "Will Export": "Bude exportováno", + "Will be replaced": "Bude nahrazeno", + "Will be restored": "Bude obnoveno", + "Window": "Okno", + "Winter Blue": "Zimní modř", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funguje se soubory RAW z fotoaparátů se standardním snímačem (Bayer). Ostatní soubory si zachovají běžnou redukci šumu.", + "XMP Sidecar Preferences": "Předvolby přidružených souborů XMP", + "XMP sidecars": "Přidružené soubory XMP", + "Year": "Rok", + "Yellow": "Žlutá", + "Yellow Hue": "Odstín žluté", + "Yellow Luminance": "Jas žluté", + "Yellow Saturation": "Sytost žluté", + "Yellow label": "Žlutý štítek", + "Yes": "Ano", + "You can close this: the download continues, and resumes if interrupted.": "Toto okno můžete zavřít: stahování pokračuje a po přerušení naváže.", + "Your Name": "Vaše jméno", + "Your library couldn't be opened": "Knihovnu se nepodařilo otevřít", + "Zoom": "Přiblížení", + "Zoom 100%": "Přiblížení 100%", + "Zoom In": "Přiblížit", + "Zoom Out": "Oddálit", + "Zoom to Fit": "Přizpůsobit přiblížení", + "Zoom to page level": "Přiblížit na úroveň stránky", + "a literal %": "literální znak %", + "a removed photo": "odebraná fotografie", + "all": "všechna", + "and": "a", + "any": "libovolné", + "are empty": "jsou prázdné", + "aren't empty": "nejsou prázdné", + "blue": "modrá", + "cached picture": "Obrazy v mezipaměti", + "cached pictures": "Obrazy v mezipaměti", + "can't be tested that way": "nelze takto testovat", + "can't match any rating from 0 to 5": "neodpovídá žádnému hodnocení od 0 do 5", + "can't test a folder; choose an album in it": "nelze testovat složku; vyberte album v ní", + "comma, separated": "oddělené, čárkou", + "commercial use allowed": "komerční použití povoleno", + "commercial use subject to GPL conditions": "komerční použití podle podmínek GPL", + "contains": "obsahuje", + "copyrighted": "chráněno autorskými právy", + "day 01–31": "den 01–31", + "days": "dny", + "denoiseModel.accept": "denoiseModel.accept", + "discarding smart previews": "odstraňování inteligentních náhledů", + "doesn't contain": "neobsahuje", + "e.g. sky · the red car · car, road": "např. obloha · červené auto · auto, silnice", + "ends with": "končí na", + "false": "nepravda", + "green": "zelená", + "has the first date after the second": "má první datum po druhém", + "hour 00–23": "hodina 00–23", + "hours": "hodiny", + "image": "obrázek", + "in use": "používá se", + "includes the folders inside it": "zahrnuje podsložky", + "installed": "nainstalováno", + "is": "je", + "is <": "je <", + "is >": "je >", + "is after": "je po", + "is before": "je před", + "is between": "je mezi", + "is empty": "je prázdné", + "is in the last": "je v posledních", + "is unknown": "je neznámé", + "is yes or no": "je ano nebo ne", + "is ≤": "je ≤", + "is ≥": "je ≥", + "isn't": "není", + "isn't a field rules know": "není známé pole pravidel", + "isn't empty": "není prázdné", + "isn't in the last": "není v posledních", + "isn't one of the choices": "není jednou z možností", + "keep names — or e.g. {date}_{seq:3}": "zachovat názvy — nebo např. {date}_{seq:3}", + "latitude, longitude": "zeměpisná šířka, délka", + "makes AI Denoise available": "zpřístupní redukci šumu pomocí AI", + "memory only": "pouze v paměti", + "minute": "minuta", + "month 01–12": "měsíc 01–12", + "months": "měsíce", + "names an album that no longer exists": "odkazuje na album, které již neexistuje", + "needs a date like 2026, 2026-08 or 2026-08-14": "vyžaduje datum jako 2026, 2026-08 nebo 2026-08-14", + "needs a number": "vyžaduje číslo", + "needs a number more than 0": "vyžaduje číslo větší než 0", + "needs a time like 1/250 or 2": "vyžaduje čas jako 1/250 nebo 2", + "needs an album": "vyžaduje album", + "needs hours, days, weeks, months or years": "vyžaduje hodiny, dny, týdny, měsíce nebo roky", + "needs something to look for (or use “are empty”)": "vyžaduje hledaný text (nebo použijte „jsou prázdné“)", + "needs something to look for (or use “aren't empty”)": "vyžaduje hledaný text (nebo použijte „nejsou prázdné“)", + "needs something to look for (or use “is empty”)": "vyžaduje hledaný text (nebo použijte „je prázdné“)", + "needs something to look for (or use “isn't empty”)": "vyžaduje hledaný text (nebo použijte „není prázdné“)", + "needs text": "vyžaduje text", + "needs two values": "vyžaduje dvě hodnoty", + "non-commercial use only": "pouze nekomerční použití", + "none": "žádné", + "none (CPU rendering)": "žádné (vykreslování na CPU)", + "none of": "žádné z", + "not added to Apple Photos (cancelled)": "nepřidáno do Apple Photos (zrušeno)", + "not available here": "zde nedostupné", + "of the following rules:": "z následujících pravidel:", + "or": "nebo", + "photo has its denoised picture.": "Fotografie s dokončenou redukcí šumu", + "photo waiting": "Čekající fotografie", + "photos have their denoised picture.": "Fotografie s dokončenou redukcí šumu", + "photos waiting": "Čekající fotografie", + "pick": "vybrat", + "previews": "náhledy", + "publicDomain": "publicDomain", + "purple": "fialová", + "raw": "RAW", + "red": "červená", + "reject": "odmítnout", + "saved in": "uloženo v", + "second": "sekunda", + "smart album value|No": "Ne", + "smart album value|Yes": "Ano", + "smart previews": "inteligentní náhledy", + "starts using it for AI Denoise": "začne jej používat pro redukci šumu pomocí AI", + "starts with": "začíná na", + "terms unclear": "nejasné podmínky", + "the graphics card": "grafická karta", + "the graphics card is not used": "grafická karta se nepoužívá", + "the photos in view": "fotografie v zobrazení", + "the selected photos": "vybrané fotografie", + "this photo": "tato fotografie", + "this screen's profile": "profil této obrazovky", + "true": "pravda", + "unknown": "neznámé", + "video": "video", + "weeks": "týdny", + "would make this album include itself through other smart albums": "způsobí zahrnutí tohoto alba do sebe sama prostřednictvím jiných inteligentních alb", + "year, 2 digits": "rok, 2 číslice", + "year, 4 digits": "rok, 4 číslice", + "years": "roky", + "yellow": "žlutá", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} je čas pořízení; u fotografie bez něj se použije čas importu.", + "© 2026 Your Name": "© 2026 Vaše jméno", + "© Your Name": "© Vaše jméno", + "− harder border · + softer border": "− ostřejší okraj · + měkčí okraj", + "≤ at most": "≤ nejvýše", + "≥ at least": "≥ nejméně", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Show photos": "Zobrazit fotografie", + "Their photos in the grid": "Fotografie této osoby v mřížce", + "Click to name this face": "Kliknutím pojmenujete tento obličej", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Po potvrzení vyberte cílové umístění PDF. PDF vytiskněte ze svého prohlížeče.", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Prohlížeče, které nečtou blok PNG cICP, zobrazí hodnoty PQ jako sRGB: obraz je plochý a vybledlý. Pro správné zobrazení všude použijte JPEG (mapa zisku).", + "Apple Photos Imports": "Importy do Apple Photos", + "Background Tasks": "Úlohy na pozadí", + "Cancel Background Task": "Zrušit úlohu na pozadí", + "Contact Sheet PDF…": "Přehled snímků PDF…", + "Create Keyword": "Vytvořit klíčové slovo", + "Display": "Zobrazení", + "Display Profile": "Profil displeje", + "Edit Keyword": "Upravit klíčové slovo", + "Export Contact Sheet PDF": "Exportovat přehled snímků PDF", + "Find Blemishes": "Najít nedokonalosti", + "Keyword Info": "Informace o klíčovém slově", + "Move Keyword": "Přesunout klíčové slovo", + "Put New Keywords Inside": "Vkládat nová klíčová slova dovnitř", + "Scan Folder for Changes": "Prohledat změny ve složce", + "Set Folder Color Label": "Nastavit barevný štítek složky", + "Remove Folder Color Label": "Odebrat barevný štítek složky", + "Show in Compare": "Zobrazit v porovnání", + "Update to Current Process": "Aktualizovat na aktuální proces", + "HDR Headroom": "Rezerva HDR", + "SDR Brightness": "Jas SDR", + "SDR Clarity": "Zřetelnost SDR", + "SDR Contrast": "Kontrast SDR", + "SDR Highlights": "Světla SDR", + "SDR Shadows": "Stíny SDR", + "SDR Whites": "Bílé SDR", + "HDR TIFF needs 32-bit float.": "HDR TIFF vyžaduje 32bitové hodnoty s plovoucí řádovou čárkou.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Barevný prostor: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Barevný prostor: Rec. 2020 PQ, 16 bitů (HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Barevný prostor: Rec. 2020 PQ (HDR JPEG XL)", + "or drop a .onnx file on the window": "nebo přetáhněte soubor .onnx do okna", + "Already installed. Installing again is harmless.": "Již nainstalováno. Opětovná instalace ničemu nevadí.", + "Enter to confirm, Esc to cancel": "Enter potvrdí, Esc zruší", + "Add a model file…": "Přidat soubor modelu…", + "Open Settings": "Otevřít nastavení", + "Select none": "Zrušit celý výběr", + "Select all": "Vybrat vše", + "Visualize HDR": "Zobrazit rozsah HDR" +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..5dabf9a59 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -126,6 +126,7 @@ language_table! { Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -432,6 +433,77 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat mod tests { use super::*; + #[test] + fn czech_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::Cs.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("cs lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("cs lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn czech_switches_persists_and_formats_without_changing_user_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.czech", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Cs); + assert_eq!(Locale::Cs.name(), "Čeština"); + assert_eq!(Locale::Cs.script(), "Latn"); + assert_eq!(crate::menubar::checked(&app, "app.language.czech"), Some(true)); + assert_eq!(tr("File"), "Soubor"); + assert_eq!(tr("Develop"), "Vyvolat"); + assert_eq!(date_group_label("2026-09-20", false), "Neděle, 20. 9. 2026"); + assert_eq!(date_group_label("2026-09-20", true), "Neděle, 20."); + assert_eq!(date_group_label("2026-09", false), "9. 2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Neděle, 20. 9. 2026 16:04:05"); + for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Importováno fotografií: {n}")); + assert_eq!( + tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { "photo waiting" } else { "photos waiting" })), + format!("Čekající fotografie: {n}") + ); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Do „{name}“ přidáno fotografií: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Cs); + let ctx = egui::Context::default(); + for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Cs)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Cs); + } + let text = painted_text(&ctx, &mut app, Locale::Cs); + assert!(text.contains("Soubor") && text.contains("Expozice"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Czech glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + #[test] fn every_language_has_a_catalog_covering_the_source() { assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); @@ -667,6 +739,7 @@ mod tests { ("app.language.german", Locale::De), ("app.language.russian", Locale::Ru), ("app.language.ukrainian", Locale::Uk), + ("app.language.czech", Locale::Cs), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..c73b31af2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -46,6 +47,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { "app.language.german" => Some(crate::i18n::Locale::De), "app.language.russian" => Some(crate::i18n::Locale::Ru), "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.czech" => Some(crate::i18n::Locale::Cs), _ => None, } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..8244a76f6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -966,7 +966,7 @@ fn region_overlay( // a click on the label opens the name box, filled with the name or the guess let resp = ui.interact(label, egui::Id::new(("region-label", index)), Sense::click()); register(ui.ctx(), format!("regionLabel:{index}"), label); - if resp.on_hover_text("Click to name this face").clicked() { + if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() { let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); *editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true }); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..8a8cac407 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -644,7 +644,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); ui.checkbox(&mut options.captions, "Filename captions"); ui.label("150 dpi · sRGB · current edits · fit without cropping"); - ui.label("Choose a PDF destination after confirming. Print the PDF from your viewer."); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; @@ -708,17 +708,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"; ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); } } if !rendered { } else if opts.format == F::Avif && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)")).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) - .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR JPEG XL)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); } else if opts.format == F::Avif { ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..364d196d3 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -245,12 +245,12 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.add_space(6.0); ui.horizontal(|ui| { let can = app.services.pick_model_file.is_some(); - let r = ui.add_enabled(can, egui::Button::new("Add a model file…")); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Add a model file…"))); register(ui.ctx(), "faces:addModel", r.rect); if r.clicked() { let _ = app.run("dialog.faceModel", json!({})); } - ui.label(RichText::new("or drop a .onnx file on the window").color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("or drop a .onnx file on the window")).color(t.text_dim)); }); // what is wrong with the user's own catalog (catalog.json in the models folder), if anything for e in list["catalog"]["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).take(3) { @@ -293,9 +293,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, ui.label(RichText::new(size).color(t.text_dim)); } if selected { - ui.label(RichText::new("in use").color(t.accent)); + ui.label(RichText::new(crate::i18n::tr("in use")).color(t.accent)); } else if installed { - ui.label(RichText::new("installed").color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("installed")).color(t.text_dim)); } }); ui.label(RichText::new(licence_line(m)).font(t.font(11.5)).color(if m["licence"]["commercial"] == "yes" { @@ -329,7 +329,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, register(ui.ctx(), format!("faces:progress:{id}"), bar.rect); } (Some("done"), _) => { - ui.label(RichText::new("Downloaded and checked. Installing…").font(t.font(11.5)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); @@ -340,7 +340,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if installed { - let r = ui.button("Remove"); + let r = ui.button(crate::i18n::tr("Remove")); register(ui.ctx(), format!("faces:remove:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.remove", json!({"id": id})); @@ -357,7 +357,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, } else { match dl_state { Some("running") => { - let r = ui.button("Cancel"); + let r = ui.button(crate::i18n::tr("Cancel")); register(ui.ctx(), format!("faces:cancelDownload:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.downloadCancel", json!({"id": id})); @@ -368,9 +368,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, Some("done") => {} _ => { if let Some(url) = m["source"].as_str() { - let r = ui - .button("Open page") - .on_hover_text("Opens the model's own page in your browser, to read about it or get the file yourself."); + let r = ui.button(crate::i18n::tr("Open page")).on_hover_text(crate::i18n::tr( + "Opens the model's own page in your browser, to read about it or get the file yourself.", + )); register(ui.ctx(), format!("faces:get:{id}"), r.rect); if r.clicked() { open_page(app, url); @@ -403,7 +403,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info let kind = info["kind"].as_str().unwrap_or("unsupported"); let file = info["fileName"].as_str().unwrap_or(""); if kind == "unsupported" { - ui.label(RichText::new("This file cannot be used yet").font(t.semibold(13.5)).color(t.caution)); + ui.label(RichText::new(crate::i18n::tr("This file cannot be used yet")).font(t.semibold(13.5)).color(t.caution)); ui.add( egui::Label::new( RichText::new(sentence(info["reason"].as_str().unwrap_or("It is not a face recognition model LightCraft understands"))) @@ -420,7 +420,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info ui.label(RichText::new(mb(info["sizeBytes"].as_u64())).color(t.text_dim)); }); if info["alreadyInstalled"] == true { - ui.label(RichText::new("Already installed. Installing again is harmless.").color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Already installed. Installing again is harmless.")).color(t.text_dim)); } let download = info["download"].is_string(); let detector = m["role"] == "detector"; @@ -445,11 +445,13 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info } let provenance = m["provenance"].as_str().unwrap_or(""); if !provenance.is_empty() { - ui.add(egui::Label::new(RichText::new(format!("Trained on: {provenance}")).color(t.text_dim)).wrap()); + ui.add( + egui::Label::new(RichText::new(crate::i18n::tr_format!("Trained on: {provenance}", provenance = provenance)).color(t.text_dim)).wrap(), + ); } if kind == "draft" { ui.add_space(2.0); - ui.label(RichText::new("LightCraft does not know this model, so it assumed:").color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr("LightCraft does not know this model, so it assumed:")).color(t.text_label)); for a in info["assumptions"].as_array().into_iter().flatten().filter_map(Value::as_str) { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(t.font(12.0)).color(t.text_dim)).wrap()); } @@ -640,7 +642,7 @@ pub fn name_editor( outcome = Editor::Setup; } } - ui.label(RichText::new("Enter to confirm, Esc to cancel").font(t.font(11.0)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Enter to confirm, Esc to cancel")).font(t.font(11.0)).color(t.text_dim)); }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..41abe054e 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -617,13 +617,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant", "cs"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 10, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 11, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..ae8be95e3 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -103,7 +103,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.label(RichText::new(count).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(PAD - 4.0); - let photos = ui.button("Show photos").on_hover_text("Their photos in the grid"); + let photos = ui.button(crate::i18n::tr("Show photos")).on_hover_text(crate::i18n::tr("Their photos in the grid")); register(ui.ctx(), "person:photos", photos.rect); if photos.clicked() { let _ = app.run("library.filter", json!({"person": shown_name})); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..0737c6fbb 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -103,7 +103,10 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + // Twelve language names do not fit one line in a narrow window: wrap them. + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/crates/ui-egui/src/panels/unnamed.rs b/crates/ui-egui/src/panels/unnamed.rs index 0b730db78..f22551551 100644 --- a/crates/ui-egui/src/panels/unnamed.rs +++ b/crates/ui-egui/src/panels/unnamed.rs @@ -148,7 +148,7 @@ pub fn naming_bar(app: &mut LightcraftApp, ui: &mut egui::Ui) { if b.clicked() { submit = Some(chosen); } - let c = ui.button("Clear"); + let c = ui.button(crate::i18n::tr("Clear")); register(ui.ctx(), "unnamed:clear", c.rect); clear = c.clicked() || field.cancelled(); }); @@ -203,7 +203,7 @@ pub fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed p.text(pos2(at.x, at.y + 24.0), Align2::LEFT_CENTER, sub, t.font(12.0), t.text_dim); if !list.ready && list.faces.is_empty() { let r = Rect::from_min_size(pos2(at.x, at.y + 38.0), vec2(120.0, 22.0)); - let b = ui.put(r, egui::Button::new("Open Settings")); + let b = ui.put(r, egui::Button::new(crate::i18n::tr("Open Settings"))); register(ui.ctx(), "unnamed:openSettings", b.rect); if b.clicked() { let _ = app.run("app.settings", json!({"tab": "faces"})); @@ -211,7 +211,7 @@ pub fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed } else if !list.faces.is_empty() { let r = Rect::from_min_size(pos2(right - 130.0, at.y + 12.0), vec2(130.0, 22.0)); let all = app.ui.unnamed_selected.len() == list.faces.len(); - let b = ui.put(r, egui::Button::new(if all { "Select none" } else { "Select all" })); + let b = ui.put(r, egui::Button::new(crate::i18n::tr(if all { "Select none" } else { "Select all" }))); register(ui.ctx(), "unnamed:selectAll", b.rect); if b.clicked() { app.ui.unnamed_selected.clear(); diff --git a/docs/localization-cs.md b/docs/localization-cs.md new file mode 100644 index 000000000..49a4d9859 --- /dev/null +++ b/docs/localization-cs.md @@ -0,0 +1,57 @@ +# Čeština + +Češtinu (`cs`, English name **Czech**, native name **Čeština**) zapnete přes **Úpravy → Jazyk → +Čeština** nebo **Nastavení → Obecné → Jazyk**. Změna platí ihned a ukládá se pro další spuštění. +Příkaz je `app.language.czech`; řídicí kanál přijímá `ui.set {"language":"cs"}` i značky +`cs-CZ` a `cs_CZ.UTF-8`. Pro běh bez okna lze nastavit `LIGHTCRAFT_LANGUAGE=cs`. + +Katalogy `crates/ui-egui/locales/cs.json` a `cs-formats.json` vznikly s pomocí AI pro @Losminek, +po celých frázích podle významu anglických zdrojových řetězců. Výchozí seznam klíčů vycházel +z největšího, ukrajinského katalogu; doplnilo se sjednocení ostatních katalogů a položky +používané kódem. Překlad byl porovnán s konvencemi rozhraní aplikace a prošel nezávislou +revizí rozsahu a terminologie. Rodilý mluvčí ani profesionální překladatel jej zatím nekorigoval. + +## Konvence a rozsah + +- Nabídky: Soubor, Úpravy, Zobrazení, Fotografie, Okno, Nápověda. +- Library = Knihovna, Develop = Vyvolat, Collection = Kolekce, Preset = Přednastavení. +- Flag = Příznak, Pick Flag = Příznak Vybráno, Soft Proofing = Měkký nátisk. +- Clarity = Zřetelnost, Dehaze = Odstranit opar, Vibrance = Živost, Saturation = Sytost. +- Highlights = Světla, Shadows = Stíny, Whites = Bílé, Blacks = Černé. +- Filmstrip = Filmový pás, Virtual Copy = Virtuální kopie, Snapshot = Snímek stavu. +- Datum má podobu `20. 9. 2026`, čas je 24hodinový (`16:04:05`). +- Čísla používají společné formátování aplikace; tento jazyk nepřidává vlastní číselný formatter. +- Počty používají neutrální formulace jako „Importováno fotografií: 22“ nebo + „Čekající fotografie: 2“. Anglická přípona se podle společných pravidel spotřebuje pomocí + `{:.0}`. Stávající mechanismus rozlišuje pouze jednotné a množné číslo; samostatné české + plurálové pravidlo se nepřidává. +- Písmo Inter dodávané s aplikací obsahuje české znaky i uvozovky „…“. Další font není potřeba. + +Katalog obsahuje 2 056 běžných položek a 246 formátových šablon. Každý běžný klíč existuje +v jiném katalogu nebo je používán kódem. Oproti německému katalogu má 108 dalších klíčů, +všechny s doloženým použitím: popisky příkazů, menu, ovládacích prvků a nastavení, +texty HDR/exportu a obličejů a kontextové hodnoty Ano/Ne. Z toho 41 klíčů již existuje +v dalších katalozích a 67 doplňuje současné překladové lookupy. + +Řádkové fragmenty poznámek k vydáním byly odstraněny, včetně 163 fragmentů převzatých +ze sdílených katalogů; text poznámek v dialogu Co je nového používá anglický fallback. +Společné nadpisy používané také v UI (například Library nebo Masking) zůstávají přeložené. + +Úplné pokrytí katalogů neznamená úplnou jazykovou paritu: technické chyby nižších vrstev +a některá hlášení mimo překladové rozhraní zůstávají anglicky. Překlad nemění data knihovny +ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze změny. + +## Ověření a revize + +Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál, +uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas +a české glyfy. Překladové šablony kontroluje také Rust při sestavení. + +Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack, +„bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení. +Postup přidávání jazyků je v [localization.md](localization.md). + +V této změně se doplnilo volání překladu u jednoduchých popisků v panelech osob a obličejů, +volbě souboru modelu a podmínkách jeho instalace, výběru nepojmenovaných obličejů, +pojmenování obličeje v detailu, instrukce tisku PDF a upozornění na PNG PQ, +včetně textů barevného prostoru HDR. Anglické zdrojové texty zůstaly stejné. diff --git a/docs/localization.md b/docs/localization.md index 715b871af..7cf92af11 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,10 +2,11 @@ The interface ships in the language it is written in (English) plus every language in the table in `crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional -Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`) and French (`fr`). This file is the reference for **adding or maintaining a language**; the per-language notes +Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`) and Czech (`cs`). This file is the reference for **adding or maintaining a language**; the per-language notes are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md), [`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md), -[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md). +[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md) and +[`localization-cs.md`](localization-cs.md). ## Adding a language @@ -30,6 +31,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); } ``` From 6402c83bb498213e70ec82bc5b1af1c58ccbaec4 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:32:45 +0200 Subject: [PATCH 043/315] contributors: add Losminek Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 79f20c726..457d4ed86 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,3 +27,6 @@ sync_github_name = true [people.sanyamsingh001] display_name = "sanyamsingh001" + +[people.Losminek] +sync_github_name = true From aec225b6ac404ec7bc13924518f74e0a53a92ae5 Mon Sep 17 00:00:00 2001 From: GabrielePecchioli <71943308+GabrielePecchioli@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:45:26 +0200 Subject: [PATCH 044/315] Add Italian interface translation (#1192) Italian (`it`) catalogs: 1,992 interface messages and all 246 format strings (2,201 of 2,398 messages; What's New left in English like most languages). 410 labels reuse the PhotoCraft, VectorCraft, PdfCraft and WordCraft Italian catalogs where the meaning fits a photo app; 46 were retranslated. Counts follow a label ("Foto importate: 3") because Italian adjectives agree with the number; the English plural suffix is consumed with {:.0}. Language table entry, menu command, three tests mirroring Ukrainian's, the button-fit test extended to Italian, and the Settings language buttons now wrap so an eleventh language doesn't widen the dialog past a short window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 3 +- assets/ATTRIBUTION.md | 1 + crates/ui-egui/locales/it-formats.json | 248 +++ crates/ui-egui/locales/it.json | 1994 +++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 109 +- crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/panels/keyword_list.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 5 +- docs/localization-parity.md | 12 +- docs/localization.md | 1 + 10 files changed, 2368 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/locales/it-formats.json create mode 100644 crates/ui-egui/locales/it.json diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..91a8f1cfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc | Korean | ko | 0% | none (no Hangul face) | | Vietnamese | vi | 0% | none | -Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%). ## Upcoming @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..db81eb53f 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -26,6 +26,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | | craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | +| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json new file mode 100644 index 000000000..0a263caa4 --- /dev/null +++ b/crates/ui-egui/locales/it-formats.json @@ -0,0 +1,248 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} con diversi file simili: usa Trova)", + " ({} in Recently Deleted)": " ({} in Eliminate di recente)", + " · {:.0}% clipped": " · {:.0}% tagliato", + " · {failed} couldn't be rendered": " · impossibili da elaborare: {failed}", + " · {skipped} skipped (file exists)": " · saltate: {skipped} (il file esiste)", + " · {} duplicate{} skipped": " · duplicati saltati: {}{:.0}", + " · {} failed: {}": " · {} con errori: {}", + " · {} moved": " · spostate: {}", + " · {} not readable": " · illeggibili: {}", + " · {} original{} left at the source": " · originali lasciati nell'origine: {}{:.0}", + " · {} restored from Recently Deleted": " · ripristinate da Eliminate di recente: {}", + " — not carried over: {}{}": " · non trasferito: {}{}", + "+{} more": "+{} altre", + ", {failed} file{} not readable": ", file illeggibili: {failed}{:.0}", + "; {kept} already had a location": "; avevano già una posizione: {kept}", + "; {outside} outside its time range": "; fuori dal loro intervallo di tempo: {outside}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} righe (Δ {}), +{} / −{} risorse binarie, {} - {}", + "Add {local_n} to My Photos": "Aggiungi {local_n} a Le mie foto", + "Add {} Photo{}": "Aggiungi {} foto{:.0}", + "Add {} Photo{}, restore {}": "Aggiungi {} foto{:.0}, ripristina {}", + "Add “{}” to Favorites": "Aggiungi “{}” ai preferiti", + "Added {n} photo{} to My Photos": "Foto aggiunte a Le mie foto: {n}{:.0}", + "Added {n} photo{} to “{}”": "Foto aggiunte a “{1}”: {n}{0:.0}", + "Added {what} to {name}": "Aggiunto a {name}: {what}", + "Adding photos… {} of {}": "Aggiunta delle foto… {} di {}", + "All {n} selected photos move by the same amount.": "Tutte le {n} foto selezionate si spostano della stessa quantità.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Già nella libreria, in Eliminate di recente: foto selezionate {}{:.0} · clic destro → Ripristina, oppure Elimina definitivamente per importarle da capo", + "Already in the library: {} photo{} selected in All Photos": "Già nella libreria (selezionate in Tutte le foto): {}{:.0}", + "Auto ({})": "Automatico ({})", + "Auto Import: added {} photo{}": "Importazione automatica · foto aggiunte: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Sincronizzazione automatica: le modifiche si applicano a {n} foto", + "Automatic ({} MB)": "Automatico ({} MB)", + "Build 1:1 Previews ({scope})": "Genera anteprime 1:1 ({scope})", + "Build Smart Previews ({scope})": "Genera anteprime avanzate ({scope})", + "Build Standard-Sized Previews ({scope})": "Genera anteprime di dimensione standard ({scope})", + "Building previews for {} photos…": "Generazione delle anteprime per {} foto…", + "Click {}:1": "Clic {}:1", + "Couldn't open the editor: {e}": "Impossibile aprire l'editor: {e}", + "Create {n} Virtual Copies": "Crea {n} copie virtuali", + "Creates {} stacks from {} of {scope}": "Crea pile: {} (da {} di {scope})", + "Decoder: {reason}": "Decodificatore: {reason}", + "Delete \"{label}\"": "Elimina \"{label}\"", + "Delete \"{label}\" (and the mask)": "Elimina \"{label}\" (e la maschera)", + "Delete {n} Photos": "Elimina {n} foto", + "Delete “{current}”": "Elimina “{current}”", + "Discard Smart Previews ({scope})": "Elimina anteprime avanzate ({scope})", + "Done ({what}): {done} photo{plural}": "Fatto ({what}): {done} foto{plural:.0}", + "Duplicate \"{label}\"": "Duplica \"{label}\"", + "Example: {example}": "Esempio: {example}", + "Exported {n} photo{}": "Foto esportate: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}", + "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}", + "Exported {} preset{}": "Preset esportati: {}{:.0}", + "Exporting {} of {total}": "Esportazione {} di {total}", + "Filter bar — {active} active filter{}": "Barra dei filtri · filtri attivi: {active}{:.0}", + "Focus {:.0}": "Messa a fuoco {:.0}", + "Folder “{}” (and its subfolders)": "Cartella “{}” (e sottocartelle)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Foto mancanti ritrovate: {n}{:.0}; ancora mancanti: {left}{unsure}", + "Healed {n} dust spot{}": "Macchie di polvere corrette: {n}{:.0}", + "Import cancelled · {} photo{} added": "Importazione annullata · foto aggiunte: {}{:.0}", + "Import failed: {e}": "Importazione non riuscita: {e}", + "Import warnings ({n}):\n{warnings}": "Avvisi di importazione ({n}):\n{warnings}", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Il profilo XMP \"{profile}\" non è stato applicato a {path}; i colori possono differire da Lightroom.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometria di Lightroom non è stata conservata per {path}; {mode} verrà ricalcolato.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata viene ignorata perché il raddrizzamento è disattivato.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento guidato non è supportata.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento non è supportata.", + "XMP adjustments were not imported for {path}: {fields}.": "Le regolazioni XMP non sono state importate per {path}: {fields}.", + "Imported {n} point curve preset{}": "Preset di curva a punti importati: {n}{:.0}", + "Imported {n} preset{}": "Preset importati: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Preset importati: {n}{:.0}, file illeggibili: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Profili importati: {profiles}{:.0} (Browser dei profili → i loro gruppi)", + "Imported {} photo{}": "Foto importate: {}{:.0}", + "Insert at the cursor (also written {})": "Inserisci al cursore (si scrive anche {})", + "Label Set: {name}": "Set di etichette: {name}", + "LightCraft could not open its window: {e}": "LightCraft non è riuscito ad aprire la sua finestra: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft non è riuscito ad aprire la sua finestra: {e}\n\nI dettagli sono nel file di log: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catalogo di Lightroom esaminato: foto {photos}, raccolte {collections}, mancanti {missing}", + "Lightroom import complete: {imported} photos added": "Importazione da Lightroom completata: foto aggiunte {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importazione da Lightroom completata: foto aggiunte {imported}, con errori {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importazione da Lightroom completata: foto aggiunte {imported}; avviso sull'indice: {warning}", + "Lightroom import failed: {e}{warning}": "Importazione da Lightroom non riuscita: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Analisi di Lightroom non riuscita: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Caratteri: {} (OFL). Icone: originali.", + "Matches: {}": "Corrispondenze: {}", + "Merging… {stage} {:.0}%": "Unione in corso… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Spostare {what} in Eliminate di recente?", + "Named ({named_n})": "Con nome ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Pennello “{k}”: fai clic sulle foto per aggiungerla o toglierla · Esc interrompe", + "Paste into {n} photo{} — only settings that were copied are pasted": "Incolla in {n} foto{:.0}: vengono incollate solo le impostazioni copiate", + "Preset: {name}": "Preset: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera ({})", + "Preview only — {}": "Solo anteprima: {}", + "Previews ready for {done} photo{plural}": "Anteprime pronte per {done} foto{plural:.0}", + "Profile: {}": "Profilo: {}", + "Proof Copy ({})": "Copia di prova ({})", + "Proof Preview · {}": "Anteprima di prova · {}", + "Quick Develop · {n} photos": "Sviluppo rapido · {n} foto", + "Quick Develop: {name}": "Sviluppo rapido: {name}", + "Rated {}": "Valutata {}", + "Reading Lightroom catalog… {done} of {total}": "Lettura del catalogo Lightroom… {done} di {total}", + "Reading photos… {done} of {total}": "Lettura delle foto… {done} di {total}", + "Reading photos… {} of {}": "Lettura delle foto… {} di {}", + "Redo {}": "Ripeti {}", + "Remove “{name}” and its {count} photo{} from the library?": "Togliere dalla libreria “{name}” e le sue foto ({count}{:.0})?", + "Remove “{}” from Favorites": "Rimuovi “{}” dai preferiti", + "Removed from {}": "Rimosso da {}", + "Removed {what} from {name}": "Rimosso da {name}: {what}", + "Rename {n} Photos…": "Rinomina {n} foto…", + "Rename “{name}”": "Rinomina “{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Rinomina “{from}” in {n} foto{:.0} (anche le parole chiave sotto di essa). Usa | per i livelli, es. Viaggi|Italia. Un nome esistente unisce le due.", + "Rendering on the CPU: {why}": "Rendering sulla CPU: {why}", + "Replace {} with:": "Sostituisci {} con:", + "Reset {name}": "Reimposta {name}", + "Reset {} Channel": "Reimposta canale {}", + "Restore {}": "Ripristina {}", + "Restore {} Photo{}": "Ripristina {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "Foto ripristinate da Eliminate di recente: {}{:.0}", + "Saved export preset “{}”": "Preset di esportazione “{}” salvato", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Valuta messa a fuoco ed esposizione di {scope} e trova scatti simili fatti a pochi secondi di distanza (raffiche).", + "Show {hidden} hidden location{}": "Mostra le posizioni nascoste: {hidden}{:.0}", + "Showing {n} of {}": "Mostrate {n} di {}", + "Smart album: {}": "Album smart: {}", + "Tagged {n} photo{} from the tracklog": "Foto geolocalizzate dal tracciato: {n}{:.0}", + "The SAM 3 download failed: {e}": "Download di SAM 3 non riuscito: {e}", + "Undo {}": "Annulla {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Tag sconosciuti{s:.0}: {} (restano come li hai scritti{:.0}; vedi Tag)", + "Updated “{name}”": "“{name}” aggiornato", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versione {}: libreria fotografica e sviluppo raw clean-room, in puro Rust.", + "Version: {}": "Versione: {}", + "Virtual copy “{name}” of {of}": "Copia virtuale “{name}” di {of}", + "Working on {what} for {} photos…": "Elaborazione di {what} per {} foto…", + "none (CPU rendering): {why}": "nessuna (rendering sulla CPU): {why}", + "the {n} selected photos": "le {n} foto selezionate", + "the {} photos in view": "le {} foto nella vista", + "these {count} photos": "queste {count} foto", + "{count} smart preview{} · {:.1} MB": "Anteprime avanzate: {count}{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} cartelle (e le loro sottocartelle)", + "{label} Grading": "Gradazione di {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} su ogni foto selezionata", + "{label} {index} of {n}": "{label} {index} di {n}", + "{label}, {n} photos": "{label}, {n} foto", + "{matching} of {t} photos": "{matching} di {t} foto", + "{matching} photos": "{matching} foto", + "{mode} spot {} of {}": "{mode}: macchia {} di {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} con errori)", + "{name} Label": "Etichetta {name}", + "{name} Off": "{name}: disattivato", + "{name} On": "{name}: attivato", + "{name} opened for editing; it is stacked with the original": "{name} aperta per la modifica; è in pila con l'originale", + "{name} — double-click to reset it": "{name}: doppio clic per reimpostarlo", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Modifiche non scritte sul disco: {n}{0:.0}: {e}\nSe esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Modifiche salvate in memoria ma non scritte sul disco: {n}{0:.0}: {e}\nLightCraft riprova da solo; se esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Modifiche salvate solo in memoria: {n}{:.0}: {e}; LightCraft riproverà", + "{n} found · {sel} selected": "Trovate: {n} · selezionate: {sel}", + "{n} photos": "{n} foto", + "{n} photos in {} are hidden by the filters above": "I filtri qui sopra nascondono {n} foto in {}", + "{n} photo{}": "{n} foto{:.0}", + "{n} photo{} match · updates automatically as photos change": "Foto corrispondenti: {n}{:.0} · si aggiorna da solo quando le foto cambiano", + "{n} photo{} now · updates automatically as photos change": "{n} foto{:.0} ora · si aggiorna da solo quando le foto cambiano", + "{pid} (missing)": "{pid} (mancante)", + "{sel_n} selected · {counted}": "Selezionate: {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} foto{:.0}", + "{weekday}, {day}": "{weekday} {day}", + "{weekday}, {day} {month} {year}": "{weekday} {day}/{month:02}/{year}", + "{what} merge added": "Unione {what} aggiunta", + "{what} merge failed: {e}": "Unione {what} non riuscita: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Direttive {{date:…}}: {}", + "{} and higher": "{} e superiori", + "{} contributors · {} commits": "Collaboratori: {} · commit: {}", + "{} failed": "Con errori: {}", + "{} files": "{} file", + "{} files and {f} folder{}": "{} file, cartelle: {f}{:.0}", + "{} item{}": "Elementi: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} di 4 guide: trascina lungo le linee che dovrebbero essere verticali o orizzontali.", + "{} photos": "{} foto", + "{} photos · exposures {} EV": "{} foto · esposizioni {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Foto illeggibili: {}{:.0}", + "{} sample{}": "Campioni: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Macchie in questa foto: {}", + "{} — show their photos": "{}: mostra le loro foto", + "· {dups} already in the library": "· già nella libreria: {dups}", + "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} in Eliminate di recente", + "… and {more} more": "… e altre {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft non sa ancora decodificare questa variante raw ({}). Stai modificando l'anteprima JPEG incorporata dalla fotocamera, che contiene già lo stile immagine della fotocamera (ad es. Monocromatico) e il bilanciamento del bianco.", + "Filtered: {label}": "Filtrate: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Foto simili: {n}{:.0} · Visualizza → Cancella filtri per vederle tutte", + "Moved; {} photo(s) relinked": "Spostata; foto ricollegate: {}", + "{} label (click more labels to show any of them)": "Etichetta {} (fai clic su altre etichette per mostrarne una qualsiasi)", + ", {} star{}": ", stelle: {}{:.0}", + ", {} label": ", etichetta {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Scaricato da {host}. Quando è arrivato ed è stato verificato, LightCraft lo installa e {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "In esecuzione sul processore: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Conservate nella cartella “denoise” della libreria; al raggiungimento del limite si eliminano per prime le più vecchie che nessuna foto usa.", + "Denoising {name}: {done} of {total} tiles{}": "Riduzione disturbo di {name}: {done} di {total} riquadri{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} di {} da {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Generazione dell'immagine con il disturbo ridotto… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Addestrato su: {provenance}", + " · {queued} waiting": " · in attesa: {queued}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Mostra le condizioni del modello, poi scarica {} da {host}, lo installa e comincia a usarlo. Non viene inviato nient'altro.", + "In line for AI Denoise ({ahead} ahead)…": "In coda per la riduzione disturbo IA ({ahead} prima)…", + "Running on {}.": "In esecuzione su {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Metti dentro “{parent}”", + "Delete the keyword “{name}”?": "Eliminare la parola chiave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Viene tolta da {count} foto{:.0}, insieme alle parole chiave sotto di essa. Annulla la riporta.", + "Create Keyword Tag Inside “{name}”…": "Crea tag parola chiave dentro “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ha già “{name}”. Unire le due?", + "There is a “{name}” at the top level already. Merge the two?": "Al livello principale c'è già “{name}”. Unire le due?", + "Added “{keyword}” to {n} photo{}": "Aggiunta “{keyword}” a {n} foto{:.0}", + "On {have} of {of} selected photos": "Su {have} di {of} foto selezionate", + "Remove “{name}”": "Rimuovi “{name}”", + "There is a keyword set “{name}” already": "Esiste già un set di parole chiave “{name}”", + "Added {n} keyword{}": "Parole chiave aggiunte: {n}{:.0}", + "; {n} gained synonyms": "; con nuovi sinonimi: {n}", + "Exported {n} keyword{}": "Parole chiave esportate: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " · Capture One non importa un elenco con ; , < o > in una parola chiave: {names}", + " — left out, as a keyword list can't hold them: {names}": " · escluse, perché un elenco di parole chiave non può contenerle: {names}", + "{done} of {total}": "{done} di {total}", + "{done} of {total} MB": "{done} di {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "+{n} altre", + "1 task is still running: {names}. Quitting stops it.": "1 attività è ancora in corso: {names}. Uscendo si interrompe.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} attività sono ancora in corso: {names}. Uscendo si interrompono.", + "{what} merge cancelled": "Unione {what} annullata", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Foto locali saltate: {n}{0:.0}; gli album contengono foto della libreria (prima aggiungile a Le mie foto)", + "New Folder in “{name}”": "Nuova cartella in “{name}”", + "Moved {} photo(s)": "Foto spostate: {}", + "not added to Apple Photos: {e}": "non aggiunte a Foto di Apple: {e}", + "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", + "{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}", + "{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale" +} diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json new file mode 100644 index 000000000..382fb80c9 --- /dev/null +++ b/crates/ui-egui/locales/it.json @@ -0,0 +1,1994 @@ +{ + " (library default)": " (predefinito della libreria)", + " (inverted)": " (invertita)", + " · best of its burst": " · la migliore della sua raffica", + " · in a burst": " · in una raffica", + "% of all commits": "% di tutti i commit", + "+ Group": "+ Gruppo", + "+ Rule": "+ Regola", + "0: nothing is rejected.": "0: non viene scartato niente.", + "1 day": "1 giorno", + "2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12", + "= exactly": "= esattamente", + "A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)", + "A one-time download of about": "Un download unico di circa", + "AI Mask Model Status": "Stato del modello IA per le maschere", + "About": "Informazioni", + "About LightCraft": "Informazioni su LightCraft", + "Active": "Attivo", + "Activity": "Attività", + "Actual size": "Dimensione reale", + "Add": "Aggiungi", + "Add Catchlight": "Aggiungi riflesso negli occhi", + "Add Folder to Local": "Aggiungi cartella a Locale", + "Add Mask": "Aggiungi maschera", + "Add Object Click": "Aggiungi oggetto con un clic", + "Add Point Color Sample": "Aggiungi campione colore puntuale", + "Add Red Eye Correction": "Aggiungi correzione occhi rossi", + "Add Remove Spot": "Aggiungi rimozione macchie", + "Add Selected Photos": "Aggiungi le foto selezionate", + "Add a rule (⌥-click: a group)": "Aggiungi una regola (⌥-clic: un gruppo)", + "Add in place": "Aggiungi sul posto", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.", + "Add keyword": "Aggiungi parola chiave", + "Add number": "Aggiungi numero", + "Add to": "Aggiungi a", + "Add to Album": "Aggiungi all'album", + "Add to Favorites": "Aggiungi ai preferiti", + "Add to Local": "Aggiungi a Locale", + "Add to My Photos": "Aggiungi a Le mie foto", + "Add to Selected Photos": "Aggiungi alle foto selezionate", + "Add to Target Album": "Aggiungi all'album di destinazione", + "Add to this mask": "Aggiungi a questa maschera", + "Add/Subtract/Intersect Mask": "Aggiungi, sottrai o interseca maschera", + "Added to photos you import that don't already have it.": "Aggiunto alle foto che importi e che non lo hanno già.", + "Adding": "Aggiunta in corso", + "Adding Lightroom photos…": "Aggiunta delle foto di Lightroom…", + "Advanced search…": "Ricerca avanzata…", + "After": "Dopo", + "After 30 days": "Dopo 30 giorni", + "After 7 days": "Dopo 7 giorni", + "After 90 days": "Dopo 90 giorni", + "After a year": "Dopo un anno", + "Album": "Album", + "Albums": "Album", + "All": "Tutti", + "All Metadata": "Tutti i metadati", + "All Metadata…": "Tutti i metadati…", + "All Photos": "Tutte le foto", + "All rights reserved": "Tutti i diritti riservati", + "All the photos imported from this disk are included.": "Sono incluse tutte le foto importate da questo disco.", + "All types": "Tutti i tipi", + "Alt Text": "Testo alternativo", + "Always": "Sempre", + "Amount": "Quantità", + "Analyzing the photo for AI masks…": "Analisi della foto per le maschere IA…", + "Any Searchable Text": "Qualsiasi testo ricercabile", + "Aperture": "Diaframma", + "Application": "Applicazione", + "Apply": "Applica", + "Apply Color Label Set": "Applica set di etichette colore", + "Apply Filter Preset": "Applica preset di filtro", + "Apply Metadata Preset": "Applica preset di metadati", + "Apply Point Curve Preset": "Applica preset di curva a punti", + "Apply Preset": "Applica preset", + "Apply Settings JSON": "Applica impostazioni JSON", + "Approved": "Approvata", + "Aqua": "Acqua", + "Aqua Hue": "Tonalità acqua", + "Aqua Luminance": "Luminanza acqua", + "Aqua Saturation": "Saturazione acqua", + "Arrange Panel": "Disponi il pannello", + "ArtCraft Website": "Sito web di ArtCraft", + "ArtCraft — more creative apps": "ArtCraft: altre app creative", + "As Shot": "Come scattata", + "As shot": "Come scattata", + "Ascending": "Crescente", + "Aspect": "Proporzioni", + "Aspect Ratio": "Proporzioni", + "Assisted Culling": "Selezione assistita", + "Assisted Culling…": "Selezione assistita…", + "Auto": "Automatico", + "Auto Advance": "Avanzamento automatico", + "Auto Advance Off": "Avanzamento automatico disattivato", + "Auto Advance On": "Avanzamento automatico attivato", + "Auto Advance after rating or flagging": "Avanzamento automatico dopo valutazione o contrassegno", + "Auto Advance: move to the next photo after rating or flagging": "Avanzamento automatico: passa alla foto successiva dopo una valutazione o un contrassegno", + "Auto Align": "Allineamento automatico", + "Auto B&W Mix": "Mix bianco e nero automatico", + "Auto Crop": "Ritaglio automatico", + "Auto Import": "Importazione automatica", + "Auto Import Now": "Importa automaticamente ora", + "Auto Import Settings": "Impostazioni di importazione automatica", + "Auto Mask": "Maschera automatica", + "Auto Select": "Selezione automatica", + "Auto Settings": "Impostazioni automatiche", + "Auto Straighten": "Raddrizzamento automatico", + "Auto Sync": "Sincronizzazione automatica", + "Auto settings applied": "Impostazioni automatiche applicate", + "Auto-Stack by Capture Time": "Raggruppa automaticamente per ora di scatto", + "Auto-Stack by Capture Time…": "Raggruppa automaticamente per ora di scatto…", + "Auto-Tag Photos from Tracklog": "Geolocalizza automaticamente le foto dal tracciato", + "Auto-Tag from Tracklog": "Geolocalizza dal tracciato", + "Auto-Tag from Tracklog…": "Geolocalizza dal tracciato…", + "Automatic": "Automatico", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatico disegna la vista Dettaglio alla dimensione in cui è mostrata, fino ai pixel della foto. Scegli una dimensione per limitarla: più piccola è più veloce.", + "Automatically Write Changes into XMP": "Scrivi automaticamente le modifiche nell'XMP", + "Automatically write changes into XMP sidecars": "Scrivi automaticamente le modifiche nei file XMP collaterali", + "Autumn Gold": "Oro d'autunno", + "B&W": "B/N", + "B&W Mix": "Mix B/N", + "B&W Mixer": "Mixer B/N", + "Back": "Indietro", + "Back Up Library…": "Esegui backup della libreria…", + "Back to Edit": "Torna a Modifica", + "Back to Edit (E)": "Torna a Modifica (E)", + "Back to Grid": "Torna alla griglia", + "Background": "Sfondo", + "Balance": "Bilanciamento", + "Basic": "Base", + "Before": "Prima", + "Before/After (Y)": "Prima/Dopo (Y)", + "Before/After Settings": "Impostazioni Prima/Dopo", + "Before/After Split": "Prima/Dopo diviso", + "Before/After Split Top/Bottom": "Prima/Dopo diviso sopra/sotto", + "Before/After Top/Bottom": "Prima/Dopo sopra/sotto", + "Begin Interaction": "Inizia interazione", + "Best of Similar Shots": "La migliore tra scatti simili", + "Binary assets added": "Risorse binarie aggiunte", + "Binary assets removed": "Risorse binarie rimosse", + "Bit depth": "Profondità bit", + "Black & White": "Bianco e nero", + "Blacks": "Neri", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Sbiancato", + "Blending": "Fusione", + "Blue": "Blu", + "Blue Hour Boost": "Ora blu potenziata", + "Blue Hue": "Tonalità blu", + "Blue Luminance": "Luminanza blu", + "Blue Primary": "Primario blu", + "Blue Saturation": "Saturazione blu", + "Blue channel": "Canale blu", + "Blue label": "Etichetta blu", + "Blue/Yellow Fringe": "Frange blu/gialle", + "Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.", + "Boundary Warp": "Deformazione dei bordi", + "Bright & Airy": "Luminoso e arioso", + "Bring All to Front": "Porta tutto in primo piano", + "Browse Folder": "Sfoglia cartella", + "Browse Folder…": "Sfoglia cartella…", + "Browse Profiles": "Sfoglia profili", + "Browse…": "Sfoglia…", + "Brush": "Pennello", + "Brush Settings": "Impostazioni pennello", + "Brush Stroke": "Tratto pennello", + "Build Previews": "Genera anteprime", + "Build Smart Previews": "Genera anteprime avanzate", + "By Date": "Per data", + "By day (YYYY/YYYY-MM-DD)": "Per giorno (AAAA/AAAA-MM-GG)", + "By month (YYYY/YYYY-MM)": "Per mese (AAAA/AAAA-MM)", + "CPU threads": "Thread della CPU", + "Calibration": "Calibrazione", + "Camera": "Fotocamera", + "Camera make and model": "Marca e modello della fotocamera", + "Cameras and Cards": "Fotocamere e schede", + "Cancel": "Annulla", + "Cancel AI Mask Model Download": "Annulla il download del modello IA per le maschere", + "Cancel Download": "Annulla download", + "Cancel Interaction": "Annulla interazione", + "Cancel Preview Build": "Annulla generazione anteprime", + "Candidate": "Candidata", + "Caption": "Didascalia", + "Capture Date": "Data di scatto", + "Capture date and time in your own format (directives below)": "Data e ora di scatto nel formato che preferisci (direttive qui sotto)", + "Capture date as YYYYMMDD": "Data di scatto come AAAAMMGG", + "Captured": "Scattata", + "Catalog Statistics": "Statistiche del catalogo", + "Catchlight": "Riflesso negli occhi", + "Change": "Cambia", + "Change Component Mode": "Cambia modalità del componente", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "I caratteri non ammessi nei nomi dei file (/ \\ : * ? \" < > |) diventano -; a un nome già esistente si aggiunge -1, -2….", + "Check All": "Seleziona tutto", + "Check Output Path": "Controlla il percorso di uscita", + "Check the files of this type": "Seleziona i file di questo tipo", + "Checking names…": "Controllo dei nomi…", + "Choose Another Library…": "Scegli un'altra libreria…", + "Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare", + "Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…", + "Choose Folder…": "Scegli cartella…", + "Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia", + "Choose…": "Scegli…", + "Cinematic": "Cinematografico", + "City": "Città", + "Clarity": "Chiarezza", + "Clean Studio": "Studio pulito", + "Clear": "Cancella", + "Clear Cache": "Svuota cache", + "Clear Filters": "Cancella filtri", + "Clear Guides": "Cancella guide", + "Clear History": "Cancella cronologia", + "Clear Preview Cache": "Svuota cache delle anteprime", + "Clear Quick Collection": "Svuota la raccolta rapida", + "Clear Reference": "Cancella riferimento", + "Clear all": "Cancella tutto", + "Clear form": "Svuota il modulo", + "Clear rating": "Togli valutazione", + "Clear rating and advance": "Togli valutazione e avanza", + "Cleared History": "Cronologia cancellata", + "Click Zoom Ratio": "Rapporto di zoom al clic", + "Click a shortcut, then press the new keys (Esc cancels).": "Fai clic su una scorciatoia, poi premi i nuovi tasti (Esc annulla).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Fai clic su un tag per inserirlo. Gli esempi si riferiscono a IMG_0042.CR3 scattata il 14 gen 2026 alle 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Fai clic sulle foto nella griglia per aggiungere o togliere la parola chiave; Esc interrompe", + "Click the object to select it · ⌥-click leaves a part out": "Fai clic sull'oggetto per selezionarlo · ⌥-clic esclude una parte", + "Click the photo to pick a colour · ⇧-click adds more": "Fai clic sulla foto per scegliere un colore · ⇧-clic ne aggiunge altri", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Fai clic sulla foto per scegliere un colore; ⇧-clic ne aggiunge altri (fino a 5)", + "Click to clear these filters": "Fai clic per togliere questi filtri", + "Click to restore · double-click to rename": "Clic per ripristinare · doppio clic per rinominare", + "Clone": "Clona", + "Close": "Chiudi", + "Close Panel": "Chiudi pannello", + "Close Window": "Chiudi finestra", + "Close all": "Chiudi tutto", + "Close file": "Chiudi file", + "Close panel": "Chiudi pannello", + "Cloudy": "Nuvoloso", + "Collapse": "Comprimi", + "Collapse All Stacks": "Comprimi tutte le pile", + "Collapse Stack": "Comprimi pila", + "Color": "Colore", + "Color Grading": "Gradazione colore", + "Color Label": "Etichetta colore", + "Color Label Names": "Nomi delle etichette colore", + "Color Label Sets": "Set di etichette colore", + "Color Mixer": "Miscelatore colore", + "Color Noise Reduction": "Riduzione disturbo colore", + "Color Priority": "Priorità colore", + "Color Range": "Intervallo colori", + "Color label cleared": "Etichetta colore tolta", + "Color space": "Spazio colore", + "Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)", + "Colors": "Colori", + "Combine files…": "Combina file…", + "Command Journal": "Registro dei comandi", + "Comments panel": "Pannello commenti", + "Commits": "Commit", + "Company": "Azienda", + "Compare": "Confronta", + "Compare (Shift+C)": "Confronta (Maiusc+C)", + "Compare Before and After": "Confronta prima e dopo", + "Compare needs at least two photos": "Per confrontare servono almeno due foto", + "Component options": "Opzioni del componente", + "Compression": "Compressione", + "Concrete Cool": "Cemento freddo", + "Confirm before deleting": "Chiedi conferma prima di eliminare", + "Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente", + "Constrain Crop": "Vincola il ritaglio", + "Continue": "Continua", + "Continue Without Saving": "Continua senza salvare", + "Contrast": "Contrasto", + "Contributors": "Collaboratori", + "Convert to DNG": "Converti in DNG", + "Cool Fade": "Dissolvenza fredda", + "Cool Morning": "Mattino fresco", + "Copy": "Copia", + "Copy After's Settings to Before": "Copia le impostazioni di Dopo in Prima", + "Copy Before to After": "Copia Prima in Dopo", + "Copy Before's Settings to After": "Copia le impostazioni di Prima in Dopo", + "Copy Edit Settings": "Copia impostazioni di modifica", + "Copy History Step Settings to Before": "Copia le impostazioni del passaggio in Prima", + "Copy Metadata": "Copia metadati", + "Copy as DNG": "Copia come DNG", + "Copy into the library (else use the files where they are)": "Copia nella libreria (altrimenti usa i file dove si trovano)", + "Copy pages": "Copia pagine", + "Copy the files (into the library's Originals/, or a folder you choose)": "Copia i file (negli Originals/ della libreria, o in una cartella a tua scelta)", + "Copy to": "Copia in", + "Copy to Clipboard": "Copia negli appunti", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Copia: i file vengono copiati nella cartella qui sotto e la libreria usa le copie; gli originali restano come sono.", + "Copyright": "Copyright", + "Copyright Info URL": "URL informazioni copyright", + "Copyright Status": "Stato del copyright", + "Country": "Paese", + "Create Album": "Crea album", + "Create Album…": "Crea album…", + "Create Folder": "Crea cartella", + "Create Folder…": "Crea cartella…", + "Create New Mask": "Crea nuova maschera", + "Create PDF from clipboard": "Crea PDF dagli appunti", + "Create PDF from file…": "Crea PDF da file…", + "Create PDF from images…": "Crea PDF da immagini…", + "Create Preset": "Crea preset", + "Create Preset…": "Crea preset…", + "Create Proof Copy": "Crea copia di prova", + "Create Smart Album": "Crea album smart", + "Create Smart Album from Filter…": "Crea album smart dal filtro…", + "Create Smart Album…": "Crea album smart…", + "Create Stack": "Crea pila", + "Create Version": "Crea versione", + "Create Version from Step": "Crea versione dal passaggio", + "Create Virtual Copy": "Crea copia virtuale", + "Creator": "Autore", + "Creator metadata": "Metadati dell'autore", + "Crisp Landscape": "Paesaggio nitido", + "Crisp Vista": "Panorama nitido", + "Crop": "Ritaglia", + "Crop & Rotate": "Ritaglia e ruota", + "Crop & Rotate (C)": "Ritaglia e ruota (C)", + "Crop Aspect": "Proporzioni del ritaglio", + "Crop, Rotate, Geometry": "Ritaglio, rotazione, geometria", + "Cross Process": "Processo incrociato", + "Culling": "Selezione", + "Curve": "Curva", + "Custom": "Personalizzato", + "Custom template…": "Modello personalizzato…", + "Custom…": "Personalizzato…", + "Cut": "Taglia", + "Cut pages": "Taglia pagine", + "Cycle Crop Overlay": "Cambia sovrapposizione del ritaglio", + "Cycle Crop Overlay Orientation": "Cambia orientamento della sovrapposizione del ritaglio", + "Cycle Info Overlay": "Cambia sovrapposizione delle informazioni", + "Cycle Mask Overlay Color": "Cambia colore della sovrapposizione maschera", + "Cycle Mask Overlay Mode": "Cambia modalità della sovrapposizione maschera", + "Cylindrical": "Cilindrica", + "Dark & Moody": "Scuro e intenso", + "Darken": "Scurisci", + "Darks": "Toni scuri", + "Date": "Data", + "Date Groups": "Gruppi per data", + "Day": "Giorno", + "Daylight": "Luce diurna", + "Decrease Brush Feather": "Riduci sfumatura del pennello", + "Decrease Brush Size": "Riduci dimensione del pennello", + "Deep Sky": "Cielo profondo", + "Defringe": "Elimina frange", + "Deghost Amount": "Quantità di rimozione fantasmi", + "Dehaze": "Elimina foschia", + "Delete": "Elimina", + "Delete (⌫)": "Elimina (⌫)", + "Delete Album": "Elimina album", + "Delete All Masks": "Elimina tutte le maschere", + "Delete Color Label Set": "Elimina set di etichette colore", + "Delete Component": "Elimina componente", + "Delete Export Preset": "Elimina preset di esportazione", + "Delete Filter Preset": "Elimina preset di filtro", + "Delete Imported Profile": "Elimina profilo importato", + "Delete Keyword": "Elimina parola chiave", + "Delete Keyword Set": "Elimina set di parole chiave", + "Delete Mask": "Elimina maschera", + "Delete Metadata Preset": "Elimina preset di metadati", + "Delete Permanently": "Elimina definitivamente", + "Delete Photo": "Elimina foto", + "Delete Photos": "Elimina foto", + "Delete Point Color": "Elimina colore puntuale", + "Delete Point Color Sample": "Elimina campione colore puntuale", + "Delete Point Curve Preset": "Elimina preset di curva a punti", + "Delete Preset": "Elimina preset", + "Delete Red Eye": "Elimina occhi rossi", + "Delete Red Eye Correction": "Elimina correzione occhi rossi", + "Delete Spot": "Elimina macchia", + "Delete Version": "Elimina versione", + "Delete all": "Elimina tutto", + "Delete all spots": "Elimina tutte le macchie", + "Delete mask": "Elimina maschera", + "Delete them": "Eliminale", + "Density": "Densità", + "Depth Range": "Intervallo di profondità", + "Descending": "Decrescente", + "Describe": "Descrivi", + "Describe what to keep": "Descrivi cosa tenere", + "Describe what to leave out": "Descrivi cosa escludere", + "Describe what to select": "Descrivi cosa selezionare", + "Describe…": "Descrivi…", + "Deselect": "Deseleziona", + "Deselect All": "Deseleziona tutto", + "Desert Heat": "Calore del deserto", + "Desert Warmth": "Tepore del deserto", + "Desktop": "Scrivania", + "Detail": "Dettaglio", + "Detail (D)": "Dettaglio (D)", + "Details": "Dettagli", + "Diagonal": "Diagonale", + "Discard Preview Cache": "Elimina cache delle anteprime", + "Display P3": "Display P3", + "Display name": "Nome visualizzato", + "Display theme": "Tema di visualizzazione", + "Distortion": "Distorsione", + "Document properties…": "Proprietà documento…", + "Don't enlarge": "Non ingrandire", + "Done": "Fine", + "Download": "Scarica", + "Download AI Mask Model": "Scarica il modello IA per le maschere", + "Download the SAM 3 Model?": "Scaricare il modello SAM 3?", + "Downloads": "Download", + "Drag along the horizon; double-click for Auto": "Trascina lungo l'orizzonte; doppio clic per Automatico", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Trascina sopra un occhio nella foto; la pupilla all'interno viene trovata da sola.", + "Draw Guides": "Disegna guide", + "Duplicate": "Duplica", + "Duplicate Component": "Duplica componente", + "Duplicate Mask": "Duplica maschera", + "Duplicate and Invert Mask": "Duplica e inverti maschera", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Ogni / apre un livello di cartella; i tag vengono compilati per ogni foto (un livello senza metadati diventa \"sconosciuto\").", + "Edge": "Bordo", + "Edit": "Modifica", + "Edit (E)": "Modifica (E)", + "Edit Capture Time": "Modifica ora di scatto", + "Edit Capture Time…": "Modifica ora di scatto…", + "Edit Color Label Names": "Modifica nomi delle etichette colore", + "Edit Color Label Names…": "Modifica nomi delle etichette colore…", + "Edit Copy for External Editor": "Modifica copia nell'editor esterno", + "Edit Date": "Modifica data", + "Edit Info": "Modifica informazioni", + "Edit Label Names": "Modifica nomi delle etichette", + "Edit Label Names…": "Modifica nomi delle etichette…", + "Edit Mask": "Modifica maschera", + "Edit Mask Component": "Modifica componente della maschera", + "Edit Sections": "Modifica sezioni", + "Edit Smart Album": "Modifica album smart", + "Edit Smart Album…": "Modifica album smart…", + "Edit Spot": "Modifica macchia", + "Edit in External Editor": "Modifica nell'editor esterno", + "Edit settings copied": "Impostazioni di modifica copiate", + "Edited": "Modificata", + "Edited or not": "Modificata o no", + "Editing": "Modifica", + "Effect": "Effetto", + "Effects": "Effetti", + "Empty Recently Deleted": "Svuota Eliminate di recente", + "Enable Lens Corrections": "Attiva correzioni obiettivo", + "Enable Profile Corrections": "Attiva correzioni del profilo", + "End Interaction": "Termina interazione", + "English": "Inglese", + "Enter Full Screen": "Schermo intero", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Inserisci una data come AAAA-MM-GG HH:MM:SS", + "Erase": "Cancella", + "Every EXIF, GPS and XMP field in the file": "Ogni campo EXIF, GPS e XMP del file", + "Existing previews": "Anteprime esistenti", + "Exit": "Esci", + "Expand": "Espandi", + "Expand All Stacks": "Espandi tutte le pile", + "Expand Stack": "Espandi pila", + "Expand/Collapse Stack": "Espandi/comprimi pila", + "Export": "Esporta", + "Export Album with Preset": "Esporta album con preset", + "Export Album…": "Esporta album…", + "Export As…": "Esporta come…", + "Export Group…": "Esporta gruppo…", + "Export Now": "Esporta ora", + "Export Point Curve Presets": "Esporta preset di curva a punti", + "Export Point Curve Presets…": "Esporta preset di curva a punti…", + "Export Presets": "Esporta predefiniti", + "Export Presets…": "Esporta preset…", + "Export User Presets…": "Esporta preset utente…", + "Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto", + "Export with Preset": "Esporta con preset", + "Export with Previous": "Esporta con le impostazioni precedenti", + "Exporting": "Esportazione in corso", + "Export…": "Esporta…", + "Exposure": "Tempo di esposizione", + "Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)", + "Extended Description": "Descrizione estesa", + "External Editor": "Editor esterno", + "Face Boxes": "Riquadri dei volti", + "Face boxes": "Riquadri dei volti", + "Face names written to XMP by Lightroom and other apps show up here.": "Qui compaiono i nomi dei volti scritti nell'XMP da Lightroom e da altre app.", + "Faded Matte": "Opaco sbiadito", + "Faded Slide": "Diapositiva sbiadita", + "Faded Urban": "Città sbiadita", + "Favorite Preset": "Preset preferito", + "Favorite Profile": "Profilo preferito", + "Favorites": "Preferiti", + "Feather": "Sfumatura", + "File": "File", + "File & date": "File e data", + "File Format": "Formato file", + "File Name": "Nome file", + "File Path": "Percorso del file", + "File Size": "Dimensione del file", + "File Type": "Tipo di file", + "File name": "Nome file", + "File types:": "Tipi di file:", + "Filename": "Nome file", + "Fill": "Riempi", + "Fill Edges": "Riempi i bordi", + "Film": "Pellicola", + "Filmstrip": "Striscia", + "Filmstrip (/)": "Striscia (/)", + "Filter": "Filtro", + "Filter Bar": "Barra dei filtri", + "Filter Presets": "Preset di filtro", + "Filter bar": "Barra dei filtri", + "Filter fields": "Campi del filtro", + "Find Dust Spots": "Trova macchie di polvere", + "Find Missing Photos": "Trova foto mancanti", + "Find Missing Photos…": "Trova foto mancanti…", + "Find Similar Photos": "Trova foto simili", + "Find tools and commands…": "Trova strumenti e comandi…", + "Find…": "Trova…", + "First": "Primo", + "First commit": "Primo commit", + "Fit": "Adatta", + "Fit (F)": "Adatta (F)", + "Fit on Screen": "Adatta allo schermo", + "Fit to width": "Adatta alla larghezza", + "Fit visible": "Adatta all'area visibile", + "Five stars": "Cinque stelle", + "Fixed keys": "Tasti fissi", + "Flag": "Bandiera", + "Flag as Pick": "Contrassegna come scelta", + "Flag as Reject": "Contrassegna come scartata", + "Flagged as Pick": "Contrassegnata come scelta", + "Flagged as Reject": "Contrassegnata come scartata", + "Flash": "Flash", + "Flip H": "Capovolgi O", + "Flip Horizontal": "Capovolgi orizzontalmente", + "Flip Overlay (⇧O)": "Capovolgi sovrapposizione (⇧O)", + "Flip V": "Capovolgi V", + "Flip Vertical": "Capovolgi verticalmente", + "Float panel": "Rendi pannello flottante", + "Flow": "Flusso", + "Fluorescent": "Fluorescente", + "Focal Length": "Lunghezza focale", + "Focal length": "Lunghezza focale", + "Focus (assisted culling)": "Messa a fuoco (selezione assistita)", + "Folder": "Cartella", + "Folder name (renamed on disk; its photos follow)": "Nome della cartella (rinominata sul disco; le sue foto la seguono)", + "Folders": "Cartelle", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Le cartelle e {date} usano l'ora di scatto; una foto che non ce l'ha usa la data di oggi.", + "Food": "Cibo", + "Forget Unchanged Local Photos": "Dimentica le foto locali non modificate", + "Forget unchanged photos": "Dimentica le foto non modificate", + "Form fields panel": "Pannello campi modulo", + "Format": "Formato", + "Forward": "Avanti", + "Frame time": "Tempo per fotogramma", + "Frame update": "Aggiornamento del fotogramma", + "Free": "Libero", + "Fresh & Bright": "Fresco e luminoso", + "Friday": "Venerdì", + "Full": "Completo", + "Full Screen Preview": "Anteprima a schermo intero", + "Lights Out": "Luci spente", + "Full screen mode": "Modalità schermo intero", + "Full size": "Dimensione piena", + "GPS Track Log": "Tracciato GPS", + "GPU Rendering": "Rendering con GPU", + "GPU rendering": "Rendering con GPU", + "General": "Generale", + "Generated demo photo": "Foto dimostrativa generata", + "Geometry": "Geometria", + "Get Develop Settings": "Ottieni impostazioni di sviluppo", + "Global Hue": "Tonalità globale", + "Global Luminance": "Luminanza globale", + "Global Saturation": "Saturazione globale", + "Glossy": "Lucido", + "Go to History Step": "Vai al passaggio della cronologia", + "Golden": "Dorato", + "Golden Glow": "Bagliore dorato", + "Golden Hour": "Ora d'oro", + "Grab bag": "Varie", + "Grain": "Grana", + "Graphic": "Grafica", + "Green": "Verde", + "Green Amount": "Quantità verde", + "Green Hue": "Tonalità verde", + "Green Hue High": "Tonalità verde alta", + "Green Hue Low": "Tonalità verde bassa", + "Green Luminance": "Luminanza verde", + "Green Primary": "Primario verde", + "Green Saturation": "Saturazione verde", + "Green channel": "Canale verde", + "Green label": "Etichetta verde", + "Grid": "Griglia", + "Grid Info": "Informazioni della griglia", + "Gritty Street": "Strada grintosa", + "Group": "Gruppo", + "Group by Date: Automatic": "Raggruppa per data: automatico", + "Group by Day": "Raggruppa per giorno", + "Group by Month": "Raggruppa per mese", + "Group by Year": "Raggruppa per anno", + "Group by date": "Raggruppa per data", + "Group into Stack": "Raggruppa in una pila", + "Group name": "Nome gruppo", + "Guided": "Guidato", + "Guided Upright": "Raddrizzamento guidato", + "Guided Upright Guides": "Guide del raddrizzamento guidato", + "HDR Merge": "Unione HDR", + "HDR Merge Preview": "Anteprima unione HDR", + "HDR Panorama Merge": "Unione panorama HDR", + "HDR Panorama Merge Preview": "Anteprima unione panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR con le ultime impostazioni", + "HDR Panoramas": "Panorami HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panorami HDR", + "HDR with Last Settings": "HDR con le ultime impostazioni", + "HDR…": "HDR…", + "Has Edits": "Con modifiche", + "Has GPS": "Con GPS", + "Heal": "Correggi", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Correggi le piccole macchie scure e sfumate che la polvere del sensore lascia sulle zone uniformi", + "Height": "Altezza", + "Height (px)": "Altezza (px)", + "Help": "Aiuto", + "Hide LightCraft": "Nascondi LightCraft", + "Hide Mask": "Nascondi maschera", + "Hide Others": "Nascondi altri", + "Hides this shortcut only; the folder and its photos stay as they are": "Nasconde solo questa scorciatoia; la cartella e le sue foto restano come sono", + "High": "Alta", + "High Contrast B&W": "B/N ad alto contrasto", + "Highlight": "Luci", + "Highlights": "Luci", + "Highlights Hue": "Tonalità delle luci", + "Highlights Luminance": "Luminanza delle luci", + "Highlights Saturation": "Saturazione delle luci", + "Highlights split": "Divisione delle luci", + "Histogram": "Istogramma", + "History": "Cronologia", + "History & Activity (Y)": "Cronologia e attività (Y)", + "Home": "Cartella personale", + "Horizontal": "Orizzontale", + "Horizontal inset (% of short edge)": "Margine orizzontale (% del lato corto)", + "Horizontal text": "Testo orizzontale", + "Hours": "Ore", + "How to install the model": "Come installare il modello", + "Hue": "Tonalità", + "Hue Range": "Intervallo di tonalità", + "ISO Speed": "Sensibilità ISO", + "ISO speed": "Sensibilità ISO", + "If file exists": "Se il file esiste", + "Image": "Immagine", + "Import": "Importa", + "Import Date": "Data di importazione", + "Import Lightroom Catalog": "Importa catalogo Lightroom", + "Import Lightroom Catalog…": "Importa catalogo Lightroom…", + "Import Photos": "Importa foto", + "Import Photos…": "Importa foto…", + "Import Point Curve Presets": "Importa preset di curva a punti", + "Import Point Curve Presets…": "Importa preset di curva a punti…", + "Import Presets": "Importa predefiniti", + "Import Presets…": "Importa preset…", + "Import Profiles": "Importa profili", + "Import Profiles & Presets…": "Importa profili e preset…", + "Import as new": "Importa come nuova", + "Import from Device": "Importa da dispositivo", + "Import from Folder…": "Importa da cartella…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importa le foto con File → Importa foto… (Cmd+Maiusc+I), oppure trascinale qui", + "Imported": "Importato", + "Imported Photos": "Foto importate", + "Importing": "Importazione in corso", + "Importing Lightroom Catalog": "Importazione del catalogo Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "L'importazione cerca le foto nell'origine; non aggiunge la cartella a Locale (usa Locale → Sfoglia cartella… per lavorare in una cartella senza importare).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "In Griglia foto e Griglia quadrata: contrassegna come scelta e avanza", + "In Photo Grid and Square Grid: expand or collapse the stack": "In Griglia foto e Griglia quadrata: espandi o comprimi la pila", + "In Recently Deleted": "In Eliminate di recente", + "In library": "Nella libreria", + "In memory — nothing is saved": "In memoria: non viene salvato niente", + "In use": "In uso", + "In-memory session: these settings last until LightCraft quits.": "Sessione in memoria: queste impostazioni durano finché LightCraft resta aperto.", + "Include subfolders": "Includi sottocartelle", + "Increase Brush Feather": "Aumenta sfumatura del pennello", + "Increase Brush Size": "Aumenta dimensione del pennello", + "Info": "Informazioni", + "Info (I)": "Informazioni (I)", + "Info Overlay Off": "Sovrapposizione informazioni disattivata", + "Info Overlay: Exposure": "Sovrapposizione informazioni: esposizione", + "Info Overlay: File": "Sovrapposizione informazioni: file", + "Info overlay": "Sovrapposizione informazioni", + "Insert at the cursor": "Inserisci al cursore", + "Inspect Lightroom Catalog": "Esamina catalogo Lightroom", + "Inspect Photo": "Esamina foto", + "Inspecting Lightroom Catalog": "Analisi del catalogo Lightroom", + "Install the SAM 3 Model": "Installa il modello SAM 3", + "Instant ’70s": "Istantanea anni '70", + "Interface": "Interfaccia", + "Interface language": "Lingua dell'interfaccia", + "Intersect": "Intersezione", + "Intersect with": "Interseca con", + "Into one folder": "In un'unica cartella", + "Invert": "Inverti", + "Invert Component": "Inverti componente", + "Invert Mask": "Inverti maschera", + "JPEG (Large)": "JPEG (grande)", + "JPEG (Small)": "JPEG (piccolo)", + "Join the ArtCraft Discord": "Unisciti al Discord di ArtCraft", + "Join the ArtCraft Discord…": "Unisciti al Discord di ArtCraft…", + "Join the ArtCraft community on Discord": "Unisciti alla community di ArtCraft su Discord", + "Keep in Local": "Tieni in Locale", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Tienila su un disco con spazio libero: le anteprime avanzate occupano circa 1 MB per foto. La cache delle miniature resta nella libreria.", + "Keyboard Shortcuts": "Scorciatoie da tastiera", + "Keyboard shortcuts": "Scorciatoie da tastiera", + "Keyword": "Parola chiave", + "Keyword Painter": "Pennello parole chiave", + "Keyword Set": "Set di parole chiave", + "Keyword Sets": "Set di parole chiave", + "Keyword Suggestions": "Suggerimenti di parole chiave", + "Keyword to paint": "Parola chiave da applicare", + "Keywords": "Parole chiave", + "Keywords (K)": "Parole chiave (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Le parole chiave che aggiungi compaiono qui; ⌥1-⌥9 le applicano.", + "Label": "Etichetta", + "Landscape": "Paesaggio", + "Language": "Lingua", + "Last": "Ultimo", + "Last GPU fallback": "Ultimo ripiego dalla GPU", + "Last commit": "Ultimo commit", + "Last loupe render": "Ultimo rendering della lente", + "Last view": "Ultima vista", + "Lateral Chromatic Aberration": "Aberrazione cromatica laterale", + "Layer": "Livello", + "Layers": "Livelli", + "Leave them": "Lasciale", + "Lens": "Obiettivo", + "Lens Blur": "Sfocatura obiettivo", + "Lens Corrections": "Correzioni obiettivo", + "Level": "Livello", + "Library": "Libreria", + "Library Info": "Informazioni sulla libreria", + "Library Originals": "Originali della libreria", + "Library Preferences": "Preferenze della libreria", + "Library State": "Stato della libreria", + "Library saved": "Libreria salvata", + "Licence:": "Licenza:", + "Light": "Luce", + "LightCraft Default": "Predefinito di LightCraft", + "LightCraft Help": "Aiuto di LightCraft", + "LightCraft Preset": "Preset di LightCraft", + "LightCraft Presets": "Preset di LightCraft", + "LightCraft Website": "Sito web di LightCraft", + "LightCraft could not start": "Impossibile avviare LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft non ha trovato dove salvare la tua libreria.", + "LightCraft couldn't open the library at": "LightCraft non è riuscito ad aprire la libreria in", + "LightCraft on GitHub": "LightCraft su GitHub", + "LightCraft website": "Sito web di LightCraft", + "LightCraft — Second Window": "LightCraft: seconda finestra", + "Lightroom Classic Catalog": "Catalogo di Lightroom Classic", + "Lightroom import cancelled": "Importazione da Lightroom annullata", + "Lightroom inspection cancelled": "Analisi di Lightroom annullata", + "Lights": "Toni chiari", + "Limit file size (KB, 0 = off)": "Limita dimensione del file (KB, 0 = nessun limite)", + "Line delta": "Differenza righe", + "Linear": "Lineare", + "Linear Gradient": "Sfumatura lineare", + "Lines +/−": "Righe +/−", + "Lines added": "Righe aggiunte", + "Lines deleted": "Righe eliminate", + "List Albums": "Elenca album", + "List Develop Controls": "Elenca controlli di sviluppo", + "List History": "Elenca cronologia", + "List Presets": "Elenca preset", + "List Profiles": "Elenca profili", + "List this folder in Local from now on": "Mostra d'ora in poi questa cartella in Locale", + "Loading preview…": "Caricamento anteprima…", + "Local": "Locale", + "Local folders": "Cartelle locali", + "Local library — no cloud account needed": "Libreria locale: non serve un account cloud", + "Locate Missing File…": "Trova file mancante…", + "Locate Photo": "Trova foto", + "Location": "Posizione", + "Lock": "Blocca", + "Lock Crop Aspect": "Blocca proporzioni del ritaglio", + "Locked": "Bloccato", + "Long Edge": "Lato lungo", + "Looking for photos…": "Ricerca delle foto…", + "Lossless": "Senza perdita", + "Low": "Bassa", + "Luminance": "Luminanza", + "Luminance Range": "Intervallo di luminanza", + "Lush Greens": "Verdi rigogliosi", + "Magenta": "Magenta", + "Magenta Hue": "Tonalità magenta", + "Magenta Luminance": "Luminanza magenta", + "Magenta Saturation": "Saturazione magenta", + "Make Candidate the Select": "Rendi la candidata quella scelta", + "Manual": "Manuale", + "Marquee zoom": "Ingrandimento selettivo", + "Mask Adjustment": "Regolazione della maschera", + "Masking": "Mascheratura", + "Masking (M)": "Maschere (M)", + "Masks": "Maschere", + "Match": "Corrispondenza", + "Match Total Exposures": "Uniforma le esposizioni totali", + "Matte": "Opaca", + "Matte Soft": "Opaco morbido", + "Med": "Medio", + "Medium Contrast": "Contrasto medio", + "Megapixels": "Megapixel", + "Memory Budget": "Memoria disponibile", + "Memory Usage": "Uso della memoria", + "Memory budget": "Memoria disponibile", + "Memory for caches": "Memoria per le cache", + "Menu": "Menu", + "Merge": "Unisci", + "Merge Keywords": "Unisci parole chiave", + "Merge into…": "Unisci in…", + "Merged": "Unita", + "Merged PRs": "PR unite", + "Merging…": "Unione in corso…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Sono condizioni di Meta, non di LightCraft. Scaricandolo le accetti.", + "Metadata": "Metadati", + "Metadata Preset": "Preset di metadati", + "Metadata Presets": "Preset di metadati", + "Metadata preset": "Preset di metadati", + "Midpoint": "Punto medio", + "Midtones": "Mezzi toni", + "Midtones Hue": "Tonalità dei mezzitoni", + "Midtones Luminance": "Luminanza dei mezzitoni", + "Midtones Saturation": "Saturazione dei mezzitoni", + "Midtones split": "Divisione dei mezzitoni", + "Minimize": "Riduci a icona", + "Missing Photos": "Foto mancanti", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "I metadati mancanti ({camera}, {title}…) lasciano uno spazio vuoto; un nome che risulta vuoto mantiene il nome originale.", + "Misty Morning": "Mattino nebbioso", + "Mode": "Metodo", + "Model": "Modello", + "Models": "Modelli", + "Modified Date": "Data di modifica", + "Monday": "Lunedì", + "Mono Red Filter": "Mono con filtro rosso", + "Mono Rich": "Mono ricco", + "Mono Soft": "Mono morbido", + "Monochrome": "Monocromia", + "Month": "Mese", + "Moody": "Cupo", + "Moody Portrait": "Ritratto cupo", + "More": "Altro", + "More preset options": "Altre opzioni del preset", + "Move": "Sposta", + "Move Album": "Sposta album", + "Move Down": "Sposta giù", + "Move Down in Stack": "Sposta giù nella pila", + "Move Folder": "Sposta cartella", + "Move Folder To…": "Sposta cartella in…", + "Move Mask": "Sposta maschera", + "Move Preset to Group": "Sposta preset nel gruppo", + "Move Preset to New Group": "Sposta preset in un nuovo gruppo", + "Move Up": "Sposta su", + "Move Up in Stack": "Sposta su nella pila", + "Move in Stack": "Sposta nella pila", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Sposta i file (negli Originals/ della libreria, o in una cartella a tua scelta), togliendoli dall'origine", + "Move them": "Spostale", + "Move to": "Sposta in", + "Move to Group": "Sposta nel gruppo", + "Move to group": "Sposta nel gruppo", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Sposta: i file vengono spostati nella cartella qui sotto; ogni originale (con il suo XMP collaterale) viene tolto dall'origine solo dopo aver verificato la copia. I duplicati e i file con errori restano dove sono.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Sposta in Eliminate di recente ogni foto importata da questo disco; nessun file viene toccato", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Sposta in Eliminate di recente le foto importate da questa cartella; nessun file viene toccato", + "Muted": "Attenuato", + "Muted Film": "Pellicola attenuata", + "My Photos": "Le mie foto", + "My Photos Panel": "Pannello Le mie foto", + "Name": "Nome", + "Name (A–Z)": "Nome (A-Z)", + "Name of the folder the original is in": "Nome della cartella in cui si trova l'originale", + "Named": "Con nome", + "Named People": "Persone con nome", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "I nomi compaiono nel menu delle etichette, nella barra dei filtri e nel pannello Informazioni, e vengono scritti nell'XMP. Vuoto = il nome del colore.", + "Navigator": "Navigatore", + "Needs Work": "Da rivedere", + "Neon Dusk": "Tramonto al neon", + "Neon Night": "Notte al neon", + "Nested group": "Gruppo annidato", + "Neutral": "Neutro", + "Never": "Mai", + "New": "Nuovo", + "New Album": "Nuovo album", + "New Album…": "Nuovo album…", + "New Folder…": "Nuova cartella…", + "New Group…": "Nuovo gruppo…", + "New Smart Album": "Nuovo album smart", + "New Smart Album from Filter…": "Nuovo album smart dal filtro…", + "New Smart Album…": "Nuovo album smart…", + "New album": "Nuovo album", + "New album…": "Nuovo album…", + "New blank PDF": "Nuovo PDF vuoto", + "New name": "Nuovo nome", + "New time": "Nuova ora", + "New…": "Nuovo…", + "Next Photo": "Foto successiva", + "Night Blue": "Blu notte", + "No": "No", + "No Camera or Card Found": "Nessuna fotocamera o scheda trovata", + "No Date Groups": "Nessun raggruppamento per data", + "No Stars": "Nessuna stella", + "No Tool": "Nessuno strumento", + "No automatic versions.": "Nessuna versione automatica.", + "No camera": "Nessuna fotocamera", + "No contributor data was built into this copy.": "Questa copia non include dati sui collaboratori.", + "No date": "Nessuna data", + "No dust spots found": "Nessuna macchia di polvere trovata", + "No edits yet.": "Ancora nessuna modifica.", + "No exposure information": "Nessuna informazione di esposizione", + "No filter presets yet": "Ancora nessun preset di filtro", + "No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).", + "No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.", + "No matching commands": "Nessun comando corrispondente", + "No matching photos": "Nessuna foto corrispondente", + "No metadata found": "Nessun metadato trovato", + "No model credits were built into this copy.": "Questa copia non include i crediti dei modelli.", + "No named people in these photos": "In queste foto non ci sono persone con nome", + "No named people yet": "Ancora nessuna persona con nome", + "No new presets": "Nessun nuovo preset", + "No photo selected": "Nessuna foto selezionata", + "No photos": "Nessuna foto", + "No photos found": "Nessuna foto trovata", + "No photos in this folder": "Nessuna foto in questa cartella", + "No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi", + "No photos match the filters (View → Clear Filters)": "Nessuna foto corrisponde ai filtri (Visualizza → Cancella filtri)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Ancora nessuna foto raw: le fotocamere compaiono qui quando le loro foto sono nella libreria.", + "No reference photo": "Nessuna foto di riferimento", + "No usable GPU found: rendering on the CPU": "Nessuna GPU utilizzabile: rendering sulla CPU", + "No versions yet. Create one to keep this look.": "Ancora nessuna versione. Creane una per conservare questo aspetto.", + "Noise": "Disturbo", + "Noise Reduction": "Riduzione disturbo", + "Non-raw photos": "Foto non raw", + "None": "Nessuno", + "Not Now": "Non ora", + "Nothing to compare": "Niente da confrontare", + "Nothing to survey": "Niente da esaminare", + "Nudge Develop Value": "Sposta di poco un valore di sviluppo", + "OK": "OK", + "Object": "Oggetto", + "Object Mask": "Maschera oggetto", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Le maschere Oggetto e Descrivi usano SAM 3, il modello di segmentazione di Meta. Non fa parte di LightCraft, e tutto il resto funziona anche senza.", + "October 2026": "Ottobre 2026", + "Off": "Disattivato", + "Offset X": "Spostamento X", + "Offset Y": "Spostamento Y", + "Opacity": "Opacità", + "Opacity (%)": "Opacità (%)", + "Open Library": "Apri libreria", + "Open Library…": "Apri libreria…", + "Open in": "Apri in", + "Open in Detail": "Apri in Dettaglio", + "Open the place in OpenStreetMap": "Apri il luogo in OpenStreetMap", + "Open…": "Apri…", + "Optics": "Ottica", + "Optimize Library": "Ottimizza libreria", + "Optional name": "Nome facoltativo", + "Options": "Opzioni", + "Orange": "Arancione", + "Orange Hue": "Tonalità arancione", + "Orange Luminance": "Luminanza arancione", + "Orange Saturation": "Saturazione arancione", + "Orientation": "Orientamento", + "Original": "Originale", + "Original + Settings": "Originale + impostazioni", + "Original extension without the dot": "Estensione originale senza il punto", + "Original file name without its extension": "Nome del file originale senza estensione", + "Original files": "File originali", + "Original offline · editing the smart preview": "Originale non disponibile · si modifica l'anteprima avanzata", + "Original offline · no smart preview": "Originale non disponibile · nessuna anteprima avanzata", + "Originals": "Originali", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Altre immagini (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Sovrapponi", + "Overwrite": "Sovrascrivi", + "Pages": "Pagine", + "Paint": "Dipingi", + "Paint only areas like the one under the brush": "Dipingi solo le zone simili a quella sotto il pennello", + "Paint over a distraction on the photo to remove it.": "Dipingi sopra un elemento di disturbo nella foto per rimuoverlo.", + "Paint overlay": "Sovrapposizione della pittura", + "Panels": "Pannelli", + "Panorama": "Panorama", + "Panorama Merge": "Unione panorama", + "Panorama Merge Preview": "Anteprima unione panorama", + "Panorama with Last Settings": "Panorama con le ultime impostazioni", + "Panoramas": "Panorami", + "Panorama…": "Panorama…", + "Parametric curve": "Curva parametrica", + "Paste": "Incolla", + "Paste Edit Settings": "Incolla impostazioni di modifica", + "Paste Metadata": "Incolla metadati", + "Paste Selected Settings": "Incolla impostazioni selezionate", + "Paste Selected Settings…": "Incolla impostazioni selezionate…", + "Paste Settings": "Incolla impostazioni", + "Paste Settings from Previous": "Incolla impostazioni dalla precedente", + "Paste from Previous": "Incolla dalla precedente", + "Paste pages": "Incolla pagine", + "Pastel Haze": "Foschia pastello", + "People": "Persone", + "Percent": "Percentuale", + "Percentage": "Percentuale", + "Performance": "Prestazioni", + "Person": "Persona", + "Perspective": "Prospettiva", + "Pet Eye": "Occhi degli animali", + "Pet Eye Catchlight": "Riflesso negli occhi degli animali", + "Photo": "Foto", + "Photo Grid": "Griglia foto", + "Photo Grid (G)": "Griglia foto (G)", + "Photo Merge": "Unione di foto", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).", + "Photos": "Foto", + "Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.", + "Pick": "Scelta", + "Pick Flag": "Contrassegno scelta", + "Pick a colour on the photo with the eyedropper.": "Scegli un colore sulla foto con il contagocce.", + "Pick the sharpest photo of each burst": "Scegli la foto più nitida di ogni raffica", + "Picked": "Scelta", + "Picks": "Scelte", + "Pictures": "Immagini", + "Pixels": "Pixel", + "Point Color": "Colore puntuale", + "Point Curve": "Curva a punti", + "Point Curve Presets": "Preset di curva a punti", + "Point curve": "Curva a punti", + "Portrait": "Ritratto", + "Position": "Posizione", + "Preferences": "Preferenze", + "Preferences…": "Preferenze…", + "Prepare AI Masks": "Prepara le maschere IA", + "Preset": "Predefinito", + "Preset name": "Nome del preset", + "Presets": "Predefiniti", + "Presets & Profiles": "Preset e profili", + "Presets (Shift+P)": "Preset (Maiusc+P)", + "Presets and profiles": "Preset e profili", + "Presets ▾": "Preset ⌄", + "Press keys…": "Premi tasti…", + "Preview": "Anteprima", + "Preview Build Progress": "Avanzamento della generazione anteprime", + "Preview only": "Solo anteprima", + "Preview size": "Dimensione dell'anteprima", + "Preview unavailable": "Anteprima non disponibile", + "Previews": "Anteprime", + "Previous Photo": "Foto precedente", + "Print": "Stampa", + "Print…": "Stampa…", + "Profile": "Profilo", + "Profile Browser": "Browser dei profili", + "Profile Distortion": "Distorsione del profilo", + "Profile Menu": "Menu dei profili", + "Profile Vignetting": "Vignettatura del profilo", + "Profile:": "Profilo:", + "Profiles": "Profili", + "Projection": "Proiezione", + "Proof copy created": "Copia di prova creata", + "Properties": "Proprietà", + "Public domain": "Pubblico dominio", + "Pupil Size": "Dimensione pupilla", + "Purple": "Viola", + "Purple Amount": "Quantità viola", + "Purple Hue": "Tonalità viola", + "Purple Hue High": "Tonalità viola alta", + "Purple Hue Low": "Tonalità viola bassa", + "Purple Luminance": "Luminanza viola", + "Purple Saturation": "Saturazione viola", + "Put the locations you removed from Local back (no files change)": "Rimetti in Locale le posizioni che avevi tolto (nessun file cambia)", + "Quality": "Qualità", + "Query Photos": "Interroga le foto", + "Quick Collection": "Raccolta rapida", + "Quick Develop": "Sviluppo rapido", + "Quiet Green": "Verde quieto", + "Quit": "Esci", + "Quit Anyway": "Esci comunque", + "Quit LightCraft": "Esci da LightCraft", + "Quit with unsaved changes?": "Uscire con modifiche non salvate?", + "RAW decoding": "Decodifica RAW", + "Radial": "Radiale", + "Radial Gradient": "Sfumatura radiale", + "Radius": "Raggio", + "Random": "Casuale", + "Range": "Intervallo", + "Rate ★": "Valuta ★", + "Rate ★ and advance": "Valuta ★ e avanza", + "Rate ★★": "Valuta ★★", + "Rate ★★ and advance": "Valuta ★★ e avanza", + "Rate ★★★": "Valuta ★★★", + "Rate ★★★ and advance": "Valuta ★★★ e avanza", + "Rate ★★★★": "Valuta ★★★★", + "Rate ★★★★ and advance": "Valuta ★★★★ e avanza", + "Rate ★★★★★": "Valuta ★★★★★", + "Rate ★★★★★ and advance": "Valuta ★★★★★ e avanza", + "Rating": "Valutazione", + "Rating cleared": "Valutazione tolta", + "Ratings & flags": "Valutazioni e contrassegni", + "Raw": "Raw", + "Raw default": "Predefinito raw", + "Raw defaults": "Predefiniti raw", + "Raw files": "File raw", + "Raw photos": "Foto raw", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Le foto raw come DNG, con le modifiche incorporate. Le opzioni di dimensione, colore e uscita non si applicano.", + "Read Metadata from File": "Leggi i metadati dal file", + "Read mode": "Modalità lettura", + "Read the SAM License": "Leggi la licenza SAM", + "Reading": "Lettura", + "Reading Lightroom catalog…": "Lettura del catalogo Lightroom…", + "Reading folder…": "Lettura della cartella…", + "Real name": "Nome reale", + "Recent": "Recenti", + "Recent Keywords": "Parole chiave recenti", + "Recently Added": "Aggiunte di recente", + "Recently Deleted": "Eliminate di recente", + "Red": "Rosso", + "Red Eye": "Occhi rossi", + "Red Hue": "Tonalità rosso", + "Red Luminance": "Luminanza rosso", + "Red Primary": "Primario rosso", + "Red Saturation": "Saturazione rosso", + "Red channel": "Canale rosso", + "Red label": "Etichetta rossa", + "Red/Cyan Fringe": "Frange rosse/ciano", + "Redo": "Ripeti", + "Reference": "Riferimento", + "Reference View": "Vista di riferimento", + "Reference the files where they are": "Usa i file dove si trovano", + "Refine": "Rifinisci", + "Refine AI Mask Detail": "Rifinisci il dettaglio della maschera IA", + "Refine Edges": "Rifinisci i bordi", + "Refine Mask Edges": "Rifinisci i bordi della maschera", + "Refine Saturation": "Rifinisci la saturazione", + "Refining the mask's detail…": "Rifinitura del dettaglio della maschera…", + "Refresh Source": "Aggiorna l'origine", + "Refresh Source (/)": "Aggiorna l'origine (/)", + "Reject": "Scarta", + "Reject below focus": "Scarta sotto la soglia di messa a fuoco", + "Rejected": "Rifiutato", + "Rejects": "Scartate", + "Reliability": "Affidabilità", + "Relink Photo": "Ricollega foto", + "Reload Changed Files": "Ricarica i file modificati", + "Reload from Disk": "Ricarica dal disco", + "Remove": "Rimuovi", + "Remove (H)": "Rimuovi (H)", + "Remove CA": "Rimuovi AC", + "Remove Chromatic Aberration": "Rimuovi aberrazione cromatica", + "Camera local tone mapping": "Mappatura tonale locale della fotocamera", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Applica la mappatura tonale locale salvata nel raw (Apple ProRAW), come la mostra la fotocamera", + "Remove Disk from Library": "Rimuovi disco dalla libreria", + "Remove Disk from Library…": "Rimuovi disco dalla libreria…", + "Remove Face Box": "Rimuovi riquadro del volto", + "Remove Folder from Library": "Rimuovi cartella dalla libreria", + "Remove a filter above, or choose Clear all": "Togli un filtro qui sopra, oppure scegli Cancella tutto", + "Remove from Album": "Rimuovi dall'album", + "Remove from Favorites": "Rimuovi dai preferiti", + "Remove from Library…": "Rimuovi dalla libreria…", + "Remove from Local": "Rimuovi da Locale", + "Remove from Photo": "Rimuovi dalla foto", + "Remove from Selected Photos": "Rimuovi dalle foto selezionate", + "Remove from Stack": "Rimuovi dalla pila", + "Remove from Target Album": "Rimuovi dall'album di destinazione", + "Remove from survey": "Rimuovi dalla panoramica", + "Remove location info": "Rimuovi le informazioni sulla posizione", + "Remove shortcut": "Rimuovi scorciatoia", + "Remove this face box (undo with Edit ▸ Undo)": "Rimuovi questo riquadro del volto (annulla con Modifica → Annulla)", + "Remove this filter": "Rimuovi questo filtro", + "Remove this group": "Rimuovi questo gruppo", + "Remove this rule": "Rimuovi questa regola", + "Rename": "Rinomina", + "Rename Album": "Rinomina album", + "Rename Component": "Rinomina componente", + "Rename Folder": "Rinomina cartella", + "Rename Folder…": "Rinomina cartella…", + "Rename Keyword": "Rinomina parola chiave", + "Rename Keyword…": "Rinomina parola chiave…", + "Rename Mask": "Rinomina maschera", + "Rename Photos": "Rinomina foto", + "Rename Photos…": "Rinomina foto…", + "Rename Preset": "Rinomina preset", + "Rename Preview": "Anteprima della rinomina", + "Rename Template Tags": "Tag del modello di rinomina", + "Rename Version": "Rinomina versione", + "Rename…": "Rinomina…", + "Rendering": "Rendering", + "Rendering…": "Rendering in corso…", + "Reorder Masks": "Riordina maschere", + "Reset": "Reimposta", + "Reset All": "Reimposta tutto", + "Reset All Curves": "Reimposta tutte le curve", + "Reset All Keyboard Shortcuts": "Reimposta tutte le scorciatoie da tastiera", + "Reset Before to Import State": "Riporta Prima allo stato di importazione", + "Reset Control": "Reimposta controllo", + "Reset Crop": "Reimposta ritaglio", + "Reset Edits": "Reimposta modifiche", + "Reset Panel Layout": "Reimposta disposizione dei pannelli", + "Reset Parametric Curve": "Reimposta curva parametrica", + "Reset Section": "Reimposta sezione", + "Reset Tone Curve": "Reimposta curva dei toni", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)", + "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", + "Reshuffle": "Rimescola", + "Resize Face Box": "Ridimensiona riquadro del volto", + "Resize panel window": "Ridimensiona la finestra del pannello", + "Resize panels": "Ridimensiona pannelli", + "Resize to": "Ridimensiona a", + "Resolution (ppi)": "Risoluzione (ppi)", + "Restore": "Ripristina", + "Restore (no shortcut)": "Ripristina (nessuna scorciatoia)", + "Restore Library from Backup…": "Ripristina la libreria da un backup…", + "Restore Version": "Ripristina versione", + "Restore keeps their edits; Import as new deletes the old record first.": "Ripristina mantiene le loro modifiche; Importa come nuova elimina prima il vecchio record.", + "Restore them": "Ripristinale", + "Retouch": "Ritocco", + "Reverse the order": "Inverti l'ordine", + "Revert": "Ripristina", + "Review": "Revisione", + "Review Import": "Rivedi importazione", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic destro su una foto → Imposta come foto di riferimento, poi Visualizza → Vista di riferimento (Maiusc+R)", + "Right-click a preset to delete it": "Clic destro su un preset per eliminarlo", + "Rights Usage Terms": "Condizioni d'uso dei diritti", + "Rotate": "Ruota", + "Rotate Crop": "Ruota il ritaglio", + "Rotate Crop Aspect": "Ruota le proporzioni del ritaglio", + "Rotate Left": "Ruota a sinistra", + "Rotate Right": "Ruota a destra", + "Roughness": "Rugosità", + "Roundness": "Rotondità", + "Rules": "Regole", + "SAM 3 download stopped: it resumes where it left off next time.": "Download di SAM 3 interrotto: la prossima volta riprende da dove si è fermato.", + "Same as raw default": "Uguale al predefinito raw", + "Sample Mask Color": "Campiona il colore della maschera", + "Sample a colour on the photo": "Campiona un colore sulla foto", + "Saturation": "Saturazione", + "Saturation Range": "Intervallo di saturazione", + "Saturday": "Sabato", + "Save": "Salva", + "Save As…": "Salva con nome…", + "Save Color Label Set": "Salva set di etichette colore", + "Save Current Filter as Preset…": "Salva il filtro corrente come preset…", + "Save Current Keywords as Set…": "Salva le parole chiave correnti come set…", + "Save Export Preset": "Salva preset di esportazione", + "Save Filter Preset": "Salva preset di filtro", + "Save Keyword Set": "Salva set di parole chiave", + "Save Metadata Preset": "Salva preset di metadati", + "Save Metadata Preset…": "Salva preset di metadati…", + "Save Metadata to File": "Salva i metadati nel file", + "Save Point Curve Preset": "Salva preset di curva a punti", + "Save Point Curve…": "Salva curva a punti…", + "Save as Preset": "Salva come preset", + "Save as Set…": "Salva come set…", + "Save as Smart Album…": "Salva come album smart…", + "Save as set": "Salva come set", + "Save as…": "Salva con nome…", + "Save preset": "Salva preset", + "Saving Lightroom import index…": "Salvataggio dell'indice di importazione di Lightroom…", + "Scale": "Scala", + "Scan failed": "Scansione non riuscita", + "Scanning": "Scansione in corso", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "I punteggi restano sulle foto: filtra o crea album smart con Messa a fuoco e La migliore tra scatti simili.", + "Screen": "Schermo", + "Scroll menu down": "Scorri il menu in giù", + "Scroll menu up": "Scorri il menu in su", + "Search": "Cerca", + "Search Photos": "Cerca foto", + "Search commands or keys": "Cerca comandi o tasti", + "Search…": "Cerca…", + "Seasons": "Stagioni", + "Second Window": "Seconda finestra", + "Select": "Seleziona", + "Select All": "Seleziona tutto", + "Select Luminance Range": "Seleziona intervallo di luminanza", + "Select Mask": "Seleziona maschera", + "Select Photos": "Seleziona foto", + "Select Spot": "Seleziona macchia", + "Select a photo to edit": "Seleziona una foto da modificare", + "Select by": "Seleziona per", + "Select by Flag, Rating or Label": "Seleziona per contrassegno, valutazione o etichetta", + "Select photos, then choose View → Survey (N)": "Seleziona le foto, poi scegli Visualizza → Panoramica (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Seleziona due foto, poi scegli Visualizza → Confronta (Maiusc+C)", + "Selected": "Selezionati", + "Selecting…": "Selezione in corso…", + "Selenium Tone": "Viraggio al selenio", + "Send Feedback…": "Invia un feedback…", + "Sepia Tone": "Viraggio seppia", + "Sequence number zero-padded to N digits (1–9)": "Numero progressivo con zeri iniziali fino a N cifre (1-9)", + "Sequence number, counting from the start number": "Numero progressivo, a partire dal numero iniziale", + "Services": "Servizi", + "Set": "Imposta", + "Set Album Cover": "Imposta copertina dell'album", + "Set Before": "Imposta Prima", + "Set Color Label": "Imposta etichetta colore", + "Set Crop": "Imposta ritaglio", + "Set Develop Values": "Imposta valori di sviluppo", + "Set Flag": "Imposta contrassegno", + "Set Image Zoom and Pan": "Imposta zoom e spostamento dell'immagine", + "Set Keyboard Shortcut": "Imposta scelta rapida da tastiera", + "Set Mask Adjustments": "Imposta regolazioni della maschera", + "Set Point Curve": "Imposta curva a punti", + "Set Profile": "Imposta profilo", + "Set Rating": "Imposta valutazione", + "Set Top of Stack": "Imposta la prima della pila", + "Set as Album Cover": "Imposta come copertina dell'album", + "Set as Before": "Imposta come Prima", + "Set as Reference Photo": "Imposta come foto di riferimento", + "Set as Target Album": "Imposta come album di destinazione", + "Set as Target Album (B adds to it)": "Imposta come album di destinazione (B aggiunge)", + "Set as Top of Stack": "Imposta come prima della pila", + "Set date & time": "Imposta data e ora", + "Set name": "Imposta nome", + "Set the mix from the photo's colours": "Imposta il mix dai colori della foto", + "Settings": "Impostazioni", + "Settings file problem": "Problema nel file delle impostazioni", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Impostazioni da cui partono le nuove foto raw. Cambiarle non tocca le foto già nella libreria.", + "Settings pasted": "Impostazioni incollate", + "Settings to include": "Impostazioni da includere", + "Settings…": "Impostazioni…", + "Shade": "Ombra", + "Shadow": "Ombra", + "Shadows": "Ombre", + "Shadows Hue": "Tonalità delle ombre", + "Shadows Luminance": "Luminanza delle ombre", + "Shadows Saturation": "Saturazione delle ombre", + "Shadows Tint": "Tinta delle ombre", + "Shadows split": "Divisione delle ombre", + "Sharpen": "Nitidezza", + "Sharpening": "Contrasto", + "Sharpness": "Nitidezza", + "Shift": "Maiusc", + "Shift by": "Sposta di", + "Short Edge": "Lato corto", + "Show": "Mostra", + "Show All": "Mostra tutto", + "Show Clipping": "Mostra tagli", + "Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi", + "Show Hidden Local Locations": "Mostra le posizioni locali nascoste", + "Show Luminance Map": "Mostra mappa di luminanza", + "Show Mask": "Mostra maschera", + "Show Mask Overlay": "Mostra sovrapposizione maschera", + "Show Mask Pins": "Mostra i punti delle maschere", + "Show Original": "Mostra originale", + "Show Photo Counts": "Mostra il numero di foto", + "Show Photos with Keyword": "Mostra le foto con la parola chiave", + "Show Pins": "Mostra i punti", + "Show Source": "Mostra origine", + "Show Thumbnails": "Mostra miniature", + "Show commands without a shortcut": "Mostra i comandi senza scorciatoia", + "Show destination gamut warning": "Mostra avviso gamma di destinazione", + "Show display gamut warning": "Mostra avviso gamma dello schermo", + "Show file names": "Mostra i nomi dei file", + "Show in Explorer": "Mostra in Esplora file", + "Show in File Manager": "Mostra nel gestore file", + "Show in Finder": "Mostra nel Finder", + "Show on Map": "Mostra sulla mappa", + "Show overlay (O)": "Mostra sovrapposizione (O)", + "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", + "Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo", + "Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo", + "Show/Hide Mask": "Mostra/nascondi maschera", + "Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto", + "Shutter speed": "Tempo di scatto", + "Side panels": "Pannelli laterali", + "Sidecar names": "Nomi dei file collaterali", + "Size": "Dimensione", + "Size (% of short edge)": "Dimensione (% del lato corto)", + "Size limit": "Limite di dimensione", + "Skip": "Salta", + "Sky": "Cielo", + "Slideshow": "Presentazione", + "Slideshow paused": "Presentazione in pausa", + "Slideshow resumed": "Presentazione ripresa", + "Slideshow · Space pauses · Esc ends": "Presentazione · Spazio mette in pausa · Esc termina", + "Smart Album": "Album smart", + "Smart Album Rule Fields": "Campi delle regole degli album smart", + "Smart Preview Status": "Stato delle anteprime avanzate", + "Smart Previews Location": "Posizione delle anteprime avanzate", + "Smart preview available": "Anteprima avanzata disponibile", + "Smart previews": "Anteprime avanzate", + "Smart previews folder changed": "Cartella delle anteprime avanzate cambiata", + "Smoothness": "Uniformità", + "Soft B&W": "B/N morbido", + "Soft Pastel": "Pastello morbido", + "Soft Proofing": "Prova colore a video", + "Soft Skin": "Pelle morbida", + "Sort": "Ordina", + "Sort by": "Ordina per", + "Source": "Origine", + "Source code on GitHub": "Codice sorgente su GitHub", + "Spherical": "Sferica", + "Spiral": "Spirale", + "Split Stack": "Dividi la pila", + "Spring Fresh": "Primavera fresca", + "Square Grid": "Griglia quadrata", + "Square Grid (G toggles)": "Griglia quadrata (G alterna)", + "Square Grid: show file names and formats": "Griglia quadrata: mostra nomi e formati dei file", + "Stack": "Pila", + "Stack photos taken within this time of each other:": "Metti in pila le foto scattate entro questo intervallo l'una dall'altra:", + "Stacks": "Pile", + "Standard": "Standard", + "Star rating (0–5)": "Valutazione a stelle (0-5)", + "Start at": "Inizia da", + "Start number": "Numero iniziale", + "Starting": "Avvio in corso", + "Startup": "Avvio", + "State / Province": "Regione / provincia", + "Stop": "Interrompi", + "Stop Building Previews": "Interrompi la generazione delle anteprime", + "Stop Using as Target Album": "Non usare più come album di destinazione", + "Stopping…": "Interruzione in corso…", + "Straighten": "Raddrizza", + "Straighten Tool": "Strumento raddrizza", + "Strong Contrast": "Contrasto forte", + "Style": "Stile", + "Subfolder": "Sottocartella", + "Subfolder name": "Nome della sottocartella", + "Subject": "Soggetto", + "Subtract": "Sottrai", + "Subtract from this mask": "Sottrai da questa maschera", + "Suggestions": "Suggerimenti", + "Summer Haze": "Foschia estiva", + "Sunday": "Domenica", + "Survey": "Panoramica", + "Survey (N)": "Panoramica (N)", + "Swap": "Scambia", + "Swap Aspect (X)": "Scambia proporzioni (X)", + "Swap Before and After": "Scambia Prima e Dopo", + "Swap Before and After Settings": "Scambia le impostazioni di Prima e Dopo", + "Swap Compare Photos": "Scambia le foto del confronto", + "Switch light / dark theme": "Alterna tema chiaro/scuro", + "Sync Settings": "Sincronizza impostazioni", + "System": "Sistema", + "System Info": "Info sistema", + "System Info…": "Info sistema…", + "System default": "Predefinito di sistema", + "Table": "Tabella", + "Tags": "Tag", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tag: vedi Tag accanto al modello. I file vengono rinominati sul disco (con i loro XMP collaterali); ai nomi già esistenti si aggiunge -1, -2…", + "Take a snapshot": "Acquisisci istantanea", + "Targeted Adjustment": "Regolazione mirata", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare i colori in quel punto", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare la curva in quel punto", + "Teal & Amber": "Ottanio e ambra", + "Teal & Orange": "Ottanio e arancio", + "Temp": "Temp.", + "Temperature": "Temperatura", + "Template": "Modello", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sessione temporanea: la tua libreria non è aperta, e niente di quello che fai qui viene salvato.", + "Text": "Testo", + "Text direction": "Direzione del testo", + "Texture": "Texture", + "That includes the photos in the folders inside it.": "Comprende anche le foto nelle cartelle al suo interno.", + "That key can't be used as a shortcut": "Quel tasto non si può usare come scorciatoia", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Il modello SAM 3 è installato: le maschere Oggetto e Descrivi sono pronte.", + "The download didn't work:": "Il download non è riuscito:", + "The number at the end of the original name (IMG_0042 → 0042)": "Il numero alla fine del nome originale (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "L'estensione originale viene sempre aggiunta; {ext} la mette anche dentro il nome.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "I file originali, invariati, ciascuno con un XMP collaterale che contiene le sue modifiche.", + "The photo has these settings": "La foto ha queste impostazioni", + "Theme": "Tema", + "They can be restored from Recently Deleted until it is emptied.": "Si possono ripristinare da Eliminate di recente finché non viene svuotato.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Thirds": "Terzi", + "This Computer": "Questo computer", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Questa versione non può ancora scaricare il modello. Per installarlo a mano, metti model.safetensors, vocab.json e merges.txt del facebook/sam3 di Meta nella cartella qui sopra: Oggetto e Descrivi funzionano appena sono lì.", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Questa cartella non è disponibile (disco scollegato?). Le anteprime avanzate non vengono generate né usate finché non torna o non scegli un'altra cartella.", + "This photo can't be opened": "Impossibile aprire questa foto", + "Threshold": "Soglia", + "Thumbnail cache": "Cache delle miniature", + "Thursday": "Giovedì", + "Time zone": "Fuso orario", + "Tint": "Tinta", + "Title": "Titolo", + "Title metadata": "Metadati del titolo", + "Toggle Keyword from Set": "Applica o togli parola chiave dal set", + "Toggle Mask": "Attiva/disattiva maschera", + "Toggle Section": "Apri/chiudi sezione", + "Toggle Zoom": "Attiva/disattiva zoom", + "Tone Curve": "Curva dei toni", + "Tools": "Strumenti", + "Top Level": "Livello principale", + "Transfer": "Trasferimento", + "Treatment": "Trattamento", + "Triangle": "Triangolo", + "Try Again": "Riprova", + "Try Saving Again": "Riprova a salvare", + "Tuesday": "Martedì", + "Tungsten": "Tungsteno", + "Turn Off": "Disattiva", + "Turn on Include subfolders, or pick another folder under Local": "Attiva Includi sottocartelle, oppure scegli un'altra cartella in Locale", + "Type": "Tipo", + "Uncheck All": "Deseleziona tutto", + "Uncheck the files of this type": "Deseleziona i file di questo tipo", + "Undo": "Annulla", + "Unedited": "Non modificata", + "Unedited raws": "Raw non modificati", + "Unflag": "Togli contrassegno", + "Unflagged": "Senza contrassegno", + "Ungroup Stack": "Separa la pila", + "Unknown Date": "Data sconosciuta", + "Unknown tags stay as typed — check the preview for a {typo}.": "I tag sconosciuti restano come li hai scritti: controlla l'anteprima per un {typo}.", + "Unrated": "Senza valutazione", + "Unreadable": "Illeggibile", + "Update": "Aggiorna", + "Update Mask Shape": "Aggiorna la forma della maschera", + "Update Preset with Current Settings": "Aggiorna il preset con le impostazioni correnti", + "Update Rules from Current Filter": "Aggiorna le regole dal filtro corrente", + "Update Version": "Aggiorna versione", + "Update Version with Current Settings": "Aggiorna la versione con le impostazioni correnti", + "Update with Current Settings": "Aggiorna con le impostazioni correnti", + "Updated the edits saved in the external editor": "Modifiche salvate nell'editor esterno aggiornate", + "Upright": "Raddrizzamento", + "Urban": "Urbano", + "Use Keyword Set": "Usa set di parole chiave", + "Use Library Folder": "Usa la cartella della libreria", + "Use camera-specific defaults": "Usa i predefiniti specifici della fotocamera", + "Use the GPU for rendering": "Usa la GPU per il rendering", + "User Presets": "Preset utente", + "Username": "Nome utente", + "Value": "Valore", + "Variance": "Varianza", + "Version": "Versione", + "Version name": "Nome della versione", + "Versions": "Versioni", + "Versions (Shift+V)": "Versioni (Maiusc+V)", + "Vertical": "Verticale", + "Vertical inset (% of short edge)": "Margine verticale (% del lato corto)", + "Vertical text": "Testo verticale", + "Vibrance": "Vividezza", + "Videos": "Video", + "View": "Visualizza", + "Viewing and sharing": "Visione e condivisione", + "Vignette": "Vignettatura", + "Vignetting": "Vignettatura", + "Vintage": "Vintage", + "Virtual Copy": "Copia virtuale", + "Visible": "Visibile", + "Visualize Spots": "Visualizza macchie", + "Visualize Spots (A)": "Visualizza macchie (A)", + "Visualize range": "Visualizza intervallo", + "Vivid": "Vivace", + "Vivid Pop": "Vivace e brillante", + "Warm Film": "Pellicola calda", + "Warm Glow": "Bagliore caldo", + "Warm Print": "Stampa calda", + "Warm Table": "Tavola calda", + "Watched folder": "Cartella sorvegliata", + "Watermark": "Filigrana", + "Wednesday": "Mercoledì", + "What's New": "Novità", + "When rated or hovered": "Quando ha una valutazione o è sotto il puntatore", + "White Balance": "Bilanciamento del bianco", + "White Balance Selector": "Selettore del bilanciamento del bianco", + "White Balance Selector (W)": "Selettore del bilanciamento del bianco (W)", + "Whites": "Bianchi", + "Width": "Larghezza", + "Width (% of photo)": "Larghezza (% della foto)", + "Width (px)": "Larghezza (px)", + "Width × Height": "Larghezza × altezza", + "Will be replaced": "Verrà sostituita", + "Will be restored": "Verrà ripristinata", + "Window": "Finestra", + "Winter Blue": "Blu d'inverno", + "XMP Sidecar Preferences": "Preferenze dei file XMP collaterali", + "XMP sidecars": "File XMP collaterali", + "Year": "Anno", + "Yellow": "Giallo", + "Yellow Hue": "Tonalità giallo", + "Yellow Luminance": "Luminanza giallo", + "Yellow Saturation": "Saturazione giallo", + "Yellow label": "Etichetta gialla", + "Yes": "Sì", + "You can close this: the download continues, and resumes if interrupted.": "Puoi chiudere questa finestra: il download continua, e riprende se viene interrotto.", + "Your Name": "Il tuo nome", + "Your library couldn't be opened": "Impossibile aprire la tua libreria", + "Zoom": "Ingrandimento", + "Zoom 100%": "Zoom 100%", + "Zoom In": "Ingrandisci", + "Zoom Out": "Riduci", + "Zoom to Fit": "Zoom adatta", + "Zoom to page level": "Adatta alla pagina", + "a literal %": "un % letterale", + "a removed photo": "una foto rimossa", + "all": "tutte", + "and": "e", + "any": "almeno una", + "are empty": "sono vuoti", + "aren't empty": "non sono vuoti", + "blue": "blu", + "comma, separated": "separati, da virgola", + "contains": "contiene", + "copyrighted": "protetto da copyright", + "day 01–31": "giorno 01-31", + "days": "giorni", + "discarding smart previews": "eliminazione delle anteprime avanzate", + "doesn't contain": "non contiene", + "e.g. sky · the red car · car, road": "es. cielo · l'auto rossa · auto, strada", + "ends with": "finisce con", + "false": "no", + "green": "verde", + "hour 00–23": "ora 00-23", + "hours": "ore", + "image": "immagine", + "includes the folders inside it": "comprende le cartelle al suo interno", + "is": "è", + "is <": "è <", + "is >": "è >", + "is after": "è dopo", + "is before": "è prima", + "is between": "è tra", + "is empty": "è vuoto", + "is in the last": "è negli ultimi", + "is unknown": "è sconosciuto", + "is ≤": "è ≤", + "is ≥": "è ≥", + "isn't": "non è", + "isn't empty": "non è vuoto", + "isn't in the last": "non è negli ultimi", + "keep names — or e.g. {date}_{seq:3}": "mantieni i nomi, oppure ad es. {date}_{seq:3}", + "latitude, longitude": "latitudine, longitudine", + "memory only": "solo in memoria", + "minute": "minuto", + "month 01–12": "mese 01-12", + "months": "mesi", + "none": "nessuno", + "none (CPU rendering)": "nessuna (rendering sulla CPU)", + "none of": "nessuna", + "not available here": "non disponibile qui", + "of the following rules:": "delle regole seguenti:", + "or": "o", + "pick": "scelta", + "previews": "anteprime", + "publicDomain": "pubblico dominio", + "purple": "viola", + "raw": "raw", + "red": "rosso", + "reject": "scartata", + "saved in": "salvato in", + "second": "secondo", + "smart previews": "anteprime avanzate", + "starts with": "inizia con", + "the photos in view": "le foto nella vista", + "the selected photos": "le foto selezionate", + "this photo": "questa foto", + "true": "sì", + "unknown": "sconosciuto", + "video": "video", + "weeks": "settimane", + "year, 2 digits": "anno, 2 cifre", + "year, 4 digits": "anno, 4 cifre", + "years": "anni", + "yellow": "giallo", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} è l'ora di scatto; una foto che non ce l'ha usa l'ora in cui è stata importata.", + "© 2026 Your Name": "© 2026 Il tuo nome", + "© Your Name": "© Il tuo nome", + "− harder border · + softer border": "− bordo più netto · + bordo più morbido", + "≤ at most": "≤ al massimo", + "≥ at least": "≥ almeno", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Any camera": "Qualsiasi fotocamera", + "Any lens": "Qualsiasi obiettivo", + "Any keyword": "Qualsiasi parola chiave", + "Label Set": "Set di etichette", + ", picked": ", scelta", + ", rejected": ", scartata", + "Preview only: editing the camera's embedded JPEG": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera", + "Captured from": "Scattate dal", + "Captured until": "Scattate fino al", + "Only": "Solo", + "Open Log Folder": "Apri la cartella dei log", + "Back to this person": "Torna a questa persona", + "AI Denoise": "Riduzione disturbo IA", + "AI Denoise Model": "Modello di riduzione disturbo IA", + "AI Denoise is off: press Use on a model.": "La riduzione disturbo IA è disattivata: premi Usa su un modello.", + "AI Denoise needs a model. Install one in Settings.": "La riduzione disturbo IA ha bisogno di un modello. Installane uno nelle Impostazioni.", + "Accept & Download": "Accetta e scarica", + "Already installed. Remove it before installing a replacement.": "Già installato. Rimuovilo prima di installarne un altro.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatico usa la scheda grafica quando è più veloce del processore: la prima volta che si elabora una foto, su questo computer vengono cronometrati entrambi.", + "Cache": "Cache", + "Checking and installing the model…": "Controllo e installazione del modello…", + "Checking…": "Controllo in corso…", + "Clear cache": "Svuota cache", + "Denoise Models": "Modelli di riduzione disturbo", + "Denoise Settings": "Impostazioni della riduzione disturbo", + "Denoise all photos with an Amount": "Riduci il disturbo in tutte le foto con una quantità", + "Downloaded and checked. Installing…": "Scaricato e verificato. Installazione…", + "Failed its last test": "Non ha superato l'ultimo test", + "Graphics card": "Scheda grafica", + "I have read these terms and accept them for my own use": "Ho letto queste condizioni e le accetto per uso personale", + "Inspect Denoise Model": "Esamina il modello di riduzione disturbo", + "Install Denoise Model": "Installa un modello di riduzione disturbo", + "Install from file…": "Installa da file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installa un tuo modello ONNX con accanto denoise-model.json. Questa versione non offre download di modelli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "La sua immagine con il disturbo ridotto non è ancora pronta, e le immagini non vengono generate da sole (Impostazioni, Riduzione disturbo IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft non conosce questo modello, quindi ha ipotizzato:", + "Limit": "Limite", + "Make it now": "Generala ora", + "Make the denoised picture of the photos I am looking at": "Genera l'immagine con il disturbo ridotto delle foto che sto guardando", + "Next up for AI Denoise…": "Prossima per la riduzione disturbo IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Non viene scaricato niente finché non accetti. Il modello resta solo su questo computer; LightCraft non lo carica né lo condivide mai.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Vengono generate solo le foto con una quantità di riduzione disturbo IA sopra 0. Le esportazioni generano sempre la loro se manca.", + "Open page": "Apri pagina", + "Open the model's page": "Apri la pagina del modello", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Apre nel browser la pagina del modello, per leggerne i dettagli o scaricare il file da te.", + "Processor": "Processore", + "Set up AI Denoise…": "Configura la riduzione disturbo IA…", + "Speed": "Velocità", + "Starting…": "Avvio in corso…", + "Tested and works on this computer": "Testato e funzionante su questo computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Il modello di riduzione disturbo è installato e in uso: ora funziona il cursore Quantità riduzione disturbo IA in Dettaglio", + "The graphics card is set up when the next photo is made.": "La scheda grafica viene configurata quando si elabora la prossima foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Il modello resta solo su questo computer. LightCraft non lo carica né lo condivide mai.", + "The processor does the work.": "Il lavoro lo fa il processore.", + "There is no model file to install": "Non c'è nessun file di modello da installare", + "This build cannot run denoise models.": "Questa versione non può eseguire modelli di riduzione disturbo.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli di riduzione disturbo, quindi la riduzione disturbo IA non è disponibile. (L'app desktop ce l'ha.)", + "This file cannot be used yet": "Questo file non si può ancora usare", + "Tick the box to accept the model's terms first": "Prima spunta la casella per accettare le condizioni del modello", + "Try again": "Riprova", + "Try the graphics card again": "Riprova con la scheda grafica", + "Turn off": "Disattiva", + "Unknown licence": "Licenza sconosciuta", + "Use": "Usa", + "Waiting to start…": "In attesa di partire…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funziona sui file raw delle fotocamere con sensore standard (Bayer). Gli altri file mantengono la loro normale riduzione del disturbo.", + "cached picture": "immagine in cache", + "cached pictures": "immagini in cache", + "commercial use allowed": "uso commerciale consentito", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in uso", + "installed": "installato", + "makes AI Denoise available": "rende disponibile la riduzione disturbo IA", + "non-commercial use only": "solo uso non commerciale", + "photo has its denoised picture.": "foto ha la sua immagine con il disturbo ridotto.", + "photo waiting": "foto in attesa", + "photos have their denoised picture.": "foto hanno la loro immagine con il disturbo ridotto.", + "photos waiting": "foto in attesa", + "starts using it for AI Denoise": "comincia a usarlo per la riduzione disturbo IA", + "terms unclear": "condizioni poco chiare", + "the graphics card": "la scheda grafica", + "the graphics card is not used": "la scheda grafica non viene usata", + "Cancel Denoise": "Annulla riduzione disturbo", + "Cancel Denoise Model Download": "Annulla il download del modello di riduzione disturbo", + "Choose Denoise Model": "Scegli il modello di riduzione disturbo", + "Clear Denoise Cache": "Svuota la cache della riduzione disturbo", + "Denoise Model Downloads": "Download dei modelli di riduzione disturbo", + "Denoise Photos": "Riduci il disturbo delle foto", + "Denoise Pump": "Coda della riduzione disturbo", + "Denoise Status": "Stato della riduzione disturbo", + "Download Denoise Model": "Scarica il modello di riduzione disturbo", + "Remove Denoise Model": "Rimuovi il modello di riduzione disturbo", + "Test Denoise Model": "Testa il modello di riduzione disturbo", + "AI Denoise is enabled on the requesting photo": "La riduzione disturbo IA è attiva sulla foto che l'ha richiesta", + "After installation, AI Denoise will turn on for the requesting photo.": "Dopo l'installazione, la riduzione disturbo IA si attiverà sulla foto che l'ha richiesta.", + "Cancel pending action": "Annulla l'azione in sospeso", + "Install a denoise model to enable AI Denoise on this photo": "Installa un modello di riduzione disturbo per attivare la riduzione disturbo IA su questa foto", + "Pending photo action cancelled": "Azione sulla foto in sospeso annullata", + "Pending photo action cancelled because its library or photo changed": "Azione sulla foto in sospeso annullata perché la libreria o la foto sono cambiate", + "ONNX model": "Modello ONNX", + "commercial use subject to GPL conditions": "uso commerciale soggetto alle condizioni della GPL", + "Decrease Rating": "Diminuisci valutazione", + "Increase Rating": "Aumenta valutazione", + "Click to type a value": "Fai clic per scrivere un valore", + "Angle": "Angolo", + "Video": "Video", + "Copyrighted": "Protetta da copyright", + "Public Domain": "Pubblico dominio", + "Unknown": "Sconosciuto", + "Landscape (wide)": "Orizzontale (larga)", + "Portrait (tall)": "Verticale (alta)", + "Square": "Quadrato", + "In Color": "A colori", + "No Label": "Nessuna etichetta", + "Choose an album…": "Scegli un album…", + "No albums yet": "Ancora nessun album", + "Fix the marked rules to save this album.": "Correggi le regole segnate per salvare questo album.", + "Some rules need fixing (Edit Smart Album…):": "Alcune regole vanno corrette (Modifica album smart…):", + "isn't a field rules know": "non è un campo che le regole conoscono", + "can't be tested that way": "non si può verificare in quel modo", + "is yes or no": "è sì o no", + "needs a number": "richiede un numero", + "needs a time like 1/250 or 2": "richiede un tempo come 1/250 o 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "richiede una data come 2026, 2026-08 o 2026-08-14", + "has the first date after the second": "ha la prima data successiva alla seconda", + "needs two values": "richiede due valori", + "needs a number more than 0": "richiede un numero maggiore di 0", + "needs hours, days, weeks, months or years": "richiede ore, giorni, settimane, mesi o anni", + "isn't one of the choices": "non è tra le scelte possibili", + "needs something to look for (or use “is empty”)": "richiede qualcosa da cercare (oppure usa “è vuoto”)", + "needs something to look for (or use “isn't empty”)": "richiede qualcosa da cercare (oppure usa “non è vuoto”)", + "needs text": "richiede un testo", + "can't match any rating from 0 to 5": "non può corrispondere a nessuna valutazione da 0 a 5", + "needs an album": "richiede un album", + "names an album that no longer exists": "indica un album che non esiste più", + "can't test a folder; choose an album in it": "non si può verificare su una cartella; scegli un album al suo interno", + "This group is empty: add a rule or remove it.": "Questo gruppo è vuoto: aggiungi una regola o rimuovilo.", + "needs something to look for (or use “are empty”)": "richiede qualcosa da cercare (oppure usa “sono vuoti”)", + "needs something to look for (or use “aren't empty”)": "richiede qualcosa da cercare (oppure usa “non sono vuoti”)", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Gi", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Do", + "Pick a date": "Scegli una data", + "would make this album include itself through other smart albums": "farebbe includere questo album in se stesso attraverso altri album smart", + "Search albums": "Cerca album", + "No albums match": "Nessun album corrisponde", + "This is the album you're editing": "È l'album che stai modificando", + "It tests this album, so testing it back would loop": "Verifica questo album, quindi verificarlo a sua volta creerebbe un ciclo", + "Copy Name": "Nome della copia", + "In a Stack": "In una pila", + "File Extension": "Estensione del file", + "Video Duration": "Durata del video", + "Keyword Count": "Numero di parole chiave", + "People Count": "Numero di persone", + "Shutter Speed": "Tempo di scatto", + "Cropped": "Ritagliata", + "Keywords & People": "Parole chiave e persone", + "Description": "Descrizione", + "Camera Info": "Informazioni sulla fotocamera", + "Develop": "Sviluppo", + "Keyword List": "Elenco parole chiave", + "Filter Keywords": "Filtra parole chiave", + "No keywords yet": "Ancora nessuna parola chiave", + "No keywords match": "Nessuna parola chiave corrisponde", + "Keyword Name": "Nome della parola chiave", + "Synonyms": "Sinonimi", + "Separated by commas": "Separati da virgole", + "Keyword Tag Options": "Opzioni del tag parola chiave", + "Include on Export": "Includi nell'esportazione", + "Export Containing Keywords": "Esporta le parole chiave che la contengono", + "Export Synonyms": "Esporta i sinonimi", + "Create Keyword Tag": "Crea tag parola chiave", + "Edit Keyword Tag": "Modifica tag parola chiave", + "Create": "Crea", + "No photo has it; the keywords below it go too.": "Nessuna foto ce l'ha; vengono tolte anche le parole chiave sotto di essa.", + "Edit Keyword Tag…": "Modifica tag parola chiave…", + "Put New Keywords Inside This Keyword": "Metti le nuove parole chiave dentro questa", + "Purge Unused Keywords": "Elimina le parole chiave non usate", + "Delete Keyword…": "Elimina parola chiave…", + "Drop here for the top level": "Rilascia qui per il livello principale", + "Their photos and the keywords below them come together under one keyword.": "Le loro foto e le parole chiave sotto di esse si riuniscono sotto un'unica parola chiave.", + "Select photos to give them keywords.": "Seleziona delle foto per assegnare loro parole chiave.", + "Add to All Selected Photos": "Aggiungi a tutte le foto selezionate", + "Will Export": "Verrà esportata", + "No keywords are exported": "Nessuna parola chiave viene esportata", + "& Containing": "e contenenti", + "Edit Set…": "Modifica set…", + "Edit Keyword Set": "Modifica set di parole chiave", + "Keywords (⌥1–⌥9)": "Parole chiave (⌥1-⌥9)", + "A keyword set needs a name": "Un set di parole chiave ha bisogno di un nome", + "Save as a new set": "Salva come nuovo set", + "Recent Keywords is built in: choose another name": "Parole chiave recenti è predefinito: scegli un altro nome", + "Import Keywords…": "Importa parole chiave…", + "Export Keywords…": "Esporta parole chiave…", + "Import Keywords": "Importa parole chiave", + "Export Keywords": "Esporta parole chiave", + "Keyword Lists": "Elenchi di parole chiave", + "Synchronize Folder…": "Sincronizza cartella…", + "Synchronize Folder": "Sincronizza cartella", + "Synchronize": "Sincronizza", + "Scanning…": "Scansione…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Trova le foto aggiunte o mancanti in questa cartella sul disco, e i file XMP collaterali modificati da altre app", + "Bring the library up to date with this folder and the folders inside it:": "Aggiorna la libreria con questa cartella e le cartelle al suo interno:", + "Import new photos": "Importa le foto nuove", + "Remove missing photos from the library": "Togli dalla libreria le foto mancanti", + "Read metadata updates from XMP sidecars": "Leggi gli aggiornamenti dei metadati dai file XMP collaterali", + "The library is up to date with this folder.": "La libreria è aggiornata rispetto a questa cartella.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Le foto mancanti finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leggere un file collaterale sostituisce valutazione, etichette, metadati e modifiche della foto con quello che contiene.", + "Skipped: files already in the library elsewhere": "Saltati: file già presenti altrove nella libreria", + "Skipped: files that can't be read": "Saltati: file che non si possono leggere", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Questa cartella non c'è: è stata spostata o rinominata, oppure il suo disco non è collegato.", + "Relink photos whose file was renamed or moved": "Ricollega le foto il cui file è stato rinominato o spostato", + "A folder is already being synchronized": "Una cartella è già in sincronizzazione", + "Synchronizing…": "Sincronizzazione in corso…", + "Relinked": "Ricollegate", + "Removed": "Rimosse", + "Metadata read": "Metadati letti", + "Failed": "Non superato", + "Stopped": "Interrotto", + "Stopped by an error (see the log)": "Interrotta da un errore (vedi il log)", + "Not saved": "Non salvato", + "A folder is being synchronized": "Una cartella è in sincronizzazione", + "Wait for the import to finish": "Aspetta che l'importazione finisca", + "Appearance": "Aspetto", + "Appearance Mode": "Modalità aspetto", + "Sync with System": "Segui il sistema", + "Light Mode": "Tema chiaro", + "Dark Mode": "Modalità scura", + "Next Appearance Mode": "Modalità aspetto successiva", + "Light Theme": "Tema chiaro", + "Dark Theme": "Tema scuro", + "In Use": "In uso", + "Charcoal": "Carboncino", + "Midnight": "Mezzanotte", + "Silver": "Argento", + "Paper": "Carta", + "Show less": "Mostra meno", + "Quit while tasks are running?": "Uscire mentre ci sono attività in corso?", + "Exporting contact sheet": "Esportazione del foglio provini", + "Reading folder": "Lettura della cartella", + "Scanning folder": "Scansione della cartella", + "Building previews": "Generazione delle anteprime", + "Building smart previews": "Generazione delle anteprime avanzate", + "Discarding smart previews": "Eliminazione delle anteprime avanzate", + "Importing Lightroom catalog": "Importazione del catalogo Lightroom", + "Reading Lightroom catalog": "Lettura del catalogo Lightroom", + "Merging": "Unione", + "Downloading model": "Download del modello", + "Finding faces": "Ricerca dei volti", + "Synchronizing folder": "Sincronizzazione della cartella", + "Looking for changes": "Ricerca delle modifiche", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Questo è un catalogo di Lightroom, non una libreria di LightCraft. Vuoi importarlo in una libreria di LightCraft?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L'importazione legge il catalogo senza modificarlo e aggiunge foto, modifiche, parole chiave e raccolte alla libreria aperta ora.", + "Albums hold library photos: Add to My Photos first": "Gli album contengono foto della libreria: prima aggiungi a Le mie foto", + "Add to My Photos First": "Prima aggiungi a Le mie foto", + "Fill Location from GPS": "Compila la posizione dal GPS", + "Download Place Names (GeoNames)": "Scarica i nomi dei luoghi (GeoNames)", + "Place Names Status": "Stato dei nomi dei luoghi", + "Fill Location": "Compila la posizione", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Compila Città, Regione e Paese vuoti dal GPS (nomi dei luoghi GeoNames, senza connessione)", + "Looking up places": "Ricerca dei luoghi", + "Downloading place names": "Download dei nomi dei luoghi", + "Place names downloaded": "Nomi dei luoghi scaricati", + "Place names download cancelled": "Download dei nomi dei luoghi annullato", + "Other…": "Altro…", + "Intent:": "Intento:", + "Perceptual": "Percettivo", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simula carta e inchiostro", + "Content-Aware": "In base al contenuto", + "Sidebar Sections": "Sezioni della barra laterale", + "Reset Sidebar Sections": "Reimposta le sezioni della barra laterale", + "Hide This Section": "Nascondi questa sezione", + "No folders to browse": "Nessuna cartella da sfogliare", + "No photos yet": "Ancora nessuna foto", + "No imported folders yet": "Ancora nessuna cartella importata", + "Show Sidebar Section": "Mostra sezione della barra laterale", + "Move Sidebar Section": "Sposta sezione della barra laterale", + "New Library…": "Nuova libreria…", + "Open Recent Library": "Apri libreria recente", + "Recent Libraries": "Librerie recenti", + "Clear Recent Libraries": "Cancella le librerie recenti", + "No Recent Libraries": "Nessuna libreria recente", + "New Library: Choose Where to Put It": "Nuova libreria: scegli dove metterla", + "New Library": "Nuova libreria", + "Library name": "Nome della libreria", + "New Folder on Disk": "Nuova cartella sul disco", + "New Folder on Disk…": "Nuova cartella sul disco…", + "Move to Folder": "Sposta nella cartella", + "Move to Folder…": "Sposta nella cartella…", + "Move Selected Photos Here": "Sposta qui le foto selezionate", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Sposta i file e i loro XMP collaterali in questa cartella; non sovrascrive mai", + "Appearance Settings…": "Impostazioni aspetto…", + "Add to favorites": "Aggiungi ai preferiti", + "Remove from favorites": "Rimuovi dai preferiti", + "Add to Apple Photos": "Aggiungi a Foto di Apple", + "Adding to Apple Photos…": "Aggiunta a Foto di Apple…", + "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)", + "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", + "Adding to Apple Photos": "Aggiunta a Foto di Apple", + "Add Face Model…": "Aggiungi modello dei volti…", + "Cancel Face Model Download": "Annulla il download del modello dei volti", + "Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto", + "Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti", + "Choose a model below (Use) to start.": "Per cominciare, scegli un modello qui sotto (Usa).", + "Detect Faces": "Rileva i volti", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Serve a Rileva i volti (menu Foto): un piccolo download da fare una volta sola.", + "Download Face Model": "Scarica il modello dei volti", + "Drag Crop Handle": "Trascina la maniglia del ritaglio", + "Evaluate Face Recognition": "Valuta il riconoscimento dei volti", + "Face Model Downloads": "Download dei modelli dei volti", + "Face Models": "Modelli dei volti", + "Face Recognition On/Off": "Riconoscimento dei volti attivo/disattivo", + "Face detector": "Rilevatore di volti", + "Face recognition": "Riconoscimento dei volti", + "Faces": "Volti", + "Faces of a Person": "Volti di una persona", + "Go back to listing them by name": "Torna all'elenco per nome", + "Index Faces": "Indicizza i volti", + "Index Faces in the Background": "Indicizza i volti in background", + "Inspect Face Model File": "Esamina il file del modello dei volti", + "Install Face Model": "Installa il modello dei volti", + "Library Folders": "Cartelle della libreria", + "Name Face": "Dai un nome al volto", + "Name Faces": "Dai un nome ai volti", + "Recognise faces (experimental)": "Riconosci i volti (sperimentale)", + "Recognition models": "Modelli di riconoscimento", + "Remove Face Model": "Rimuovi il modello dei volti", + "Reorder Album": "Riordina album", + "Show Person": "Mostra persona", + "Sort Albums A–Z": "Ordina album A-Z", + "Sort Albums by Name": "Ordina album per nome", + "Sort Contents A–Z": "Ordina contenuti A-Z", + "Suggest Names for Faces": "Suggerisci nomi per i volti", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Suggerisce chi c'è in una foto a partire dai volti a cui hai dato un nome. Tutto resta sul tuo computer.", + "Test Face Model": "Testa il modello dei volti", + "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", + "Unnamed Faces": "Volti senza nome" +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..0ae41bb0c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -126,6 +126,7 @@ language_table! { Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -667,6 +668,7 @@ mod tests { ("app.language.german", Locale::De), ("app.language.russian", Locale::Ru), ("app.language.ukrainian", Locale::Uk), + ("app.language.italian", Locale::It), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -921,6 +923,109 @@ mod tests { set_language(Locale::En); } + /// Italian's coverage of every catalog message and display label, reported like Ukrainian's. + #[test] + fn italian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::It.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("it lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("it lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn italian_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.italian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::It); + assert_eq!(crate::menubar::checked(&app, "app.language.italian"), Some(true)); + assert_eq!(tr("Light"), "Luce"); + assert_eq!(tr("Landscape"), "Paesaggio"); + assert_eq!(tr("Home"), "Cartella personale"); + assert_eq!(date_group_label("2026-09-20", false), "Domenica 20/09/2026"); + assert_eq!(date_group_label("2026-09", false), "09/2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Domenica 20/09/2026 16:04:05"); + // Italian agrees adjectives with the count ("1 foto importata", "2 foto importate"), so the + // count follows a label instead, which reads right for every number; the English plural + // suffix is consumed and never shows. + for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Foto importate: {n}")); + assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("{n} file, cartelle: {n}")); + let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); + let expected = if n == 1 { "1 immagine in cache".to_string() } else { format!("{n} immagini in cache") }; + assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); + } + // This trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + assert_eq!( + tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), + "Riduzione disturbo di IMG.CR3: 1 di 4 riquadri · in attesa: 5" + ); + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Foto aggiunte a “{name}”: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Bagliore caldo"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"it""#)); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::It); + let ctx = egui::Context::default(); + for code in ["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::It)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::It); + } + set_language(Locale::En); + } + + #[test] + fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains("Le mie foto") && text.contains("Tutte le foto"), "{text}"); + for (command, title) in + [("app.about", "Informazioni su LightCraft"), ("app.shortcuts", "Scorciatoie da tastiera"), ("app.settings", "Impostazioni")] + { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" + .chars() + .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// catalog can translate. What a language lacks is reported, not failed (like /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every @@ -1062,7 +1167,7 @@ mod tests { } #[test] - fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() { + fn german_ukrainian_and_italian_buttons_fit_the_bottom_bar_and_export_dialog() { fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { match shape { egui::epaint::Shape::Text(shape) => { @@ -1072,7 +1177,7 @@ mod tests { _ => {} } } - for language in [Locale::De, Locale::Uk] { + for language in [Locale::De, Locale::Uk, Locale::It] { let ctx = fonts_ctx(&[]); let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..cf635c429 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -46,6 +47,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { "app.language.german" => Some(crate::i18n::Locale::De), "app.language.russian" => Some(crate::i18n::Locale::Ru), "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.italian" => Some(crate::i18n::Locale::It), _ => None, } } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..9a41a47a1 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -617,13 +617,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ja", "zh-hans", "zh-hant"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 10, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 11, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..1991d2a6d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -103,7 +103,10 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + // One button per language: wrap rather than widen the dialog as languages are added. + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 18aae618a..a1ae77b6d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language measurement from the catalogs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Italian interface catalog added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-<code>.md` @@ -58,15 +58,16 @@ that is promising but is not recorded as review, so the column says `no (communi | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | -## Other languages LightCraft ships (3) +## Other languages LightCraft ships (4) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | | Ukrainian | `uk` | 2,287 / 2,304 (99%) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h | -Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai ~20–35 h on top of the shaping work. @@ -81,12 +82,13 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw ## Totals -- Bring the 9 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Bring the 10 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). - Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. -- Reach the target's 16 languages: + ~60–100 h (Italian, Dutch, Polish, Swedish, Norwegian, Thai). +- Reach the target's 16 languages: + ~50–85 h (Dutch, Polish, Swedish, Norwegian, Thai). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | diff --git a/docs/localization.md b/docs/localization.md index 715b871af..25959ffbf 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -30,6 +30,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); } ``` From 4f1ed6df652651464b68f01c1bc453f032d0257d Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:21:42 +0530 Subject: [PATCH 045/315] LRC-PRINT-LAYOUT: the print layout model (paper, margins, grid, package cells, packer, snapping) crates/engine/src/print/layout.rs is the pure page model the Print module builds on: ten named papers plus custom, orientation, margins and the printable area; Single Image, Contact Sheet (grid with spacing and square cells), Picture Package and Custom Package styles; package cells with a stacking order (send to back / backward / forward / bring to front); a deterministic MaxRects packer behind Auto Layout (largest first, earlier pages filled before a new one opens, a cell turned a quarter only when it fits nowhere as given); snap-to-cells and snap-to-grid helpers; ruler units and guide options. All lengths are inches in page space, after orientation. Every number from outside is checked (NaN, infinity, negative, oversized, cell and page caps) and Layout::validate guards saved layouts. No command or UI reaches it yet, so the parity rows keep their status. --- crates/engine/src/lib.rs | 1 + crates/engine/src/print/layout.rs | 1300 +++++++++++++++++++++++++++++ crates/engine/src/print/mod.rs | 4 + docs/gaps.md | 3 +- docs/parity-checklist.md | 7 +- 5 files changed, 1311 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/print/layout.rs create mode 100644 crates/engine/src/print/mod.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc6c431e..21fc30e46 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod print; pub mod proof; pub mod rename; pub mod segment; diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs new file mode 100644 index 000000000..8a9e1a027 --- /dev/null +++ b/crates/engine/src/print/layout.rs @@ -0,0 +1,1300 @@ +//! The Print module's page layout model: paper, margins, layout styles and cells. +//! +//! Pure data and geometry, no rendering. Every length is in **inches** in page space: the origin +//! is the top-left corner of the page *as printed* (after orientation), x grows right and y down. +//! Rulers show other units ([`Unit`]) but convert at the edge; the model never stores them. +//! +//! Four layout styles ([`Style`]): +//! * `singleImage`: one photo fills the printable area of each page; +//! * `contactSheet`: a grid of identical cells ([`GridSpec`]), the photos paginated over pages; +//! * `picturePackage`: one photo at several cell sizes, packed onto pages by [`Layout::auto_layout`]; +//! * `customPackage`: free placement of cells (overlap allowed, [`Stack`] order), each with its own photo. +//! +//! Package cells live in [`Layout::cells`] in stacking order (later = on top). Everything that +//! arrives from outside (commands, saved templates) passes through [`Layout::validate`]; all +//! setters reject NaN, infinite, negative and oversized numbers instead of storing them. +use serde::{Deserialize, Serialize}; + +/// Largest page edge accepted, inches. +pub const MAX_PAGE_IN: f64 = 60.0; +/// Smallest page edge accepted, inches. +pub const MIN_PAGE_IN: f64 = 1.0; +/// Smallest cell edge accepted, inches. +pub const MIN_CELL_IN: f64 = 0.1; +/// Package layouts hold at most this many cells. +pub const MAX_CELLS: usize = 200; +/// Package layouts span at most this many pages. +pub const MAX_PAGES: u32 = 50; +/// Largest margin accepted, inches. +pub const MAX_MARGIN_IN: f64 = 10.0; +/// The printable area must stay at least this wide and tall, inches. +pub const MIN_PRINTABLE_IN: f64 = 0.5; +/// Largest grid dimension of a contact sheet. +pub const MAX_GRID: usize = 20; +/// Geometry tolerance, inches (far below one printed pixel at any supported resolution). +pub const EPS: f64 = 1e-6; + +/// Paper sizes by name: portrait width and height in millimetres (ISO) or inches (US/photo). +const PAPER: &[(&str, f64, f64)] = &[ + ("a3", 297.0 / 25.4, 420.0 / 25.4), + ("a4", 210.0 / 25.4, 297.0 / 25.4), + ("a5", 148.0 / 25.4, 210.0 / 25.4), + ("letter", 8.5, 11.0), + ("legal", 8.5, 14.0), + ("tabloid", 11.0, 17.0), + ("4x6", 4.0, 6.0), + ("5x7", 5.0, 7.0), + ("8x10", 8.0, 10.0), + ("13x19", 13.0, 19.0), +]; + +/// Names accepted by [`PageSetup::paper`] (besides `custom`). +pub fn paper_names() -> impl Iterator<Item = &'static str> { + PAPER.iter().map(|p| p.0) +} + +/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`. +pub fn paper_size(name: &str) -> Option<(f64, f64)> { + let name = name.to_ascii_lowercase(); + PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2)) +} + +/// An axis-aligned rectangle in inches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +impl Rect { + pub const fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + Self { x, y, w, h } + } + pub fn right(&self) -> f64 { + self.x + self.w + } + pub fn bottom(&self) -> f64 { + self.y + self.h + } + pub fn area(&self) -> f64 { + self.w * self.h + } + pub fn is_finite(&self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.w.is_finite() && self.h.is_finite() + } + /// Whether the interiors overlap by more than [`EPS`] on both axes (touching edges do not count). + pub fn overlaps(&self, o: &Rect) -> bool { + self.x < o.right() - EPS && o.x < self.right() - EPS && self.y < o.bottom() - EPS && o.y < self.bottom() - EPS + } + /// Whether `o` lies entirely inside `self`, within [`EPS`]. + pub fn contains(&self, o: &Rect) -> bool { + o.x >= self.x - EPS && o.y >= self.y - EPS && o.right() <= self.right() + EPS && o.bottom() <= self.bottom() + EPS + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Orientation { + #[default] + Portrait, + Landscape, +} + +/// Ruler and dimension units. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + #[serde(rename = "in")] + Inches, + #[serde(rename = "cm")] + Centimeters, + #[serde(rename = "mm")] + Millimeters, + #[serde(rename = "pt")] + Points, +} + +impl Unit { + pub fn per_inch(self) -> f64 { + match self { + Unit::Inches => 1.0, + Unit::Centimeters => 2.54, + Unit::Millimeters => 25.4, + Unit::Points => 72.0, + } + } + pub fn from_inches(self, inches: f64) -> f64 { + inches * self.per_inch() + } + pub fn to_inches(self, value: f64) -> f64 { + value / self.per_inch() + } + pub fn label(self) -> &'static str { + match self { + Unit::Inches => "in", + Unit::Centimeters => "cm", + Unit::Millimeters => "mm", + Unit::Points => "pt", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Margins { + pub top: f64, + pub right: f64, + pub bottom: f64, + pub left: f64, +} + +impl Default for Margins { + fn default() -> Self { + Self::uniform(0.25) + } +} + +impl Margins { + pub const fn uniform(v: f64) -> Self { + Self { top: v, right: v, bottom: v, left: v } + } +} + +/// Paper, orientation and margins (Lightroom's Page Setup + the Page margins). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageSetup { + /// A name from [`paper_names`] or `custom`. + pub paper: String, + /// Portrait size of a `custom` paper, inches. + pub custom_width: f64, + pub custom_height: f64, + pub orientation: Orientation, + pub margins: Margins, +} + +impl Default for PageSetup { + fn default() -> Self { + Self { paper: "letter".into(), custom_width: 8.5, custom_height: 11.0, orientation: Orientation::Portrait, margins: Margins::default() } + } +} + +fn check(v: f64, what: &str, min: f64, max: f64) -> Result<f64, String> { + if v.is_finite() && v >= min && v <= max { Ok(v) } else { Err(format!("{what} must be a number from {min} to {max}")) } +} + +impl PageSetup { + /// Page width and height in inches after orientation. + pub fn size(&self) -> Result<(f64, f64), String> { + let (w, h) = if self.paper.eq_ignore_ascii_case("custom") { + ( + check(self.custom_width, "custom width", MIN_PAGE_IN, MAX_PAGE_IN)?, + check(self.custom_height, "custom height", MIN_PAGE_IN, MAX_PAGE_IN)?, + ) + } else { + paper_size(&self.paper) + .ok_or_else(|| format!("unknown paper `{}` (one of {} or custom)", self.paper, paper_names().collect::<Vec<_>>().join(", ")))? + }; + Ok(match self.orientation { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + }) + } + + /// The area inside the margins. + pub fn printable(&self) -> Result<Rect, String> { + let (w, h) = self.size()?; + let m = &self.margins; + let (t, r, b, l) = ( + check(m.top, "top margin", 0.0, MAX_MARGIN_IN)?, + check(m.right, "right margin", 0.0, MAX_MARGIN_IN)?, + check(m.bottom, "bottom margin", 0.0, MAX_MARGIN_IN)?, + check(m.left, "left margin", 0.0, MAX_MARGIN_IN)?, + ); + let (pw, ph) = (w - l - r, h - t - b); + if pw < MIN_PRINTABLE_IN || ph < MIN_PRINTABLE_IN { + return Err(format!("margins leave less than {MIN_PRINTABLE_IN} in of printable area")); + } + Ok(Rect::new(l, t, pw, ph)) + } + + /// The page size in PDF points (1/72 in). + pub fn size_points(&self) -> Result<(f64, f64), String> { + let (w, h) = self.size()?; + Ok((w * 72.0, h * 72.0)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Style { + #[default] + SingleImage, + ContactSheet, + PicturePackage, + CustomPackage, +} + +impl Style { + /// Styles whose cells are listed in [`Layout::cells`]. + pub fn is_package(self) -> bool { + matches!(self, Style::PicturePackage | Style::CustomPackage) + } +} + +/// The contact-sheet grid. `singleImage` ignores it (one cell per page, no spacing). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct GridSpec { + pub columns: usize, + pub rows: usize, + /// Gap between columns / rows, inches. + pub spacing_x: f64, + pub spacing_y: f64, + /// Make the cells square (the grid is centred in the printable area). + pub keep_square: bool, +} + +impl Default for GridSpec { + fn default() -> Self { + Self { columns: 3, rows: 4, spacing_x: 0.1, spacing_y: 0.1, keep_square: false } + } +} + +/// One cell of a package layout. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Cell { + pub id: u32, + /// Zero-based page the cell is on. + pub page: u32, + pub rect: Rect, + /// The photo printed in the cell (Custom Package); `None` uses the job's first photo. + /// Not part of a saved template. + #[serde(skip_serializing_if = "Option::is_none")] + pub photo: Option<u64>, +} + +/// Where a cell goes in the stacking order of its page. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stack { + ToBack, + Backward, + Forward, + ToFront, +} + +/// How dragged cells snap. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Snap { + Off, + /// To the edges of the other cells and of the printable area. + #[default] + Cells, + /// To multiples of [`Guides::grid_size`] from the printable area's corner. + Grid, +} + +/// Rulers, grid and guides (Lightroom's Guides panel). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Guides { + pub show_rulers: bool, + pub units: Unit, + pub snap: Snap, + /// Grid pitch, inches. + pub grid_size: f64, + pub show_margins: bool, + pub show_bleed: bool, + pub show_page_grid: bool, + pub show_cells: bool, + pub show_dimensions: bool, +} + +impl Default for Guides { + fn default() -> Self { + Self { + show_rulers: true, + units: Unit::Inches, + snap: Snap::Cells, + grid_size: 0.25, + show_margins: true, + show_bleed: false, + show_page_grid: false, + show_cells: true, + show_dimensions: false, + } + } +} + +impl Guides { + pub fn validate(&self) -> Result<(), String> { + check(self.grid_size, "grid size", 0.05, 10.0).map(|_| ()) + } +} + +/// The page layout: setup, style and cells. See the module docs for the units and style rules. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Layout { + pub style: Style, + pub page: PageSetup, + pub grid: GridSpec, + /// Gap kept between cells by [`Layout::auto_layout`] and new cells, inches. + pub spacing: f64, + /// Pages of a package layout (at least 1 and past every cell). + pub pages: u32, + /// Package cells in stacking order, back to front. + pub cells: Vec<Cell>, + next_id: u32, +} + +impl Default for Layout { + fn default() -> Self { + Self { + style: Style::SingleImage, + page: PageSetup::default(), + grid: GridSpec::default(), + spacing: 0.1, + pages: 1, + cells: Vec::new(), + next_id: 1, + } + } +} + +/// Free space on one page as a set of maximal rectangles (the MaxRects scheme). +struct Free(Vec<Rect>); + +impl Free { + fn new(area: Rect) -> Self { + Self(vec![area]) + } + + /// Removes `used` from the free space. + fn occupy(&mut self, used: &Rect) { + let mut next = Vec::with_capacity(self.0.len() + 4); + for f in &self.0 { + if !f.overlaps(used) { + next.push(*f); + continue; + } + if used.x > f.x + EPS { + next.push(Rect::new(f.x, f.y, used.x - f.x, f.h)); + } + if used.right() < f.right() - EPS { + next.push(Rect::new(used.right(), f.y, f.right() - used.right(), f.h)); + } + if used.y > f.y + EPS { + next.push(Rect::new(f.x, f.y, f.w, used.y - f.y)); + } + if used.bottom() < f.bottom() - EPS { + next.push(Rect::new(f.x, used.bottom(), f.w, f.bottom() - used.bottom())); + } + } + // Drop rectangles contained in another (the first of two equal ones survives). + let mut keep: Vec<Rect> = Vec::with_capacity(next.len()); + for (i, r) in next.iter().enumerate() { + let dominated = next.iter().enumerate().any(|(j, o)| i != j && o.contains(r) && (!r.contains(o) || j < i)); + if !dominated { + keep.push(*r); + } + } + self.0 = keep; + } + + /// The best spot for a `w` x `h` block: least leftover on the short side, then the long side, + /// then top-most and left-most. The block is turned a quarter (when `rotate`) only if it fits + /// nowhere as given, so cells keep their shape whenever they can. Returns the spot and + /// whether the block was turned. + fn best_fit(&self, w: f64, h: f64, rotate: bool) -> Option<(Rect, bool)> { + self.fit_block(w, h) + .map(|r| (r, false)) + .or_else(|| if rotate && (w - h).abs() >= EPS { self.fit_block(h, w).map(|r| (r, true)) } else { None }) + } + + fn fit_block(&self, bw: f64, bh: f64) -> Option<Rect> { + let mut best: Option<((f64, f64, f64, f64), Rect)> = None; + let eq = |a: f64, b: f64| (a - b).abs() < EPS; + for f in &self.0 { + if bw > f.w + EPS || bh > f.h + EPS { + continue; + } + let (dw, dh) = (f.w - bw, f.h - bh); + let key = (dw.min(dh), dw.max(dh), f.y, f.x); + let better = best.as_ref().is_none_or(|(k, _)| { + if !eq(k.0, key.0) { + return key.0 < k.0; + } + if !eq(k.1, key.1) { + return key.1 < k.1; + } + if !eq(k.2, key.2) { + return key.2 < k.2; + } + key.3 < k.3 - EPS + }); + if better { + best = Some((key, Rect::new(f.x, f.y, bw, bh))); + } + } + best.map(|(_, r)| r) + } +} + +/// Where each block of [`pack`] went: page index, rectangle in page space and whether it was turned. +pub type Placement = (u32, Rect, bool); + +/// Packs blocks (width, height) onto pages of `area`, keeping `spacing` between them. +/// +/// Deterministic: blocks are placed largest first (area, then longer side, then input order) with +/// the MaxRects best-short-side-fit rule, filling earlier pages before opening a new one. Blocks +/// may be turned a quarter when `rotate`. Fails when a block cannot fit an empty page or the +/// result would need more than [`MAX_PAGES`] pages. The result is in input order. +pub fn pack(sizes: &[(f64, f64)], area: Rect, spacing: f64, rotate: bool) -> Result<Vec<Placement>, String> { + if !area.is_finite() || area.w <= 0.0 || area.h <= 0.0 { + return Err("no printable area to pack into".into()); + } + let spacing = check(spacing, "spacing", 0.0, MAX_PAGE_IN)?; + if sizes.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + for (w, h) in sizes { + check(*w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(*h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + } + // Each block carries `spacing` on its right and bottom; the area grows by the same so a block + // flush with the margin still fits. + let padded = Rect::new(area.x, area.y, area.w + spacing, area.h + spacing); + let mut order: Vec<usize> = (0..sizes.len()).collect(); + order.sort_by(|&a, &b| { + let (sa, sb) = (sizes.get(a).copied().unwrap_or_default(), sizes.get(b).copied().unwrap_or_default()); + (sb.0 * sb.1).total_cmp(&(sa.0 * sa.1)).then((sb.0.max(sb.1)).total_cmp(&sa.0.max(sa.1))).then(a.cmp(&b)) + }); + let mut pages: Vec<Free> = Vec::new(); + let mut out: Vec<Option<Placement>> = vec![None; sizes.len()]; + for i in order { + let (w, h) = sizes.get(i).copied().unwrap_or_default(); + let (bw, bh) = (w + spacing, h + spacing); + let mut placed = None; + for (p, free) in pages.iter().enumerate() { + if let Some(hit) = free.best_fit(bw, bh, rotate) { + placed = Some((p, hit)); + break; + } + } + let (p, (spot, turned)) = match placed { + Some(v) => v, + None => { + if pages.len() as u32 >= MAX_PAGES { + return Err(format!("the cells need more than {MAX_PAGES} pages")); + } + let fresh = Free::new(padded); + let hit = fresh.best_fit(bw, bh, rotate).ok_or_else(|| format!("a {w:.2} x {h:.2} in cell does not fit the printable area"))?; + pages.push(fresh); + (pages.len() - 1, hit) + } + }; + if let Some(free) = pages.get_mut(p) { + free.occupy(&spot); + } + let (cw, ch) = if turned { (h, w) } else { (w, h) }; + if let Some(slot) = out.get_mut(i) { + *slot = Some((p as u32, Rect::new(spot.x, spot.y, cw, ch), turned)); + } + } + out.into_iter().map(|p| p.ok_or_else(|| "internal packing error".to_string())).collect() +} + +/// Snaps a span (`start`, `len`) along one axis so its start or end lands on the nearest candidate +/// within `threshold`; unchanged when none is close. +fn snap_span(start: f64, len: f64, candidates: &[f64], threshold: f64) -> f64 { + let mut best: Option<(f64, f64)> = None; + for &c in candidates { + for shift in [c - start, c - (start + len)] { + if shift.abs() <= threshold && best.is_none_or(|(d, _)| shift.abs() < d) { + best = Some((shift.abs(), shift)); + } + } + } + start + best.map_or(0.0, |b| b.1) +} + +/// Snaps one coordinate (a dragged edge) to the nearest candidate within `threshold`. +fn snap_edge(v: f64, candidates: &[f64], threshold: f64) -> f64 { + candidates.iter().copied().filter(|c| (c - v).abs() <= threshold).min_by(|a, b| (a - v).abs().total_cmp(&(b - v).abs())).unwrap_or(v) +} + +impl Layout { + /// Page size in inches after orientation. + pub fn page_size(&self) -> Result<(f64, f64), String> { + self.page.size() + } + + /// The printable area (inside the margins). + pub fn printable(&self) -> Result<Rect, String> { + self.page.printable() + } + + /// Checks every number and cross-reference; also repairs `next_id` so ids stay unique. + /// Run on anything that did not come from the setters (commands, templates). + pub fn validate(&mut self) -> Result<(), String> { + let area = self.page.printable()?; + check(self.spacing, "spacing", 0.0, 10.0)?; + if !(1..=MAX_GRID).contains(&self.grid.columns) || !(1..=MAX_GRID).contains(&self.grid.rows) { + return Err(format!("columns and rows must be 1 to {MAX_GRID}")); + } + check(self.grid.spacing_x, "column spacing", 0.0, 10.0)?; + check(self.grid.spacing_y, "row spacing", 0.0, 10.0)?; + for r in self.grid_rects_for(area)? { + if r.w < MIN_CELL_IN || r.h < MIN_CELL_IN { + return Err("the grid leaves cells smaller than 0.1 in".into()); + } + } + if self.cells.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if !(1..=MAX_PAGES).contains(&self.pages) { + return Err(format!("pages must be 1 to {MAX_PAGES}")); + } + let mut seen = std::collections::BTreeSet::new(); + for c in &self.cells { + if !seen.insert(c.id) { + return Err(format!("duplicate cell id {}", c.id)); + } + if c.page >= self.pages { + return Err(format!("cell {} is on page {} but the layout has {} pages", c.id, c.page + 1, self.pages)); + } + self.check_rect(&c.rect, area)?; + } + self.next_id = self.next_id.max(self.cells.iter().map(|c| c.id).max().unwrap_or(0).saturating_add(1)).max(1); + Ok(()) + } + + fn check_rect(&self, r: &Rect, area: Rect) -> Result<(), String> { + if !r.is_finite() || r.w < MIN_CELL_IN - EPS || r.h < MIN_CELL_IN - EPS { + return Err(format!("cells must be at least {MIN_CELL_IN} in wide and tall")); + } + if !area.contains(r) { + return Err("a cell lies outside the printable area".into()); + } + Ok(()) + } + + /// The cells of one grid page in reading order (`singleImage`: one cell filling the area). + pub fn grid_rects(&self) -> Result<Vec<Rect>, String> { + self.grid_rects_for(self.page.printable()?) + } + + fn grid_rects_for(&self, area: Rect) -> Result<Vec<Rect>, String> { + if self.style == Style::SingleImage { + return Ok(vec![area]); + } + let g = &self.grid; + let (cols, rows) = (g.columns.clamp(1, MAX_GRID), g.rows.clamp(1, MAX_GRID)); + let gaps_x = g.spacing_x * (cols - 1) as f64; + let gaps_y = g.spacing_y * (rows - 1) as f64; + let mut cw = (area.w - gaps_x) / cols as f64; + let mut ch = (area.h - gaps_y) / rows as f64; + if !(cw > 0.0 && ch > 0.0) { + return Err("the grid spacing leaves no room for cells".into()); + } + let (mut ox, mut oy) = (area.x, area.y); + if g.keep_square { + let side = cw.min(ch); + ox += (cw - side) * cols as f64 / 2.0; + oy += (ch - side) * rows as f64 / 2.0; + cw = side; + ch = side; + } + let mut out = Vec::with_capacity(cols * rows); + for r in 0..rows { + for c in 0..cols { + out.push(Rect::new(ox + c as f64 * (cw + g.spacing_x), oy + r as f64 * (ch + g.spacing_y), cw, ch)); + } + } + Ok(out) + } + + /// Cells per page of a grid style; 1 for the package styles. + pub fn cells_per_page(&self) -> usize { + match self.style { + Style::SingleImage => 1, + Style::ContactSheet => self.grid.columns.clamp(1, MAX_GRID) * self.grid.rows.clamp(1, MAX_GRID), + Style::PicturePackage | Style::CustomPackage => 1, + } + } + + /// Pages a print of `photos` photos needs: the photos over the grid for the grid styles, the + /// layout's own pages for the packages. + pub fn page_count(&self, photos: usize) -> usize { + match self.style { + Style::SingleImage | Style::ContactSheet => photos.max(1).div_ceil(self.cells_per_page()), + Style::PicturePackage | Style::CustomPackage => self.pages.max(1) as usize, + } + } + + pub fn cell(&self, id: u32) -> Option<&Cell> { + self.cells.iter().find(|c| c.id == id) + } + + fn index(&self, id: u32) -> Result<usize, String> { + self.cells.iter().position(|c| c.id == id).ok_or_else(|| format!("no cell {id}")) + } + + /// Cells of one page, back to front. + pub fn cells_on_page(&self, page: u32) -> impl Iterator<Item = &Cell> { + self.cells.iter().filter(move |c| c.page == page) + } + + /// Sets the layout style. Entering a package style from a grid style keeps existing cells. + pub fn set_style(&mut self, style: Style) { + self.style = style; + } + + fn require_package(&self) -> Result<(), String> { + if self.style.is_package() { Ok(()) } else { Err("cells exist only in the Picture Package and Custom Package styles".into()) } + } + + /// Adds a `w` x `h` in cell at the first free spot of the earliest page with room (a new page + /// when none has), and returns its id. A cell too large for the printable area is refused. + pub fn add_cell(&mut self, w: f64, h: f64) -> Result<u32, String> { + self.require_package()?; + let area = self.printable()?; + check(w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + if self.cells.len() >= MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if w > area.w + EPS || h > area.h + EPS { + return Err(format!("a {w:.2} x {h:.2} in cell is larger than the printable area ({:.2} x {:.2} in)", area.w, area.h)); + } + let padded = Rect::new(area.x, area.y, area.w + self.spacing, area.h + self.spacing); + let spot = (0..self.pages).find_map(|p| { + let mut free = Free::new(padded); + for c in self.cells_on_page(p) { + free.occupy(&Rect::new(c.rect.x, c.rect.y, c.rect.w + self.spacing, c.rect.h + self.spacing)); + } + free.best_fit(w + self.spacing, h + self.spacing, false).map(|(r, _)| (p, r)) + }); + let (page, at) = match spot { + Some(v) => v, + None => { + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + (self.pages - 1, Rect::new(area.x, area.y, w, h)) + } + }; + let id = self.take_id(); + self.cells.push(Cell { id, page, rect: Rect::new(at.x, at.y, w, h), photo: None }); + Ok(id) + } + + fn take_id(&mut self) -> u32 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + /// Moves a cell so its top-left corner is at (`x`, `y`), kept inside the printable area. + pub fn move_cell(&mut self, id: u32, x: f64, y: f64) -> Result<Rect, String> { + self.require_package()?; + let area = self.printable()?; + if !x.is_finite() || !y.is_finite() { + return Err("position must be finite".into()); + } + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + let r = &mut cell.rect; + r.x = x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + Ok(*r) + } + + /// Gives a cell a new rectangle (position and size), clamped into the printable area. + pub fn resize_cell(&mut self, id: u32, rect: Rect) -> Result<Rect, String> { + self.require_package()?; + let area = self.printable()?; + if !rect.is_finite() { + return Err("rectangle must be finite".into()); + } + let w = check(rect.w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.w); + let h = check(rect.h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.h); + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + cell.rect = Rect::new(rect.x.clamp(area.x, (area.right() - w).max(area.x)), rect.y.clamp(area.y, (area.bottom() - h).max(area.y)), w, h); + Ok(cell.rect) + } + + pub fn delete_cell(&mut self, id: u32) -> Result<(), String> { + let i = self.index(id)?; + self.cells.remove(i); + Ok(()) + } + + /// Removes every cell and extra page (Lightroom's Clear Layout). + pub fn clear(&mut self) { + self.cells.clear(); + self.pages = 1; + } + + /// Appends an empty page to a package layout and returns its zero-based index. + pub fn new_page(&mut self) -> Result<u32, String> { + self.require_package()?; + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + Ok(self.pages - 1) + } + + /// Moves a cell to another page, keeping its rectangle (clamped inside the printable area). + pub fn set_cell_page(&mut self, id: u32, page: u32) -> Result<(), String> { + self.require_package()?; + if page >= self.pages { + return Err(format!("the layout has {} pages", self.pages)); + } + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.page = page; + } + Ok(()) + } + + /// Assigns (or with `None` clears) the photo printed in a cell. + pub fn set_cell_photo(&mut self, id: u32, photo: Option<u64>) -> Result<(), String> { + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.photo = photo; + } + Ok(()) + } + + /// Changes a cell's place in the stacking order of its page (Send to Back, Backward, Forward, + /// Bring to Front). Cells on other pages are not moved past. + pub fn restack(&mut self, id: u32, how: Stack) -> Result<(), String> { + let i = self.index(id)?; + let page = self.cells.get(i).map(|c| c.page).ok_or("no such cell")?; + let same: Vec<usize> = self.cells.iter().enumerate().filter(|(_, c)| c.page == page).map(|(k, _)| k).collect(); + let pos = same.iter().position(|&k| k == i).ok_or("no such cell")?; + let target = match how { + Stack::ToBack => 0, + Stack::Backward => pos.saturating_sub(1), + Stack::Forward => (pos + 1).min(same.len().saturating_sub(1)), + Stack::ToFront => same.len().saturating_sub(1), + }; + if target == pos { + return Ok(()); + } + let cell = self.cells.remove(i); + // Index in the shortened vector where the target neighbour now sits. + let at = same.get(target).map(|&k| if k > i { k - 1 } else { k }).ok_or("no such cell")?; + let at = if target > pos { at + 1 } else { at }; + self.cells.insert(at.min(self.cells.len()), cell); + Ok(()) + } + + /// Re-packs every cell of the package onto as few pages as possible (Auto Layout). Cells keep + /// their sizes (a cell may be turned a quarter to fit) and their stacking order; their + /// photos are untouched. Fails, changing nothing, when a cell cannot fit the printable area. + pub fn auto_layout(&mut self) -> Result<(), String> { + self.require_package()?; + let area = self.printable()?; + let sizes: Vec<(f64, f64)> = self.cells.iter().map(|c| (c.rect.w, c.rect.h)).collect(); + let placed = pack(&sizes, area, self.spacing, true)?; + let mut pages = 1; + for (c, (page, rect, _)) in self.cells.iter_mut().zip(placed) { + c.page = page; + c.rect = rect; + pages = pages.max(page + 1); + } + self.pages = pages; + Ok(()) + } + + /// Whether two cells on the same page overlap. + pub fn has_overlaps(&self) -> bool { + self.cells.iter().enumerate().any(|(i, a)| self.cells.iter().skip(i + 1).any(|b| a.page == b.page && a.rect.overlaps(&b.rect))) + } + + /// Snap candidates along each axis for cell `skip` on `page`: edges of other cells and of the + /// printable area, or grid lines. + fn candidates(&self, page: u32, skip: Option<u32>, snap: Snap, grid: f64) -> Result<(Vec<f64>, Vec<f64>), String> { + let area = self.printable()?; + let (mut xs, mut ys) = (Vec::new(), Vec::new()); + match snap { + Snap::Off => {} + Snap::Cells => { + xs.extend([area.x, area.right()]); + ys.extend([area.y, area.bottom()]); + for c in self.cells_on_page(page).filter(|c| Some(c.id) != skip) { + xs.extend([c.rect.x, c.rect.right()]); + ys.extend([c.rect.y, c.rect.bottom()]); + } + } + Snap::Grid => { + let grid = check(grid, "grid size", 0.05, 10.0)?; + let n = |len: f64| (len / grid).floor().min(4000.0) as usize; + xs.extend((0..=n(area.w)).map(|k| area.x + k as f64 * grid)); + ys.extend((0..=n(area.h)).map(|k| area.y + k as f64 * grid)); + xs.push(area.right()); + ys.push(area.bottom()); + } + } + Ok((xs, ys)) + } + + /// Snaps a cell being dragged: `rect` keeps its size, and its left or right edge (likewise top + /// or bottom) lands on a candidate within `threshold` inches. + pub fn snap_move(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result<Rect, String> { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let area = self.printable()?; + let x = snap_span(rect.x, rect.w, &xs, threshold.max(0.0)); + let y = snap_span(rect.y, rect.h, &ys, threshold.max(0.0)); + Ok(Rect::new(x.clamp(area.x, (area.right() - rect.w).max(area.x)), y.clamp(area.y, (area.bottom() - rect.h).max(area.y)), rect.w, rect.h)) + } + + /// Snaps a cell being resized by its bottom-right corner: the right and bottom edges land on + /// candidates within `threshold`, the top-left corner stays. + pub fn snap_resize(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result<Rect, String> { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let right = snap_edge(rect.right(), &xs, threshold.max(0.0)); + let bottom = snap_edge(rect.bottom(), &ys, threshold.max(0.0)); + Ok(Rect::new(rect.x, rect.y, (right - rect.x).max(MIN_CELL_IN), (bottom - rect.y).max(MIN_CELL_IN))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package() -> Layout { + Layout { style: Style::PicturePackage, ..Default::default() } + } + + fn assert_clean(l: &Layout) { + let area = l.printable().unwrap(); + for c in &l.cells { + assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); + assert!(c.page < l.pages); + } + assert!(!l.has_overlaps(), "{:#?}", l.cells); + } + + #[test] + fn papers_and_orientation() { + let mut p = PageSetup::default(); + assert_eq!(p.size().unwrap(), (8.5, 11.0)); + p.orientation = Orientation::Landscape; + assert_eq!(p.size().unwrap(), (11.0, 8.5)); + assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); + p.paper = "A4".into(); + let (w, h) = p.size().unwrap(); + assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); + for n in paper_names() { + p.paper = n.into(); + assert!(p.printable().is_ok(), "{n}"); + } + p.paper = "custom".into(); + p.custom_width = 20.0; + p.custom_height = 30.0; + assert_eq!(p.size().unwrap(), (30.0, 20.0)); + p.paper = "poster".into(); + assert!(p.size().is_err()); + } + + #[test] + fn hostile_numbers_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { + let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; + assert!(p.size().is_err(), "{bad}"); + let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; + assert!(p.printable().is_err(), "{bad}"); + let mut l = package(); + assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); + assert!(l.add_cell(2.0, bad).is_err()); + let id = l.add_cell(2.0, 2.0).unwrap(); + assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); + assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + let mut g = Layout { spacing: bad, ..package() }; + assert!(g.validate().is_err()); + assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); + assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); + } + // Margins that leave nothing. + let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; + assert!(p.printable().is_err()); + let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; + assert!(l.validate().is_err()); + l.grid.columns = 21; + assert!(l.validate().is_err()); + } + + #[test] + fn units_convert() { + for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { + assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); + } + assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); + assert_eq!(Unit::Points.from_inches(2.0), 144.0); + assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); + } + + #[test] + fn contact_grid_fills_the_printable_area() { + let l = Layout { + style: Style::ContactSheet, + grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, + ..Default::default() + }; + let area = l.printable().unwrap(); + let cells = l.grid_rects().unwrap(); + assert_eq!(cells.len(), 12); + let first = cells.first().unwrap(); + let last = cells.last().unwrap(); + assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); + assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); + for (i, a) in cells.iter().enumerate() { + assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); + for b in cells.iter().skip(i + 1) { + assert!(!a.overlaps(b)); + } + } + // Square cells are centred. + let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; + let cells = sq.grid_rects().unwrap(); + assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); + let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); + assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); + // Single image ignores the grid. + let one = Layout { style: Style::SingleImage, ..l }; + assert_eq!(one.grid_rects().unwrap(), vec![area]); + } + + #[test] + fn page_counts() { + let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; + assert_eq!(l.page_count(0), 1); + assert_eq!(l.page_count(4), 1); + assert_eq!(l.page_count(5), 2); + l.style = Style::SingleImage; + assert_eq!(l.page_count(3), 3); + l.style = Style::PicturePackage; + l.pages = 4; + assert_eq!(l.page_count(99), 4); + } + + #[test] + fn packer_is_deterministic_and_clean() { + let area = Layout::default().printable().unwrap(); + let sizes: Vec<(f64, f64)> = + [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] + .to_vec(); + let a = pack(&sizes, area, 0.1, true).unwrap(); + assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); + assert_eq!(a.len(), sizes.len()); + for (i, (p, r, turned)) in a.iter().enumerate() { + assert!(area.contains(r), "{r:?}"); + let (w, h) = sizes[i]; + let expect = if *turned { (h, w) } else { (w, h) }; + assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); + for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { + if p == q { + // The spacing is kept between neighbours. + let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); + assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); + } + } + } + // The input order does not hide a block that needs a page of its own. + assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); + } + + #[test] + fn packer_rotates_only_when_needed_and_refuses_oversize() { + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + // 10 x 5 only fits a portrait page turned. + let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); + assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); + assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); + let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); + assert!(!r[0].2, "a block that fits as given is not turned"); + assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); + // A block that exactly fills the area fits despite the spacing. + assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); + assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); + // More pages than allowed. + assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); + assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); + } + + #[test] + fn packing_is_dense() { + // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); + assert!(r.iter().all(|p| p.0 == 0)); + // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). + let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); + assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); + } + + #[test] + fn add_cell_fills_gaps_then_opens_pages() { + let mut l = package(); + let a = l.add_cell(3.9, 6.0).unwrap(); + let b = l.add_cell(3.9, 6.0).unwrap(); + assert_eq!(l.cell(a).unwrap().rect.x, 0.25); + assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); + assert_clean(&l); + // Keep adding until a second page appears. + for _ in 0..6 { + l.add_cell(3.9, 6.0).unwrap(); + } + assert!(l.pages >= 2); + assert_clean(&l); + assert_eq!(l.cells.len(), 8); + assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); + let mut grid = Layout::default(); + assert!(grid.add_cell(1.0, 1.0).is_err()); + } + + #[test] + fn cell_cap() { + let mut l = package(); + for _ in 0..MAX_CELLS { + if l.add_cell(0.5, 0.5).is_err() { + break; + } + } + assert!(l.cells.len() <= MAX_CELLS); + let n = l.cells.len(); + if n == MAX_CELLS { + assert!(l.add_cell(0.5, 0.5).is_err()); + } + } + + #[test] + fn move_and_resize_stay_inside() { + let mut l = package(); + let id = l.add_cell(2.0, 3.0).unwrap(); + let r = l.move_cell(id, 100.0, -5.0).unwrap(); + let area = l.printable().unwrap(); + assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); + let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); + assert!(area.contains(&r)); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); + assert!(l.move_cell(77, 0.0, 0.0).is_err()); + l.delete_cell(id).unwrap(); + assert!(l.delete_cell(id).is_err()); + } + + #[test] + fn stacking_order() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let ids: Vec<u32> = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); + let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::<Vec<_>>(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::ToFront).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::ToBack).unwrap(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), ids); + assert!(l.restack(99, Stack::ToBack).is_err()); + } + + #[test] + fn stacking_ignores_other_pages() { + let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; + let a = l.add_cell(1.0, 1.0).unwrap(); + let b = l.add_cell(1.0, 1.0).unwrap(); + let c = l.add_cell(1.0, 1.0).unwrap(); + l.set_cell_page(b, 1).unwrap(); + l.restack(a, Stack::ToFront).unwrap(); + // `a` goes past `c` (same page) and not necessarily past `b`. + let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); + assert!(pos(a) > pos(c)); + } + + #[test] + fn overlap_is_allowed_by_move_and_reported() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(3.0, 3.0).unwrap(); + assert!(!l.has_overlaps()); + let r = l.cell(a).unwrap().rect; + l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); + assert!(l.has_overlaps()); + // Touching edges are not an overlap. + l.move_cell(b, r.right(), r.y).unwrap(); + assert!(!l.has_overlaps()); + } + + #[test] + fn auto_layout_repacks_cleanly_and_keeps_photos() { + let mut l = package(); + for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { + l.add_cell(w, h).unwrap(); + } + let first = l.cells[0].id; + l.set_cell_photo(first, Some(42)).unwrap(); + // Scramble positions to overlap, then auto layout. + for c in &mut l.cells { + c.rect.x = 0.25; + c.rect.y = 0.25; + c.page = 0; + } + l.pages = 1; + assert!(l.has_overlaps()); + l.auto_layout().unwrap(); + assert_clean(&l); + assert_eq!(l.cell(first).unwrap().photo, Some(42)); + assert_eq!(l.cells.len(), 6); + let again = l.clone(); + let mut l2 = again.clone(); + l2.auto_layout().unwrap(); + l.auto_layout().unwrap(); + assert_eq!(l, l2); + } + + #[test] + fn auto_layout_failure_changes_nothing() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); + let before = l.clone(); + assert!(l.auto_layout().is_err()); + assert_eq!(l, before); + } + + #[test] + fn clear_and_new_page() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + assert_eq!(l.new_page().unwrap(), 1); + assert_eq!(l.pages, 2); + l.clear(); + assert_eq!((l.cells.len(), l.pages), (0, 1)); + for _ in 1..MAX_PAGES { + l.new_page().unwrap(); + } + assert!(l.new_page().is_err()); + let mut g = Layout::default(); + assert!(g.new_page().is_err()); + } + + #[test] + fn validate_catches_bad_cells() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + let ok = l.clone(); + let mut bad = ok.clone(); + bad.cells[0].rect.w = f64::NAN; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].page = 3; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].rect.x = 50.0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells.push(bad.cells[0].clone()); + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.pages = 0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.next_id = 0; + bad.validate().unwrap(); + assert!(bad.next_id > bad.cells[0].id); + assert!(serde_json::from_str::<Layout>(r#"{"surprise":1}"#).is_err()); + let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); + round.validate().unwrap(); + assert_eq!(round, ok); + } + + #[test] + fn snapping_to_cells_grid_and_margins() { + let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(2.0, 2.0).unwrap(); + let ra = l.cell(a).unwrap().rect; + // Left edge of b near the right edge of a. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - ra.right()).abs() < 1e-9); + assert_eq!(m.y, 5.0); + // Too far: untouched. + let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); + // Right edge snaps to the printable margin. + let area = l.printable().unwrap(); + let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.right() - area.right()).abs() < 1e-9); + // Off leaves everything. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); + // Grid snapping lands on multiples from the printable corner. + let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); + // Resize snaps the dragged corner only. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); + assert_eq!((r.x, r.y), (5.0, 5.0)); + assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); + // Never below the minimum size. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); + assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); + } + + #[test] + fn cell_aspect_survives_auto_layout() { + let mut l = package(); + let id = l.add_cell(2.5, 3.5).unwrap(); + l.auto_layout().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); + } + + #[test] + fn guides_validate() { + assert!(Guides::default().validate().is_ok()); + for bad in [f64::NAN, 0.0, -1.0, 1e9] { + assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); + } + } + + #[test] + fn rect_helpers() { + let a = Rect::new(0.0, 0.0, 2.0, 2.0); + assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); + assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); + assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); + assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); + assert_eq!(a.area(), 4.0); + } +} diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs new file mode 100644 index 000000000..d309dbd6f --- /dev/null +++ b/crates/engine/src/print/mod.rs @@ -0,0 +1,4 @@ +//! The Print module: page layout, composition of pages and PDF output. +//! +//! [`layout`] is the pure page model (paper, margins, cells); the rest of the module renders it. +pub mod layout; diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..724758c8a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 15: print layout model; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 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/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..ee965a945 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -749,9 +749,9 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | | LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/print/layout.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing. The new layout model (ten paper sizes + custom, orientation, margins, grid with spacing and square cells, package cells with Auto Layout packing) is in the engine but not yet reachable from a command or the UI | | LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | -| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | `crates/engine/src/print/layout.rs` | cell model, deterministic packer, stacking order and snapping exist in the engine; no command or UI yet | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | | LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 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 | From 8336f06d283cae611344c8a97b5946963185e78f Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:28:40 +0530 Subject: [PATCH 046/315] LRC-PRINT-IMAGESETTINGS: compose print pages (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) print/compose.rs turns a layout and its photos into PageRasters: 16-bit interleaved RGB encoded in Rec. 2020 at the job's pixel density. Each cell's photo is rendered with the export pipeline at exactly the size the cell needs, then fitted (whole photo, centred) or zoomed to fill (centre crop), turned a quarter clockwise when Rotate to Fit makes it print larger (or crop less), framed by the Photo Border (package styles only) and finished with the Inner Stroke drawn inside the photo edge. The page background, border and stroke colours are given in sRGB and converted to the page encoding. Photos go to cells by layout style: Single Image and Contact Sheet paginate the selection, Picture Package repeats the first photo, Custom Package prints each cell's own photo. The job is prepared on the session and run off it with progress and cancellation, like the contact sheet; page and job pixel counts and page counts are capped so a hostile resolution or paper cannot exhaust memory. print/output.rs holds the interim PDF writer (Rec. 2020 to 8-bit sRGB JPEG pages sized to the paper in points) through the contact sheet's Pdf, now pub(crate). PrintJob carries the print resolution (72 to 720 ppi, 240 default). Tests draw on synthetic images for exact pixels (borders, strokes, crop, rotation direction, resampling) and on the demo library for whole pages. --- crates/engine/src/contact_sheet.rs | 10 +- crates/engine/src/print/compose.rs | 845 ++++++++++++++++++++++++++++ crates/engine/src/print/mod.rs | 81 ++- crates/engine/src/print/output.rs | 103 ++++ crates/engine/src/print/template.rs | 27 + docs/gaps.md | 1 + docs/parity-checklist.md | 7 +- 7 files changed, 1065 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/print/compose.rs create mode 100644 crates/engine/src/print/output.rs create mode 100644 crates/engine/src/print/template.rs diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index e83ad8c67..af4228e67 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -197,14 +197,14 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> } // Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -struct Pdf { - bytes: Vec<u8>, +pub(crate) struct Pdf { + pub(crate) bytes: Vec<u8>, offsets: Vec<usize>, points: (f64, f64), } impl Pdf { - fn new(pages: usize, points: (f64, f64)) -> Self { + pub(crate) fn new(pages: usize, points: (f64, f64)) -> Self { let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" "); @@ -226,7 +226,7 @@ impl Pdf { self.object(id, &body); } - fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { + pub(crate) fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { let id = 3 + index * 3; let (w, h) = self.points; self.object( @@ -246,7 +246,7 @@ impl Pdf { ); } - fn finish(mut self) -> Vec<u8> { + pub(crate) fn finish(mut self) -> Vec<u8> { let start = self.bytes.len(); self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); for offset in self.offsets.iter().skip(1) { diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs new file mode 100644 index 000000000..58b29b0d9 --- /dev/null +++ b/crates/engine/src/print/compose.rs @@ -0,0 +1,845 @@ +//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit), +//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s. +//! +//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one +//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a +//! background thread with progress and cancellation. +//! +//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the +//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border, +//! stroke) are converted to the same encoding. +use super::layout::{Layout, Rect, Style}; +use super::template::PrintSettings; +use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster}; +use crate::Session; +use crate::export::{ExportOptions, output_size}; +use crate::media::RenderJob; +use lightcraft_catalog::PhotoId; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace}; +use serde::{Deserialize, Serialize}; + +/// Most photos one print job takes. +pub const MAX_PHOTOS: usize = 1000; +/// Widest Photo Border, inches. +pub const MAX_BORDER_IN: f64 = 2.0; +/// Widest Inner Stroke, points. +pub const MAX_STROKE_PT: f64 = 36.0; +/// Renders kept for cells that print the same photo at the same size. +const RENDER_CACHE: usize = 4; + +/// Lightroom's Image Settings panel. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct ImageSettings { + /// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo. + pub zoom_to_fill: bool, + /// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill). + pub rotate_to_fit: bool, + /// A border around the photo inside its cell; Picture Package and Custom Package only. + pub border_enabled: bool, + /// Border width, inches. + pub border_width: f64, + /// Border colour, sRGB. + pub border_color: [u8; 3], + /// A stroke drawn inside the photo's edge, every layout style. + pub stroke_enabled: bool, + /// Stroke width, points. + pub stroke_width_pt: f64, + /// Stroke colour, sRGB. + pub stroke_color: [u8; 3], +} + +impl Default for ImageSettings { + fn default() -> Self { + Self { + zoom_to_fill: false, + rotate_to_fit: false, + border_enabled: false, + border_width: 0.25, + border_color: [255; 3], + stroke_enabled: false, + stroke_width_pt: 1.0, + stroke_color: [0; 3], + } + } +} + +impl ImageSettings { + pub fn validate(&self) -> Result<(), String> { + let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v); + if !ok(self.border_width, MAX_BORDER_IN) { + return Err(format!("border width must be 0 to {MAX_BORDER_IN} in")); + } + if !ok(self.stroke_width_pt, MAX_STROKE_PT) { + return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt")); + } + Ok(()) + } +} + +/// Lightroom's Page panel (the parts that are not margins or overlays). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageStyle { + /// Page background colour, sRGB. + pub background: [u8; 3], +} + +impl Default for PageStyle { + fn default() -> Self { + Self { background: [255; 3] } + } +} + +/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]). +pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { + let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0))); + let trc = OutputSpace::Rec2020.trc(); + lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) +} + +// ---- placement ------------------------------------------------------------------------------ + +/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + /// Turned a quarter (clockwise). + pub rotated: bool, + /// Scaled size after turning: at most the area when fitting, at least the area when filling. + pub w: usize, + pub h: usize, +} + +impl Placement { + /// The scaled size before turning (the size to render at). + pub fn unturned(&self) -> (usize, usize) { + if self.rotated { (self.h, self.w) } else { (self.w, self.h) } + } +} + +/// Decides turning and scale. `None` for an empty area or a photo without size. +pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option<Placement> { + if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 { + return None; + } + let (af, bf) = (aw as f64, ah as f64); + let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) }; + let (s0, s1) = (scale(iw, ih), scale(ih, iw)); + let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) }; + let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) }; + let (w, h) = if fill { + (((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah)) + } else { + ((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah)) + }; + Some(Placement { rotated, w, h }) +} + +/// A rectangle in page pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PxRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page. +pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option<PxRect> { + if !r.is_finite() { + return None; + } + let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize; + let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph)); + (x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) +} + +/// The cells of every page with the photo each prints, by layout style: Single Image and Contact +/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the +/// first photo in every cell; Custom Package prints each cell's own photo (else the first). +pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result<Vec<Vec<(Rect, PhotoId)>>, String> { + let first = photos.first().copied().ok_or("select at least one photo to print")?; + if photos.len() > MAX_PHOTOS { + return Err(format!("a print takes at most {MAX_PHOTOS} photos")); + } + match layout.style { + Style::SingleImage | Style::ContactSheet => { + let grid = layout.grid_rects()?; + let per_page = grid.len().max(1); + let pages = photos.len().div_ceil(per_page); + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos")); + } + Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect()) + } + Style::PicturePackage | Style::CustomPackage => { + let pages = layout.pages.max(1) as usize; + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if layout.cells.is_empty() { + return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into()); + } + let mut out: Vec<Vec<(Rect, PhotoId)>> = vec![Vec::new(); pages]; + for c in &layout.cells { + let photo = match layout.style { + Style::CustomPackage => c.photo.map_or(first, PhotoId), + _ => first, + }; + out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo)); + } + Ok(out) + } + } +} + +// ---- pixels ---------------------------------------------------------------------------------- + +/// 16-bit interleaved RGB. +#[derive(Clone, Debug, PartialEq)] +pub struct Image16 { + pub w: usize, + pub h: usize, + pub rgb: Vec<u16>, +} + +impl Image16 { + pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> { + let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; + let mut rgb = Vec::new(); + rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?; + for _ in 0..w * h { + rgb.extend_from_slice(&c); + } + Ok(Self { w, h, rgb }) + } + + fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> { + let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?; + let s = self.rgb.get(i..i.checked_add(3)?)?; + Some([*s.first()?, *s.get(1)?, *s.get(2)?]) + } + + /// Fills a rectangle (clipped to the image). + pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) { + let x1 = x.saturating_add(w).min(self.w); + for row in y..y.saturating_add(h).min(self.h) { + let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue }; + for px in line.as_chunks_mut::<3>().0 { + *px = c; + } + } + } + + /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. + pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { + let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + for row in 0..h { + let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); + let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); + if let (Some(from), Some(to)) = (from, to) { + to.copy_from_slice(from); + } + } + } + + /// A quarter turn clockwise. + pub fn rotated_cw(&self) -> Result<Image16, String> { + let mut out = Image16::filled(self.h, self.w, [0; 3])?; + for y in 0..self.h { + for x in 0..self.w { + let Some(p) = self.px(x, y) else { continue }; + // (x, y) lands at (h - 1 - y, x). + let i = (x * out.w + (self.h - 1 - y)) * 3; + if let Some(d) = out.rgb.get_mut(i..i + 3) { + d.copy_from_slice(&p); + } + } + } + Ok(out) + } + + /// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it. + pub fn resized(&self, nw: usize, nh: usize) -> Result<Image16, String> { + if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 { + return Err("empty image".into()); + } + if (nw, nh) == (self.w, self.h) { + return Ok(self.clone()); + } + let wx = axis_weights(self.w, nw); + let wy = axis_weights(self.h, nh); + // Horizontal pass into 16-bit rows, then the vertical pass. + let mut mid = Image16::filled(nw, self.h, [0; 3])?; + for y in 0..self.h { + let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue }; + let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (x, taps) in wx.iter().enumerate() { + for c in 0..3 { + let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum(); + if let Some(d) = dst.get_mut(x * 3 + c) { + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + let mut out = Image16::filled(nw, nh, [0; 3])?; + for (y, taps) in wy.iter().enumerate() { + let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (i, d) in dst.iter_mut().enumerate() { + let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum(); + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + Ok(out) + } +} + +/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1). +fn axis_weights(old: usize, new: usize) -> Vec<Vec<(usize, f32)>> { + let (o, n) = (old as f64, new as f64); + (0..new) + .map(|i| { + if new < old { + let step = o / n; + let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step); + let first = a.floor() as usize; + let last = (b.ceil() as usize).min(old); + (first..last) + .filter_map(|j| { + let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0); + (overlap > 0.0).then_some((j, (overlap / step) as f32)) + }) + .collect() + } else { + let pos = (i as f64 + 0.5) * o / n - 0.5; + let j0 = pos.floor(); + let f = (pos - j0) as f32; + let at = |j: f64| j.clamp(0.0, o - 1.0) as usize; + vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)] + } + }) + .collect() +} + +/// Everything about a cell's look that does not depend on the photo. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CellStyle { + pub fill: bool, + pub rotate: bool, + pub border_px: usize, + pub border: [u16; 3], + pub stroke_px: usize, + pub stroke: [u16; 3], +} + +impl CellStyle { + /// The style for `settings` at `ppi`; the border applies to the package styles only. + pub fn of(settings: &PrintSettings, ppi: f64) -> Self { + let img = &settings.image; + let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 }; + let stroke_px = + if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 }; + Self { + fill: img.zoom_to_fill, + rotate: img.rotate_to_fit, + border_px, + border: rec2020_from_srgb8(img.border_color), + stroke_px, + stroke: rec2020_from_srgb8(img.stroke_color), + } + } +} + +/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when +/// asked), then the inner stroke on top of it. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> { + // The border may not eat the cell: at least one pixel of photo area stays. + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (ax, ay) = (cell.x + border, cell.y + border); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (uw, uh) = p.unturned(); + let mut img = photo.resized(uw, uh)?; + if p.rotated { + img = img.rotated_cw()?; + } + // Where the photo lands: centred in the area, cropped to it when it overflows. + let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2); + let (w, h) = (img.w.min(aw), img.h.min(ah)); + let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2); + if border > 0 { + page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); + } + page.blit(&img, sx, sy, dx, dy, w, h); + let s = style.stroke_px.min(w.min(h) / 2); + if s > 0 { + page.fill_rect(dx, dy, w, s, style.stroke); + page.fill_rect(dx, dy + h - s, w, s, style.stroke); + page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke); + page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke); + } + Ok(()) +} + +// ---- the job --------------------------------------------------------------------------------- + +struct Item { + page: usize, + name: String, + cell: PxRect, + key: (u64, usize, usize), + job: RenderJob, +} + +/// A print job set up from the session: one render per cell, ready to run on another thread. +pub struct Prepared { + pages: Vec<(usize, usize)>, + items: Vec<Item>, + ppi: f32, + background: [u16; 3], + style: CellStyle, +} + +impl Prepared { + /// Renders (progress steps) the job will run. + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Pages the job will produce. + pub fn pages(&self) -> usize { + self.pages.len() + } + + /// Renders every photo and composes the pages. `progress(done, name)` is called before each + /// render with the photo's file name; returning `false` cancels (no partial result). + pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Vec<PageRaster>, String> { + let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new(); + let mut done = 0usize; + let mut items = self.items.into_iter().peekable(); + let mut out = Vec::with_capacity(self.pages.len()); + for (index, &(w, h)) in self.pages.iter().enumerate() { + let mut page = Image16::filled(w, h, self.background)?; + while let Some(item) = items.next_if(|i| i.page == index) { + if !progress(done, &item.name) { + return Err("Print cancelled".into()); + } + done += 1; + let at = cache.iter().position(|(k, _)| *k == item.key); + let at = match at { + Some(at) => at, + None => { + let img = render(item.job).map_err(|e| format!("{}: {e}", item.name))?; + if cache.len() >= RENDER_CACHE { + cache.remove(0); + } + cache.push((item.key, img)); + cache.len() - 1 + } + }; + let photo = &cache.get(at).ok_or("render cache lost an entry")?.1; + draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?; + } + out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); + } + if !progress(done, "") { + return Err("Print cancelled".into()); + } + Ok(out) + } +} + +fn render(job: RenderJob) -> Result<Image16, String> { + let rendered = job.run().rendered?; + let deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) }; + if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) { + return Err("the render is not Rec. 2020 RGB".into()); + } + Ok(Image16 { w: deep.width, h: deep.height, rgb }) +} + +/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell. +pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result<Prepared, String> { + let mut settings = settings.clone(); + settings.validate()?; + let ppi = f64::from(settings.job.effective_ppi()); + let (pw_in, ph_in) = settings.layout.page_size()?; + let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize); + let page_px = (pw as u64).saturating_mul(ph as u64); + if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS { + return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution")); + } + let plan = plan_cells(&settings.layout, photos)?; + if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS { + return Err("the print is too large for memory; lower the print resolution or print fewer pages".into()); + } + let style = CellStyle::of(&settings, ppi); + let mut items = Vec::new(); + for (page, cells) in plan.iter().enumerate() { + for (rect, id) in cells { + let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue }; + let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?; + let name = photo.file_name.clone(); + let (nw, nh) = output_size(photo, &ExportOptions::default()); + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (rw, rh) = p.unturned(); + let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?; + items.push(Item { page, name, cell, key: (id.0, rw, rh), job }); + } + } + Ok(Prepared { pages: vec![(pw, ph); plan.len()], items, ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::{GridSpec, Margins}; + + const RED: [u16; 3] = [60000, 1000, 1000]; + const BLUE: [u16; 3] = [1000, 1000, 60000]; + const WHITE: [u16; 3] = [65535; 3]; + + /// A `w` x `h` image whose left half is red and right half blue, with a green top row. + fn halves(w: usize, h: usize) -> Image16 { + let mut img = Image16::filled(w, h, RED).unwrap(); + img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); + img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); + img + } + + fn style() -> CellStyle { + CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } + } + + fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { + img.px(x, y).unwrap() + } + + fn page() -> Image16 { + Image16::filled(100, 100, WHITE).unwrap() + } + + #[test] + fn placement_fit_fill_and_rotation() { + // 4:3 photo into a 2:1 area. + let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); + assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); + let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); + assert_eq!((fill.w, fill.h), (200, 150)); + // A portrait photo in a landscape area: turning prints it larger. + assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); + let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); + assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); + assert_eq!(turned.unturned(), (300, 400)); + // Filling a landscape cell with a portrait photo crops less when turned. + let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); + assert!(f.rotated && f.w >= 400 && f.h >= 300); + // Matching orientation never turns. + assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); + assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); + // Hostile input. + for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { + assert!(place(bad, 10.0, 10, 10, false, false).is_none()); + assert!(place(10.0, bad, 10, 10, true, true).is_none()); + } + assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); + } + + #[test] + fn fit_centres_the_photo() { + let mut p = page(); + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); + // 40x20 scaled to 80x40, centred vertically at y 30..70. + assert_eq!(at(&p, 10, 50), RED); + assert_eq!(at(&p, 89, 50), BLUE); + assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); + assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); + assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); + } + + #[test] + fn zoom_to_fill_crops_the_centre() { + let mut p = page(); + let s = CellStyle { fill: true, ..style() }; + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); + // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. + for y in [10, 50, 89] { + assert_ne!(at(&p, 10, y), WHITE); + assert_ne!(at(&p, 89, y), WHITE); + } + assert_eq!(at(&p, 20, 50), RED); + assert_eq!(at(&p, 80, 50), BLUE); + // Nothing leaks outside the cell. + assert_eq!(at(&p, 9, 50), WHITE); + assert_eq!(at(&p, 90, 50), WHITE); + assert_eq!(at(&p, 50, 9), WHITE); + assert_eq!(at(&p, 50, 90), WHITE); + } + + #[test] + fn rotate_to_fit_turns_clockwise() { + // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. + let mut p = page(); + let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; + draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); + assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); + assert_eq!(at(&p, 45, 30), RED); + let mut q = page(); + draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); + // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. + assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); + assert_eq!(at(&q, 12, 48), BLUE); + assert_eq!(at(&q, 50, 20), RED); + assert_eq!(at(&q, 50, 40), BLUE); + assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); + } + + #[test] + fn border_frames_the_photo_inside_the_cell() { + let mut p = page(); + let green = [100, 60000, 100]; + let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); + assert_eq!(at(&p, 10, 40), green); + assert_eq!(at(&p, 14, 40), green); + assert_eq!(at(&p, 15, 40), RED); + assert_eq!(at(&p, 69, 40), green); + assert_eq!(at(&p, 64, 40), BLUE); + assert_eq!(at(&p, 40, 10), green); + assert_eq!(at(&p, 40, 69), green); + // A fitted photo gets the border hugging it, not the whole cell. + let mut q = page(); + let s = CellStyle { border_px: 5, border: green, ..style() }; + draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); + // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. + assert_eq!(at(&q, 50, 23), green); + assert_eq!(at(&q, 50, 22), green); + assert_eq!(at(&q, 50, 21), WHITE); + // A border wider than the cell leaves the photo visible. + let mut r = page(); + let s = CellStyle { border_px: 500, border: green, ..style() }; + draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); + assert_ne!(at(&r, 20, 20), WHITE); + } + + #[test] + fn stroke_is_drawn_inside_the_photo_edge() { + let mut p = page(); + let black = [0, 0, 0]; + let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); + for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { + assert_eq!(at(&p, x, y), black, "({x}, {y})"); + } + assert_eq!(at(&p, 23, 40), RED); + assert_eq!(at(&p, 76, 40), BLUE); + assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); + // Corners are covered exactly once and a huge stroke stays within the photo. + let mut q = page(); + let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; + draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); + assert_eq!(at(&q, 40, 40), black); + assert_eq!(at(&q, 19, 40), WHITE); + assert_eq!(at(&q, 60, 40), WHITE); + } + + #[test] + fn colours_are_rec2020_encoded() { + assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); + assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); + let grey = rec2020_from_srgb8([128; 3]); + assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); + // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. + let red = rec2020_from_srgb8([255, 0, 0]); + assert!(red[0] < 65535 && red[1] > 0); + } + + #[test] + fn resize_preserves_flat_colour_and_edges() { + let img = halves(64, 32); + for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { + let r = img.resized(w, h).unwrap(); + assert_eq!((r.w, r.h), (w, h)); + assert_eq!(r.rgb.len(), w * h * 3); + } + let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); + assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); + assert!(img.resized(0, 5).is_err()); + let r = img.resized(32, 16).unwrap(); + assert_eq!(at(&r, 4, 8), RED); + assert_eq!(at(&r, 28, 8), BLUE); + } + + #[test] + fn rotate_and_blit_are_clipped() { + let img = halves(3, 2); + let r = img.rotated_cw().unwrap(); + assert_eq!((r.w, r.h), (2, 3)); + let mut p = page(); + p.blit(&img, 0, 0, 99, 99, 10, 10); + assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); + p.blit(&img, 5, 5, 0, 0, 3, 3); + p.fill_rect(95, 95, 50, 50, RED); + assert_eq!(at(&p, 99, 99), RED); + p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); + } + + fn settings(style: Style) -> PrintSettings { + let mut s = PrintSettings::default(); + s.layout.style = style; + s.layout.page.paper = "4x6".into(); + s.layout.page.margins = Margins::uniform(0.5); + s.job.ppi = 100.0; + s + } + + #[test] + fn plans_follow_the_layout_style() { + let ids: Vec<PhotoId> = (1..=5).map(PhotoId).collect(); + let mut s = settings(Style::ContactSheet); + s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; + let plan = plan_cells(&s.layout, &ids).unwrap(); + assert_eq!(plan.iter().map(Vec::len).collect::<Vec<_>>(), vec![4, 1]); + assert_eq!(plan[1][0].1, PhotoId(5)); + let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); + assert_eq!(one.len(), 5); + assert!(plan_cells(&s.layout, &[]).is_err()); + assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); + + let mut pkg = settings(Style::PicturePackage); + assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); + pkg.layout.add_cell(1.0, 1.0).unwrap(); + pkg.layout.add_cell(2.0, 2.0).unwrap(); + let plan = plan_cells(&pkg.layout, &ids).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); + + let mut custom = settings(Style::CustomPackage); + let a = custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.set_cell_photo(a, Some(3)).unwrap(); + let plan = plan_cells(&custom.layout, &ids).unwrap(); + assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); + let blank_page = { + custom.layout.new_page().unwrap(); + plan_cells(&custom.layout, &ids).unwrap() + }; + assert_eq!(blank_page.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 0]); + } + + fn first_ids(s: &Session, n: usize) -> Vec<PhotoId> { + s.catalog.photos().take(n).map(|p| p.id).collect() + } + + #[test] + fn composes_pages_from_real_photos() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 3); + let mut set = settings(Style::ContactSheet); + set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; + set.page.background = [200, 30, 30]; + set.image.zoom_to_fill = true; + set.image.stroke_enabled = true; + set.image.stroke_width_pt = 2.0; + set.image.stroke_color = [0, 0, 0]; + let work = prepare(&mut s, &set, &ids).unwrap(); + assert_eq!((work.len(), work.pages()), (3, 2)); + let mut steps = Vec::new(); + let pages = work + .run(&mut |i, name| { + steps.push((i, name.to_string())); + true + }) + .unwrap(); + assert_eq!(steps.iter().map(|s| s.0).collect::<Vec<_>>(), vec![0, 1, 2, 3]); + assert_eq!(pages.len(), 2); + let first = &pages[0]; + assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); + assert_eq!(first.rgb.len(), 400 * 600 * 3); + let bg = rec2020_from_srgb8([200, 30, 30]); + let px = |p: &PageRaster, x: usize, y: usize| { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + }; + assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); + assert_eq!(px(first, 395, 595), bg); + // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. + assert_eq!(px(first, 50, 50), [0, 0, 0]); + let centre = px(first, 200, 150); + assert_ne!(centre, bg); + assert_ne!(centre, [0, 0, 0]); + // The second page holds the third photo in the first cell only: the second cell is bare. + let second = &pages[1]; + assert_ne!(px(second, 200, 150), bg); + assert_eq!(px(second, 200, 450), bg); + // Cancelling gives no pages. + let work = prepare(&mut s, &set, &ids).unwrap(); + assert!(work.run(&mut |i, _| i < 1).is_err()); + } + + #[test] + fn package_renders_each_size_and_border() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::PicturePackage); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.image.border_enabled = true; + set.image.border_width = 0.1; + set.image.border_color = [10, 200, 10]; + let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); + assert_eq!(pages.len(), 1); + let green = rec2020_from_srgb8([10, 200, 10]); + let cell = set.layout.cells[0].rect; + let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); + let at = |x: usize, y: usize| { + let i = (y * 400 + x) * 3; + pages[0].rgb[i..i + 3].to_vec() + }; + // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. + let mid = (x0 + 75, y0 + 50); + assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); + let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); + assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); + } + + #[test] + fn caps_and_hostile_settings_are_refused() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::SingleImage); + set.layout.page.paper = "custom".into(); + set.layout.page.custom_width = 60.0; + set.layout.page.custom_height = 60.0; + set.job.ppi = 720.0; + assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); + let mut set = settings(Style::SingleImage); + set.job.ppi = f32::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + let mut set = settings(Style::SingleImage); + set.image.border_width = f64::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + set.image.border_width = 0.1; + set.image.stroke_width_pt = -1.0; + assert!(prepare(&mut s, &set, &ids).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); + let set = settings(Style::SingleImage); + assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); + // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. + let mut big = settings(Style::SingleImage); + big.job.ppi = 720.0; + assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); + } +} diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs index d309dbd6f..a9ca3e35f 100644 --- a/crates/engine/src/print/mod.rs +++ b/crates/engine/src/print/mod.rs @@ -1,4 +1,83 @@ //! The Print module: page layout, composition of pages and PDF output. //! -//! [`layout`] is the pure page model (paper, margins, cells); the rest of the module renders it. +//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its +//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages +//! as a PDF. +pub mod compose; pub mod layout; +pub mod output; +pub mod template; + +use serde::{Deserialize, Serialize}; + +/// Lowest and highest print resolution, pixels per inch. +pub const MIN_PPI: f32 = 72.0; +pub const MAX_PPI: f32 = 720.0; +/// Pages a single print may have. +pub const MAX_JOB_PAGES: usize = 100; +/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60). +pub const MAX_PAGE_PIXELS: u64 = 100_000_000; +/// Most pixels a print job may hold across all its pages (6 bytes each in memory). +pub const MAX_JOB_PIXELS: u64 = 200_000_000; + +/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in +/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's). +/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches. +#[derive(Clone, Debug, PartialEq)] +pub struct PageRaster { + pub width: u32, + pub height: u32, + pub ppi: f32, + pub rgb: Vec<u16>, +} + +/// How a print job is produced (Lightroom's Print Job panel; colour management joins it later). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintJob { + /// Print resolution in pixels per inch, 72 to 720. + pub ppi: f32, +} + +impl Default for PrintJob { + fn default() -> Self { + Self { ppi: 240.0 } + } +} + +impl PrintJob { + /// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN). + pub fn effective_ppi(&self) -> f32 { + if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 } + } + + /// Rejects a resolution outside the supported range (commands and templates, which should + /// say so rather than silently clamp). + pub fn validate(&self) -> Result<(), String> { + if self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi) { + Ok(()) + } else { + Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ppi_is_clamped_and_validated() { + assert_eq!(PrintJob::default().effective_ppi(), 240.0); + for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] { + assert_eq!(PrintJob { ppi: v }.effective_ppi(), e); + } + assert!(PrintJob { ppi: 300.0 }.validate().is_ok()); + for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] { + assert!(PrintJob { ppi: bad }.validate().is_err()); + } + let j: PrintJob = serde_json::from_str("{}").unwrap(); + assert_eq!(j, PrintJob::default()); + assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err()); + } +} diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs new file mode 100644 index 000000000..f058ccf0b --- /dev/null +++ b/crates/engine/src/print/output.rs @@ -0,0 +1,103 @@ +//! Writes composed pages as a PDF. +//! +//! Interim writer: each page is converted from Rec. 2020 to 8-bit sRGB and embedded as a JPEG +//! covering the paper. Colour-managed output replaces the body of [`write`], not its signature. +use super::{MAX_JOB_PAGES, PageRaster, PrintJob}; +use crate::contact_sheet::Pdf; +use crate::export::{ExportFormat, ExportOptions, encode_image}; +use lightcraft_color::transfer::encode_srgb8; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::OutputSpace; +use lightcraft_raster::Rgba8; + +/// Largest PDF produced, bytes. +const MAX_PDF_BYTES: usize = 256 * 1024 * 1024; + +/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped). +pub fn page_to_srgb8(page: &PageRaster) -> Result<Rgba8, String> { + let (w, h) = (page.width as usize, page.height as usize); + let n = w.checked_mul(h).ok_or("page too large")?; + if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? { + return Err("a page's pixel data does not match its size".into()); + } + let trc = OutputSpace::Rec2020.trc(); + // 16-bit Rec. 2020 code → linear light, once per code value. + let lut: Vec<f32> = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect(); + let m = REC2020.to_space(&SRGB).to_f32(); + let mut out = Rgba8::new(w, h); + for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) { + let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0)); + let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]); + *dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255]; + } + Ok(out) +} + +/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in +/// points (`pixels / ppi * 72`) with the image covering it. +pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8>, String> { + let _ = (job, title); + let first = pages.first().ok_or("nothing to print")?; + if pages.len() > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if !(first.ppi.is_finite() && first.ppi > 0.0) { + return Err("a page has no resolution".into()); + } + let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0; + let size = (points(first.width), points(first.height)); + let mut pdf = Pdf::new(pages.len(), size); + for (i, page) in pages.iter().enumerate() { + if (page.width, page.height) != (first.width, first.height) { + return Err("all pages of a PDF must be the same size".into()); + } + let rgba = page_to_srgb8(page)?; + let jpg = encode_image(&rgba, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; + if pdf.bytes.len().saturating_add(jpg.len()) > MAX_PDF_BYTES { + return Err("the PDF would exceed 256 MiB; lower the print resolution or print fewer pages".into()); + } + pdf.page(i, &jpg, page.width as usize, page.height as usize); + } + Ok(pdf.finish()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::compose::rec2020_from_srgb8; + + fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster { + let c = rec2020_from_srgb8(srgb); + PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() } + } + + #[test] + fn colours_survive_the_round_trip() { + for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] { + let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0]; + for k in 0..3 { + assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}"); + } + } + assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err()); + assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err()); + } + + #[test] + fn pdf_pages_have_the_paper_size() { + let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; + let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.4")); + assert!(text.contains("/Count 2")); + assert!(text.contains("/MediaBox [0 0 306.00 396.00]"), "4.25 x 5.5 in is 306 x 396 pt"); + assert!(text.contains("/Width 425 /Height 550")); + assert!(write(&[], &PrintJob::default(), "t").is_err()); + let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; + assert!(write(&mixed, &PrintJob::default(), "t").is_err()); + let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) }; + assert!(write(&[bad], &PrintJob::default(), "t").is_err()); + let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) }; + assert!(write(&[zero], &PrintJob::default(), "t").is_err()); + } +} diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs new file mode 100644 index 000000000..2fd74ca60 --- /dev/null +++ b/crates/engine/src/print/template.rs @@ -0,0 +1,27 @@ +//! The settings a print is made from, and (with the saved-template commands) what a print +//! template stores. +use super::PrintJob; +use super::compose::{ImageSettings, PageStyle}; +use super::layout::{Guides, Layout}; +use serde::{Deserialize, Serialize}; + +/// Layout, image settings, page, guides and job: the whole Print module state. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintSettings { + pub layout: Layout, + pub image: ImageSettings, + pub page: PageStyle, + pub guides: Guides, + pub job: PrintJob, +} + +impl PrintSettings { + /// Checks every part; run on anything that did not come from the setters. + pub fn validate(&mut self) -> Result<(), String> { + self.layout.validate()?; + self.image.validate()?; + self.guides.validate()?; + self.job.validate() + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 724758c8a..4c3a84ef0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee965a945..e984542c8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -748,11 +748,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | | LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke and page background compose in the engine (16-bit Rec. 2020 pages, tested on pixels); no command or UI yet | | LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/print/layout.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing. The new layout model (ten paper sizes + custom, orientation, margins, grid with spacing and square cells, package cells with Auto Layout packing) is in the engine but not yet reachable from a command or the UI | | LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | | LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | `crates/engine/src/print/layout.rs` | cell model, deterministic packer, stacking order and snapping exist in the engine; no command or UI yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs`, `crates/engine/src/print/compose.rs` | optional filename captions; page background colour composes in the engine (no command or UI yet); no identity plates | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | | LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 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) | From 3b32f82e9e879e14d10b1f8afe96a572097fb895 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:35:03 +0530 Subject: [PATCH 047/315] LRC-PRINT-LAYOUTSTYLE: print.* commands for the layout styles, cells, templates and PDF The Print module's state lives in the session (Session::print), so the app, CLI, control channel and MCP share one print: layout style (Single Image, Contact Sheet, Picture Package, Custom Package), page setup, grid, image settings, page background, guides and job. 18 commands, none journaled (nothing here touches the catalog): print.get / set / reset set merges a partial patch, validates the whole result and changes nothing on error; a new paper or margin pulls cells back inside print.cell.add|move|resize|delete|stack|photo|page print.layout.autoLayout|newPage|clear print.exportPdf {path, ids?} composes and writes the PDF with an activity row, refuses to replace an original print.template.list|save|apply|delete Templates keep layout, image settings, page and job (not the photos assigned to cells, not the guides) and are saved in the library's prefs.json next to the export presets. They are stored as JSON and checked when applied, so a template from another version or a hostile one is an error on use and never makes the preferences file unreadable. Tests drive every command through the dispatcher (hostile numbers, unknown keys, caps, persistence across reopening a library) and a CLI end-to-end test chains set / add cells / Auto Layout / PDF in one run. --- apps/lightcraft-cli/tests/cli.rs | 39 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/print.rs | 351 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/library.rs | 4 + crates/engine/src/print/layout.rs | 32 +++ crates/engine/src/print/template.rs | 97 ++++++++ crates/engine/src/tests_print.rs | 307 ++++++++++++++++++++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 23 +- 10 files changed, 855 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/print.rs create mode 100644 crates/engine/src/tests_print.rs diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9aafc9310..fe29704b1 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -47,6 +47,45 @@ fn contact_sheet_exports_pdf_and_protects_originals() { assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output"); } +#[test] +fn print_commands_chain_into_a_picture_package_pdf() { + let input = tmp("print-input.png"); + gradient_png(&input); + let out = tmp("print.pdf"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "print.set", + r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#, + r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#, + "job={\"ppi\":72}", + "print.cell.add", + "width=1.5", + "height=1", + "print.cell.add", + "width=1.5", + "height=1", + "print.layout.autoLayout", + "print.exportPdf", + &format!("path={}", out.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let export = lines.last().unwrap(); + assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 432.00 288.00]"), "6 x 4 in"); + // a bad setting fails the run and writes nothing new + let before = std::fs::read(&out).unwrap(); + let (ok, _, _) = + run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None); + assert!(!ok); + assert_eq!(std::fs::read(&out).unwrap(), before); +} + fn tmp(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..5718d6546 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -32,6 +32,7 @@ mod organize; mod prefs; mod preset_files; pub mod previews; +mod print; mod query; mod sync; mod xmp; @@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(preset_files::specs()); v.extend(prefs::specs()); v.extend(export::specs()); + v.extend(print::specs()); v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs new file mode 100644 index 000000000..4fa77df86 --- /dev/null +++ b/crates/engine/src/cmd/print.rs @@ -0,0 +1,351 @@ +//! `print.*`: the Print module as commands. +//! +//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the +//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at +//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches +//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json. +//! +//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` +//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` +//! * `print.exportPdf {path, ids?}` +//! * `print.template.list|save|apply|delete` +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, f64_req}; +use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; +use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::{Result, Session}; + +fn rect_json(r: &Rect) -> Value { + json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h}) +} + +/// The state, with what a front end needs to draw it (page size, printable area, grid cells). +fn state(s: &Session, photos: usize) -> Value { + let l = &s.print.layout; + let mut out = json!({ + "settings": s.print, + "papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::<Vec<_>>(), + "overlaps": l.has_overlaps(), + "photos": photos, + }); + if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) { + o.insert("pageWidth".into(), json!(w)); + o.insert("pageHeight".into(), json!(h)); + o.insert("printable".into(), rect_json(&area)); + o.insert("pageCount".into(), json!(l.page_count(photos))); + o.insert("cellsPerPage".into(), json!(l.cells_per_page())); + if !l.style.is_package() { + o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::<Vec<_>>())); + } + } + out +} + +fn reply(s: &Session) -> Result<Value> { + Ok(state(s, s.targets(&Value::Null).len())) +} + +/// Applies `patch` over `dst`: objects merge key by key, anything else replaces. +fn merge(dst: &mut Value, patch: &Value, depth: usize) { + match (dst, patch) { + (Value::Object(d), Value::Object(p)) if depth < 8 => { + for (k, v) in p { + match d.get_mut(k) { + Some(slot) => merge(slot, v, depth + 1), + None => { + d.insert(k.clone(), v.clone()); + } + } + } + } + (d, p) => *d = p.clone(), + } +} + +fn get(s: &mut Session, p: &Value) -> Result<Value> { + Ok(state(s, s.targets(p).len())) +} + +fn set(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.set"; + let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?; + if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)"))); + } + if let Some(l) = patch.get("layout").and_then(Value::as_object) + && let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId")) + { + return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands"))); + } + let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?; + merge(&mut value, p, 0); + let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?; + // A new paper, orientation or margin may leave cells outside the page: pull them back. + if next.layout.page.printable().is_ok() { + next.layout.fit_cells().map_err(|e| bad(ID, e))?; + } + next.validate().map_err(|e| bad(ID, e))?; + s.print = next; + reply(s) +} + +fn reset(s: &mut Session, _: &Value) -> Result<Value> { + s.print = PrintSettings::default(); + reply(s) +} + +fn cell_id(p: &Value, id: &str) -> Result<u32> { + p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`")) +} + +fn opt_f64(p: &Value, key: &str, id: &str) -> Result<Option<f64>> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))), + } +} + +/// Snaps `r` (a cell being moved or resized) when the call asks for it. +fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result<Rect> { + let on = match p.get("snap") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`snap` must be true or false")), + }; + if !on || guides.snap == Snap::Off { + return Ok(r); + } + let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1); + let f = if resize { Layout::snap_resize } else { Layout::snap_move }; + f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e)) +} + +fn cell_add(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.add"; + let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?); + let mut next = s.print.layout.clone(); + let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?; + if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) { + if let Some(page) = p.get("page").and_then(Value::as_u64) { + let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?; + next.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + } + next.move_cell(id, x, y).map_err(|e| bad(ID, e))?; + } + if let Some(v) = p.get("photo").filter(|v| !v.is_null()) { + let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?; + next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?; + } + s.print.layout = next; + let cell = s.print.layout.cell(id).cloned(); + Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages})) +} + +fn cell_move(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.move"; + let id = cell_id(p, ID)?; + let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?); + let mut next = s.print.layout.clone(); + let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?; + let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_resize(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.resize"; + let id = cell_id(p, ID)?; + let mut next = s.print.layout.clone(); + let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let want = Rect::new( + opt_f64(p, "x", ID)?.unwrap_or(cur.x), + opt_f64(p, "y", ID)?.unwrap_or(cur.y), + opt_f64(p, "width", ID)?.unwrap_or(cur.w), + opt_f64(p, "height", ID)?.unwrap_or(cur.h), + ); + let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?; + let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_delete(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.delete"; + let id = cell_id(p, ID)?; + s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?; + Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()})) +} + +fn cell_stack(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.stack"; + let id = cell_id(p, ID)?; + let how = match p.get("order").and_then(Value::as_str) { + Some("toBack") => Stack::ToBack, + Some("backward") => Stack::Backward, + Some("forward") => Stack::Forward, + Some("toFront") => Stack::ToFront, + _ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")), + }; + s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?; + Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::<Vec<_>>()})) +} + +fn cell_photo(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.photo"; + let id = cell_id(p, ID)?; + let photo = match p.get("photo") { + None | Some(Value::Null) => None, + Some(v) => { + let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?; + if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() { + return Err(bad(ID, format!("no photo {n}"))); + } + Some(n) + } + }; + s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "photo": photo})) +} + +fn cell_page(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.page"; + let id = cell_id(p, ID)?; + let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?; + s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "page": page})) +} + +fn auto_layout(s: &mut Session, _: &Value) -> Result<Value> { + s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?; + Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells})) +} + +fn new_page(s: &mut Session, _: &Value) -> Result<Value> { + let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?; + Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES})) +} + +fn clear(s: &mut Session, _: &Value) -> Result<Value> { + s.print.layout.clear(); + Ok(json!({"pages": 1, "cells": 0})) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.exportPdf"; + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?; + if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)"))); + } + if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) { + return Err(bad(ID, "ids must be an array of photo ids")); + } + if p.get("id").is_some_and(|v| v.as_u64().is_none()) { + return Err(bad(ID, "id must be a photo id")); + } + s.check_write_target(path).map_err(|e| bad(ID, e))?; + let ids = s.targets(p); + let settings = s.print.clone(); + let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?; + let total = work.len() as u64; + let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No); + row.progress(0, total); + let pages = work + .run(&mut |done, name| { + row.progress(done as u64, total); + row.detail(name); + true + }) + .map_err(|e| bad(ID, e))?; + let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?; + s.check_write_target(path).map_err(|e| bad(ID, e))?; + crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()})) +} + +// ---- templates ------------------------------------------------------------------------------ + +fn template_list(s: &Session) -> Value { + json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::<Vec<_>>()}) +} + +fn template_name(p: &Value, id: &str) -> Result<String> { + clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e)) +} + +fn template_save(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.template.save"; + let name = template_name(p, ID)?; + let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?; + match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = t, + None => { + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + s.print_templates.push(t); + } + } + s.save_prefs()?; + Ok(template_list(s)) +} + +fn template_apply(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.template.apply"; + let name = template_name(p, ID)?; + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?; + reply(s) +} + +fn template_delete(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.template.delete"; + let name = template_name(p, ID)?; + let before = s.print_templates.len(); + s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if s.print_templates.len() == before { + return Err(bad(ID, format!("no print template `{name}`"))); + } + s.save_prefs()?; + Ok(template_list(s)) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!(query "print.get", "Print Settings", [], None, + "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", + always, get), + cmd!(query "print.set", "Set Print Settings", [], None, + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + always, set), + cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), + cmd!(query "print.cell.add", "Add Print Cell", [], None, + "{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given", + always, cell_add), + cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move), + cmd!(query "print.cell.resize", "Resize Print Cell", [], None, + "{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize), + cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete), + cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack), + cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo), + cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page), + cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout), + cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page), + cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear), + cmd!(query "print.exportPdf", "Print to PDF", [], None, + "{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original", + always, export_pdf), + cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))), + cmd!(query "print.template.save", "Save Print Template", [], None, + "{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list", + always, template_save), + cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), + cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 21fc30e46..4cce1d8df 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -251,6 +251,11 @@ pub struct Session { pub last_export: Option<serde_json::Value>, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec<export::ExportPreset>, + /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives + /// with the session, so the app, CLI, control channel and MCP all see the same print. + pub print: print::template::PrintSettings, + /// Saved print templates (`print.template.*`), persisted in prefs.json. + pub print_templates: Vec<print::template::PrintTemplate>, /// Metadata presets (`metadata.*`), persisted in prefs.json. pub metadata_presets: Vec<cmd::metadata::MetadataPreset>, /// Saved filter-bar settings (`filter.*`), persisted in prefs.json. @@ -371,6 +376,8 @@ impl Session { xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), + print: Default::default(), + print_templates: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), @@ -1020,6 +1027,8 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_process; #[cfg(test)] mod tests_segment; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c2..5e4da681e 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -161,6 +161,8 @@ struct PrefsFile { last_export: Option<serde_json::Value>, /// The user's export presets. export_presets: Vec<crate::export::ExportPreset>, + /// Print templates. + print_templates: Vec<crate::print::template::PrintTemplate>, /// Metadata presets. metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>, /// Filter presets. @@ -393,6 +395,7 @@ impl Session { self.xmp = prefs.xmp; self.last_export = prefs.last_export; self.export_presets = prefs.export_presets; + self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; @@ -628,6 +631,7 @@ impl Session { xmp: self.xmp, last_export: self.last_export.clone(), export_presets: self.export_presets.clone(), + print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs index 8a9e1a027..26d1818e5 100644 --- a/crates/engine/src/print/layout.rs +++ b/crates/engine/src/print/layout.rs @@ -819,6 +819,23 @@ impl Layout { Ok(()) } + /// Pulls every cell back inside the printable area (shrinking one that is bigger than it), for + /// after the paper, orientation or margins changed. + pub fn fit_cells(&mut self) -> Result<(), String> { + let area = self.printable()?; + for c in &mut self.cells { + let r = &mut c.rect; + if !r.is_finite() { + continue; + } + r.w = r.w.min(area.w); + r.h = r.h.min(area.h); + r.x = r.x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = r.y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + } + Ok(()) + } + /// Whether two cells on the same page overlap. pub fn has_overlaps(&self) -> bool { self.cells.iter().enumerate().any(|(i, a)| self.cells.iter().skip(i + 1).any(|b| a.page == b.page && a.rect.overlaps(&b.rect))) @@ -1271,6 +1288,21 @@ mod tests { assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); } + #[test] + fn fit_cells_follows_the_margins() { + let mut l = package(); + let id = l.add_cell(7.0, 10.0).unwrap(); + l.page.margins = Margins::uniform(1.0); + assert!(l.validate().is_err(), "the cell no longer fits"); + l.fit_cells().unwrap(); + l.validate().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!(l.printable().unwrap().contains(&r)); + assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); + l.page.paper = "poster".into(); + assert!(l.fit_cells().is_err()); + } + #[test] fn cell_aspect_survives_auto_layout() { let mut l = package(); diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs index 2fd74ca60..2465c123a 100644 --- a/crates/engine/src/print/template.rs +++ b/crates/engine/src/print/template.rs @@ -25,3 +25,100 @@ impl PrintSettings { self.job.validate() } } + +/// Longest template name, characters. +pub const MAX_NAME: usize = 80; +/// Most templates kept. +pub const MAX_TEMPLATES: usize = 200; + +/// A saved print template: the layout, image settings, page and job of a print, without the +/// photos assigned to cells and without the guides (a view preference). The settings are kept as +/// JSON so a template written by another version never makes the preferences file unreadable; +/// [`PrintTemplate::settings`] checks them when they are used. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintTemplate { + pub name: String, + pub settings: serde_json::Value, +} + +/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters. +pub fn clean_name(name: &str) -> Result<String, String> { + let name = name.trim(); + if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) { + return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters")); + } + Ok(name.to_string()) +} + +impl PrintTemplate { + /// A template of `settings` (validated first) named `name`. + pub fn new(name: &str, settings: &PrintSettings) -> Result<Self, String> { + let mut s = settings.clone(); + s.validate()?; + for c in &mut s.layout.cells { + c.photo = None; + } + let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?; + if let Some(o) = value.as_object_mut() { + o.remove("guides"); + } + Ok(Self { name: clean_name(name)?, settings: value }) + } + + /// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors. + /// `guides` are those of the print the template is applied to. + pub fn settings(&self, guides: &Guides) -> Result<PrintSettings, String> { + let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?; + s.guides = *guides; + s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; + Ok(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::Style; + use serde_json::json; + + #[test] + fn a_template_keeps_the_look_not_the_photos_or_guides() { + let mut s = PrintSettings::default(); + s.layout.style = Style::CustomPackage; + let id = s.layout.add_cell(3.0, 2.0).unwrap(); + s.layout.set_cell_photo(id, Some(77)).unwrap(); + s.image.zoom_to_fill = true; + s.page.background = [10, 20, 30]; + s.job.ppi = 300.0; + s.guides.grid_size = 1.0; + let t = PrintTemplate::new(" Proof sheet ", &s).unwrap(); + assert_eq!(t.name, "Proof sheet"); + assert!(t.settings.get("guides").is_none()); + let back = t.settings(&Guides::default()).unwrap(); + assert_eq!(back.layout.cells.len(), 1); + assert_eq!(back.layout.cells[0].photo, None); + assert!(back.image.zoom_to_fill); + assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0)); + assert_eq!(back.guides, Guides::default(), "the guides are the caller's"); + } + + #[test] + fn hostile_templates_are_rejected() { + for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] { + assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}"); + } + let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err(); + assert!(bad(json!("text"))); + assert!(bad(json!({"surprise": 1}))); + assert!(bad(json!({"job": {"ppi": 1e30}}))); + assert!(bad(json!({"layout": {"pages": 0}}))); + assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}}))); + assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}}))); + assert!(bad(json!({"image": {"borderWidth": -1}}))); + assert!(!bad(json!({}))); + let mut s = PrintSettings::default(); + s.job.ppi = f32::NAN; + assert!(PrintTemplate::new("t", &s).is_err()); + } +} diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs new file mode 100644 index 000000000..261d47d52 --- /dev/null +++ b/crates/engine/src/tests_print.rs @@ -0,0 +1,307 @@ +//! The `print.*` commands: state, cells, templates and the PDF. + +use lightcraft_catalog::MemStore; +use serde_json::{Value, json}; + +use crate::Session; +use crate::library::LibraryStores; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn fails(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string() +} + +fn package(s: &mut Session) { + run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}})); +} + +#[test] +fn get_set_and_reset() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["style"], "singleImage"); + assert_eq!(g["pageWidth"], 8.5); + assert_eq!(g["printable"]["x"], 0.25); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); + assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4")); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 1); + + let g = run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 2, "rows": 3}, "page": {"paper": "a4", "orientation": "landscape", "margins": {"left": 1.0}}}, "image": {"zoomToFill": true}, "job": {"ppi": 300.0}}), + ); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["left"], 1.0); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["top"], 0.25, "a patch merges"); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 6); + assert!((g["pageWidth"].as_f64().unwrap() - 11.693).abs() < 1e-3); + assert_eq!(g["settings"]["image"]["zoomToFill"], true); + // the page count follows the photos: 6 cells per page + let g = run(&mut s, "print.get", json!({"ids": vec![1; 7]})); + assert_eq!(g["pageCount"], 2); + + let g = run(&mut s, "print.reset", json!({})); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); +} + +#[test] +fn bad_settings_are_refused_and_change_nothing() { + let mut s = Session::with_demo(); + let before = run(&mut s, "print.get", json!({})); + for p in [ + json!({"typo": 1}), + json!({"layout": {"typo": 1}}), + json!({"layout": {"style": "poster"}}), + json!({"layout": {"cells": []}}), + json!({"layout": {"pages": 3}}), + json!({"layout": {"page": {"paper": "poster"}}}), + json!({"layout": {"page": {"paper": "custom", "customWidth": -1}}}), + json!({"layout": {"page": {"margins": {"top": 1e9}}}}), + json!({"layout": {"page": {"margins": {"top": 6.0, "bottom": 5.0}}}}), + json!({"layout": {"grid": {"columns": 0}}}), + json!({"layout": {"grid": {"columns": 9999}}}), + json!({"layout": {"spacing": -1}}), + json!({"image": {"borderWidth": 99}}), + json!({"image": {"strokeWidthPt": -2}}), + json!({"image": {"borderColor": [1, 2]}}), + json!({"image": {"zoomToFill": "yes"}}), + json!({"guides": {"gridSize": 0}}), + json!({"guides": {"units": "furlong"}}), + json!({"job": {"ppi": 1e30}}), + json!({"job": {"ppi": 10}}), + json!({"job": null}), + ] { + fails(&mut s, "print.set", p); + } + assert_eq!(run(&mut s, "print.get", json!({})), before); + assert!(s.execute("print.set", &json!([1])).is_err()); +} + +#[test] +fn cells_are_commands() { + let mut s = Session::with_demo(); + // grid styles have no cells + fails(&mut s, "print.cell.add", json!({"width": 2, "height": 2})); + package(&mut s); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0, "x": 5.0, "y": 5.0}))["id"].as_u64().unwrap(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["cells"].as_array().unwrap().len(), 2); + assert_eq!(g["overlaps"], false); + // move, clamped inside the margins + let r = run(&mut s, "print.cell.move", json!({"id": a, "x": 99.0, "y": 0.0})); + assert_eq!(r["rect"]["x"], 8.25 - 3.0); + assert_eq!(r["rect"]["y"], 0.25); + // resize keeps what is not given + let r = run(&mut s, "print.cell.resize", json!({"id": b, "width": 4.0})); + assert_eq!((r["rect"]["w"].as_f64().unwrap(), r["rect"]["h"].as_f64().unwrap()), (4.0, 2.0)); + // snapping to the other cell's edge + let ra = run(&mut s, "print.get", json!({}))["settings"]["layout"]["cells"][0]["rect"].clone(); + let left = ra["x"].as_f64().unwrap(); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.0)).abs() < 1e-9, "{r}"); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "no snap unless asked: {r}"); + run(&mut s, "print.set", json!({"guides": {"snap": "off"}})); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "snap off"); + // stacking + let r = run(&mut s, "print.cell.stack", json!({"id": a, "order": "toFront"})); + assert_eq!(r["order"], json!([b, a])); + fails(&mut s, "print.cell.stack", json!({"id": a, "order": "sideways"})); + // photos and pages + let photo = s.catalog.photos().next().unwrap().id.0; + run(&mut s, "print.cell.photo", json!({"id": a, "photo": photo})); + fails(&mut s, "print.cell.photo", json!({"id": a, "photo": 999_999})); + run(&mut s, "print.layout.newPage", json!({})); + run(&mut s, "print.cell.page", json!({"id": b, "page": 1})); + fails(&mut s, "print.cell.page", json!({"id": b, "page": 7})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["pageCount"], 2); + // delete, clear + run(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.move", json!({"id": 99, "x": 0, "y": 0})); + fails(&mut s, "print.cell.move", json!({"x": 0, "y": 0})); + run(&mut s, "print.layout.clear", json!({})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!((g["settings"]["layout"]["cells"].as_array().unwrap().len(), g["pageCount"].as_u64()), (0, Some(1))); +} + +#[test] +fn hostile_cell_numbers_are_refused() { + let mut s = Session::with_demo(); + package(&mut s); + for (w, h) in [(0.0, 2.0), (-1.0, 2.0), (1e12, 1e12), (2.0, 1e-9), (50.0, 50.0)] { + fails(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + fails(&mut s, "print.cell.add", json!({"width": "wide", "height": 2})); + fails(&mut s, "print.cell.add", json!({"width": 2})); + let id = run(&mut s, "print.cell.add", json!({"width": 2, "height": 2}))["id"].as_u64().unwrap(); + fails(&mut s, "print.cell.move", json!({"id": id, "x": "left", "y": 0})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": -4})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": "x"})); + fails(&mut s, "print.cell.move", json!({"id": id, "x": 1, "y": 1, "snap": "yes"})); + fails(&mut s, "print.cell.move", json!({"id": u64::MAX, "x": 1, "y": 1})); + let r = run(&mut s, "print.cell.move", json!({"id": id, "x": -1e300, "y": 1e300})); + assert_eq!(r["rect"]["x"], 0.25); +} + +#[test] +fn auto_layout_fills_pages_without_overlap() { + let mut s = Session::with_demo(); + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + for (w, h) in [(4.0, 6.0), (4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (8.0, 10.0)] { + run(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + let r = run(&mut s, "print.layout.autoLayout", json!({})); + assert!(r["pages"].as_u64().unwrap() >= 2); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["overlaps"], false); + let again = run(&mut s, "print.layout.autoLayout", json!({})); + assert_eq!(again["cells"], r["cells"], "deterministic"); + // an oversize cell that arrived by a paper change is pulled in, not left outside the page + run(&mut s, "print.set", json!({"layout": {"page": {"paper": "4x6"}}})); + let g = run(&mut s, "print.get", json!({})); + for c in g["settings"]["layout"]["cells"].as_array().unwrap() { + assert!(c["rect"]["x"].as_f64().unwrap() + c["rect"]["w"].as_f64().unwrap() <= 4.0 - 0.25 + 1e-9); + } +} + +fn temp_pdf(name: &str) -> String { + std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() +} + +#[test] +fn export_pdf_prints_the_current_settings() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 2}, "page": {"paper": "a5"}}, "job": {"ppi": 100.0}}), + ); + let path = temp_pdf("export"); + let r = run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["photos"].as_u64()), (Some(2), Some(3))); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-1.4") && text.contains("/Count 2")); + // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) + assert!(text.contains("/MediaBox [0 0 419.76 595.44]"), "{}", &text[..text.len().min(400)]); + // the row in the activity stack is gone when the print is done + assert!(s.activity.list().is_empty()); + fails(&mut s, "print.exportPdf", json!({})); + fails(&mut s, "print.exportPdf", json!({"path": path, "typo": 1})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ["x"]})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": [999_999]})); + // a package with no cells has nothing to print + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); +} + +#[test] +fn custom_package_prints_each_cells_photo() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + run(&mut s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}})); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + run(&mut s, "print.cell.photo", json!({"id": a, "photo": ids[0]})); + run(&mut s, "print.cell.photo", json!({"id": b, "photo": ids[1]})); + let r = run(&mut s, "print.exportPdf", json!({"path": temp_pdf("custom"), "ids": [ids[0]]})); + assert_eq!(r["pages"], 1); +} + +#[test] +fn templates_save_apply_delete_and_persist() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let mut s = open(&files); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 4, "rows": 5}}, "page": {"background": [10, 20, 30]}, "guides": {"gridSize": 1.0}}), + ); + let r = run(&mut s, "print.template.save", json!({"name": "Proofs"})); + assert_eq!(r["templates"][0]["name"], "Proofs"); + assert!(r["templates"][0]["settings"].get("guides").is_none()); + run(&mut s, "print.template.save", json!({"name": "proofs"})); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 1, "the same name in another case replaces"); + + run(&mut s, "print.reset", json!({})); + run(&mut s, "print.set", json!({"guides": {"gridSize": 0.5}})); + let g = run(&mut s, "print.template.apply", json!({"name": "PROOFS"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 4); + assert_eq!(g["settings"]["page"]["background"], json!([10, 20, 30])); + assert_eq!(g["settings"]["guides"]["gridSize"], 0.5, "guides stay as they were"); + + // it survives reopening the library + let mut again = open(&files); + assert_eq!(run(&mut again, "print.template.list", json!({}))["templates"][0]["name"], "proofs"); + run(&mut again, "print.template.apply", json!({"name": "Proofs"})); + + fails(&mut s, "print.template.save", json!({"name": " "})); + fails(&mut s, "print.template.save", json!({})); + fails(&mut s, "print.template.apply", json!({"name": "Nope"})); + fails(&mut s, "print.template.delete", json!({"name": "Nope"})); + run(&mut s, "print.template.delete", json!({"name": "proofs"})); + assert!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); + assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); +} + +#[test] +fn a_hostile_template_in_the_preferences_does_not_break_the_library() { + let files = MemStore::new(); + let hostile = json!({"printTemplates": [ + {"name": "Bad", "settings": {"layout": {"pages": 0}, "extra": true}}, + {"name": "Huge", "settings": {"job": {"ppi": 1e300}}}, + {"name": "Fine", "settings": {"layout": {"style": "contactSheet"}}} + ], "cacheMb": 123}); + files.set("prefs.json", serde_json::to_vec(&hostile).unwrap()); + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + assert!(s.take_library_warnings().is_empty(), "the preferences are not damaged"); + assert_eq!(s.cache_mb, 123); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 3); + fails(&mut s, "print.template.apply", json!({"name": "Bad"})); + fails(&mut s, "print.template.apply", json!({"name": "Huge"})); + run(&mut s, "print.template.apply", json!({"name": "Fine"})); +} + +#[test] +fn every_print_command_is_registered_with_a_description() { + for id in [ + "print.get", + "print.set", + "print.reset", + "print.cell.add", + "print.cell.move", + "print.cell.resize", + "print.cell.delete", + "print.cell.stack", + "print.cell.photo", + "print.cell.page", + "print.layout.autoLayout", + "print.layout.newPage", + "print.layout.clear", + "print.exportPdf", + "print.template.list", + "print.template.save", + "print.template.apply", + "print.template.delete", + ] { + let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); + assert!(c.params.len() > 3, "{id}"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 4c3a84ef0..72649a5bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: no Print view (layouts, cells, image settings and templates exist as `print.*` commands only), printer colour management, identity plate / watermark / page numbers / crop marks / photo info, Custom Package Auto Layout | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e984542c8..75dc988ae 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| Lightroom Classic extras | 34 | 19 | 32 | 8 | — | 24/26 (92%) | +| **Total** | 408 | 54 | 69 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.9% of 172. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -747,14 +747,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke and page background compose in the engine (16-bit Rec. 2020 pages, tested on pixels); no command or UI yet | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/print/layout.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing. The new layout model (ten paper sizes + custom, orientation, margins, grid with spacing and square cells, package cells with Auto Layout packing) is in the engine but not yet reachable from a command or the UI | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | -| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | `crates/engine/src/print/layout.rs` | cell model, deterministic packer, stacking order and snapping exist in the engine; no command or UI yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs`, `crates/engine/src/print/compose.rs` | optional filename captions; page background colour composes in the engine (no command or UI yet); no identity plates | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | Single Image, Contact Sheet, Picture Package and Custom Package are print layout styles reachable from commands, the CLI, control channel and MCP; no Print module UI yet (File → Contact Sheet PDF still the only dialog) | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | 🟡 | `cmd:print.set`, `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke (width in points, colour) through `print.set {image}`; pixel-tested; no panel yet | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi; commands only, no panel yet | +| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | `cmd:print.set` | guide options (ruler units in/cm/mm/pt, snap off/cells/grid, grid size, show margins/bleed/page grid/cells/dimensions) are stored and used by snapping in `print.cell.move`/`resize`; nothing is drawn until the Print UI lands | +| LRC-PRINT-CELLS | Picture-package cells | P2 | 🟡 | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout` | add/move/resize/delete cells of any size, stacking order, per-cell photos (Custom Package), deterministic Auto Layout packer, snapping to cells or grid; no UI (drag placement) yet | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | page background colour and margins through `print.set`; optional filename captions in the contact-sheet PDF; no identity plates, watermarks, page numbers or crop marks | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete` | named templates of layout, image settings, page and job saved with the library (prefs.json); no built-in templates, no UI, no template import/export | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | From 32b5aefc5b1aac6cfe2db374ed6c101338a646b7 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 15:15:33 +0530 Subject: [PATCH 048/315] =?UTF-8?q?LRC-PRINT-GUIDES:=20the=20Print=20dialo?= =?UTF-8?q?g=20(File=20=E2=86=92=20Print=E2=80=A6)=20with=20page=20preview?= =?UTF-8?q?,=20rulers,=20guides=20and=20cell=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File → Print… (⌘P) opens the Print module as a dialog: settings on the left, the page drawn to scale on the right with the photos as they will print (fit or Zoom to Fill, turned by Rotate to Fit, Photo Border, Inner Stroke, page background). Settings column: templates (save, apply, delete), layout style, image settings, grid / package cell controls (standard and custom cell sizes, Auto Layout, New Page, Clear Layout), rulers and guides (units in/cm/mm/pt, snap off/cells/grid, grid size, margins, bleed, page grid, image cells, dimensions), page setup (paper, orientation, margins), page background and the print job. Preview: rulers in the chosen unit, dashed margins and bleed, page grid, dimension labels, page navigation. In the package styles cells are selected, dragged (print.cell.move) and resized from a corner handle (print.cell.resize) with the guides' snapping, arranged from the inspector row or a context menu, and given a photo (Custom Package). Every change is a print.* command; the drag is measured from where the pointer went down so the click-versus-drag slop is not lost. Print to PDF… picks a file and renders on a background thread with an activity-stack row, cancellation and original protection, like the contact sheet, which keeps working unchanged. Headless tests drive the dialog with real pointer input through the control channel (print:cell:N, print:resize:N, button:print*): styles and checkboxes, add / drag / resize / arrange / delete, snapping to a neighbour, Auto Layout and pages, templates, the PDF, the activity row, and that the dialog and its buttons fit the window. --- README.md | 14 + apps/lightcraft-cli/tests/cli.rs | 7 +- crates/engine/src/print/output.rs | 6 +- crates/engine/src/tests_print.rs | 13 +- crates/ui-egui/src/export_task.rs | 56 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 28 +- crates/ui-egui/src/panels/dialogs.rs | 11 +- crates/ui-egui/src/print_view.rs | 823 +++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_print.rs | 300 ++++++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 23 +- 13 files changed, 1274 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/print_view.rs create mode 100644 crates/ui-egui/src/tests_print.rs diff --git a/README.md b/README.md index 8884f2bd3..8d6a8b414 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle. This is a contact-sheet workflow, not a printer-driver or custom picture-package layout editor. +- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image, + Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells, + stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper + sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi, + and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command + (`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`, + `print.template.save`…), so the CLI and MCP drive it too: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \ + print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf + ``` + + Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet. - **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a side door. - The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts. diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index fe29704b1..137caca7f 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -76,8 +76,11 @@ fn print_commands_chain_into_a_picture_package_pdf() { let export = lines.last().unwrap(); assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); let bytes = std::fs::read(&out).unwrap(); - assert!(bytes.starts_with(b"%PDF-1.4")); - assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 432.00 288.00]"), "6 x 4 in"); + assert!(bytes.starts_with(b"%PDF-")); + let text = String::from_utf8_lossy(&bytes); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in"); // a bad setting fails the run and writes nothing new let before = std::fs::read(&out).unwrap(); let (ok, _, _) = diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs index f058ccf0b..937884a8e 100644 --- a/crates/engine/src/print/output.rs +++ b/crates/engine/src/print/output.rs @@ -88,9 +88,11 @@ mod tests { let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("%PDF-1.4")); + assert!(text.starts_with("%PDF-")); assert!(text.contains("/Count 2")); - assert!(text.contains("/MediaBox [0 0 306.00 396.00]"), "4.25 x 5.5 in is 306 x 396 pt"); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); assert!(text.contains("/Width 425 /Height 550")); assert!(write(&[], &PrintJob::default(), "t").is_err()); let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 261d47d52..eb7ab032c 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -171,6 +171,14 @@ fn auto_layout_fills_pages_without_overlap() { } } +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + fn temp_pdf(name: &str) -> String { std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() } @@ -190,9 +198,10 @@ fn export_pdf_prints_the_current_settings() { let bytes = std::fs::read(&path).unwrap(); assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); let text = String::from_utf8_lossy(&bytes); - assert!(text.starts_with("%PDF-1.4") && text.contains("/Count 2")); + assert!(text.starts_with("%PDF-") && text.contains("/Count 2")); // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) - assert!(text.contains("/MediaBox [0 0 419.76 595.44]"), "{}", &text[..text.len().min(400)]); + let (w, h) = media_box(&bytes); + assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt"); // the row in the activity stack is gone when the print is done assert!(s.activity.list().is_empty()); fails(&mut s, "print.exportPdf", json!({})); diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 051ad22bd..470e2a377 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -132,6 +132,59 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va Ok(json!({"background": true, "total": total})) } +/// Print the session's current print (`print.get`) to a PDF in the background, with the same +/// activity row and cancellation as the other exports. +pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> { + use lightcraft_engine::print::{compose, output}; + if app.export.is_some() { + return Err("an export is already running".into()); + } + let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string(); + app.session.check_write_target(&path)?; + let guard = app.session.original_guard(); + let settings = app.session.print.clone(); + let ids = app.session.targets(params); + let prepared = compose::prepare(&mut app.session, &settings, &ids)?; + let total = prepared.len(); + let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let write = app.services.write_shared.clone(); + let progress = Arc::new(Mutex::new((0, String::new()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (p, c) = (progress.clone(), cancel.clone()); + let (tx, rx) = channel(); + let photos = ids.len(); + let work = move || { + let result = prepared + .run(&mut |done, name| { + if let Ok(mut g) = p.lock() { + *g = (done, name.to_string()); + } + !c.load(Ordering::Relaxed) + }) + .and_then(|pages| { + if c.load(Ordering::Relaxed) { + return Err("Print cancelled".into()); + } + let bytes = output::write(&pages, &settings.job, &title)?; + guard.check(std::path::Path::new(&path))?; + match write { + Some(write) => write(&path, &bytes)?, + None => lightcraft_engine::export::write_file(&path, &bytes)?, + } + Ok(vec![json!({"path": path, "pages": pages.len(), "photos": photos, "bytes": bytes.len(), "print": true})]) + }); + let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed)))); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("print".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone())); + row.progress(0, total as u64); + app.export = Some(ExportTask { total, progress, cancel, rx, guard: row }); + Ok(json!({"background": true, "total": total})) +} + /// The toast's account of [`AfterExport::run`]'s Apple Photos outcome. pub fn apple_photos_note(outcome: &Value) -> String { if let Some(e) = outcome.get("error").and_then(Value::as_str) { @@ -200,6 +253,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sheet) = sheet { m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } if skipped > 0 { m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..96ef1d91e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; +pub mod print_view; pub mod region; pub mod render; pub mod shortcuts; @@ -104,6 +105,8 @@ mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..ff7f1519d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,6 +162,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), @@ -1169,6 +1171,28 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); Ok(Value::Null) } + "dialog.print" => { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, ¶ms) + } "app.contactSheet" => { if app.export.is_some() { return Some(Err("an export is already running".into())); @@ -1798,7 +1822,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { match id { - "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "dialog.contactSheet" | "app.contactSheet" | "dialog.print" | "app.print" => { + !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none() + } s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { app.session.active().is_some() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..b574acbe3 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,7 @@ pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); fn window_id(dialog: &Dialog) -> egui::Id { egui::Id::new(match dialog { Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Print => "lightcraft-print-dialog", Dialog::Settings { .. } => "lightcraft-settings-dialog", _ => "lightcraft-dialog", }) @@ -251,6 +252,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", + Dialog::Print => "Print", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", @@ -270,6 +272,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); let window_id = window_id(&dlg); // Every other dialog fits the window (#781): its options scroll when they don't, and the title // bar and the button row stay outside the scroll area. The room they take is measured on the @@ -289,13 +293,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, _ => 380.0, }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); ui.add( @@ -1167,6 +1172,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); ui.horizontal(|ui| { @@ -1203,6 +1209,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label.as_str() } Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::Print => "Print to PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", @@ -1276,6 +1283,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } | Dialog::Export { .. } | Dialog::ContactSheet { .. } + | Dialog::Print | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1665,6 +1673,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso }), ), Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), + Dialog::Print => app.run("app.print", json!({})), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs new file mode 100644 index 000000000..7c1ab85fb --- /dev/null +++ b/crates/ui-egui/src/print_view.rs @@ -0,0 +1,823 @@ +//! The Print dialog (File ▸ Print…): settings on the left, the pages drawn to scale on the right. +//! +//! Every change goes through a `print.*` command ([`lightcraft_engine::cmd`]); this file only draws +//! the session's print ([`lightcraft_engine::Session::print`]) and turns pointer input into +//! commands: dragging a cell is `print.cell.move`, its corner handle `print.cell.resize`, with +//! the guides' snapping done by the command. Widgets register under `print:*`, `button:print*` +//! and `drag:print*` ids for the control channel and the headless tests. +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::export::ExportOptions; +use lightcraft_engine::print::compose::{self, place}; +use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; +use lightcraft_engine::print::template::PrintSettings; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::{register, text_button}; + +/// Width of the settings column. +const SETTINGS_W: f32 = 290.0; +/// Handle for resizing the selected cell, screen points. +const HANDLE: f32 = 12.0; +/// Snap distance while dragging, screen points. +const SNAP_PX: f32 = 8.0; +/// Cell sizes offered as buttons, inches (width, height). +const CELL_SIZES: &[(f64, f64)] = &[(2.0, 2.0), (2.5, 3.5), (3.5, 5.0), (4.0, 5.0), (4.0, 6.0), (5.0, 7.0), (8.0, 10.0), (3.0, 3.0)]; + +/// A drag of a cell in progress: the cell's rectangle when it began and how far the pointer went. +#[derive(Clone, Debug)] +pub struct Drag { + cell: u32, + origin: layout::Rect, + resize: bool, + /// Where the pointer was pressed; the distance dragged is measured from it, so the first few + /// points egui spends deciding between a click and a drag are not lost. + press: Pos2, + total: Vec2, +} + +/// What the Print dialog remembers between frames (not saved). +#[derive(Clone, Debug, Default)] +pub struct PrintUi { + /// The page being shown, zero-based. + pub page: usize, + /// The selected cell. + pub selected: Option<u32>, + drag: Option<Drag>, + /// The name typed for Save Template. + pub template_name: String, + /// Size of the next cell added, inches. + pub new_cell: Option<(f64, f64)>, +} + +fn run(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Option<Value> { + match app.run(id, params) { + Ok(v) => Some(v), + Err(e) => { + app.toast(ctx, e); + None + } + } +} + +fn patch(app: &mut LightcraftApp, ctx: &egui::Context, params: Value) { + run(app, ctx, "print.set", params); +} + +fn heading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.semibold(12.0)).color(t.text)); + ui.add_space(2.0); +} + +fn row<R>(ui: &mut Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(86.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn decimals(unit: Unit) -> usize { + match unit { + Unit::Inches | Unit::Centimeters => 2, + Unit::Millimeters => 1, + Unit::Points => 0, + } +} + +fn fmt_len(inches: f64, unit: Unit) -> String { + format!("{:.*} {}", decimals(unit), unit.from_inches(inches), unit.label()) +} + +/// A length field in the ruler's unit; the new value in inches when it changed. +fn length(ui: &mut Ui, id: &str, inches: f64, unit: Unit, min_in: f64, max_in: f64) -> Option<f64> { + let mut v = unit.from_inches(inches); + let r = ui.add( + egui::DragValue::new(&mut v) + .range(unit.from_inches(min_in)..=unit.from_inches(max_in)) + .speed(unit.per_inch() * 0.01) + .max_decimals(decimals(unit)) + .suffix(format!(" {}", unit.label())), + ); + register(ui.ctx(), format!("drag:{id}"), r.rect); + r.changed().then(|| unit.to_inches(v)) +} + +fn check(ui: &mut Ui, id: &str, label: &str, value: bool) -> Option<bool> { + let mut v = value; + let r = ui.checkbox(&mut v, crate::i18n::tr(label)); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed().then_some(v) +} + +fn choice<V: PartialEq + Copy>(ui: &mut Ui, id: &str, options: &[(V, &str)], current: V) -> Option<V> { + let mut out = None; + ui.spacing_mut().item_spacing.x = 3.0; + for (i, (v, l)) in options.iter().enumerate() { + if text_button(ui, &format!("{id}-{i}"), l, current == *v).clicked() && current != *v { + out = Some(*v); + } + } + out +} + +fn colour(ui: &mut Ui, id: &str, rgb: [u8; 3]) -> Option<[u8; 3]> { + let mut c = rgb; + let r = ui.color_edit_button_srgb(&mut c); + register(ui.ctx(), format!("color:{id}"), r.rect); + (c != rgb).then_some(c) +} + +fn paper_label(name: &str) -> String { + match name { + "a3" | "a4" | "a5" => name.to_uppercase(), + "letter" => "Letter".into(), + "legal" => "Legal".into(), + "tabloid" => "Tabloid".into(), + "custom" => "Custom".into(), + other => other.replace('x', " × ") + " in", + } +} + +/// The dialog's body: the settings column and the page preview side by side. +pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + let screen = ctx.content_rect(); + let width = (screen.width() - 80.0).clamp(640.0, 1180.0); + let height = (screen.height() - 190.0).clamp(280.0, 780.0); + let mut pu = std::mem::take(&mut app.ui.print); + let st = app.session.print.clone(); + let ids = app.session.targets(&json!({})); + // the page shown stays inside the print + let pages = st.layout.page_count(ids.len()); + pu.page = pu.page.min(pages.saturating_sub(1)); + if pu.selected.is_some_and(|c| st.layout.cell(c).is_none_or(|c| c.page as usize != pu.page)) && pu.drag.is_none() { + pu.selected = pu.selected.filter(|c| st.layout.cell(*c).is_some()); + } + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(SETTINGS_W, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(SETTINGS_W, height)); + egui::ScrollArea::vertical().id_salt("print-settings").max_height(height).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(SETTINGS_W - 14.0); + settings(app, ui, t, &mut pu, &st, ids.len()); + }); + }); + // (a `separator` would take the dialog's whole free height) + let (line, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover()); + ui.painter().line_segment([line.center_top(), line.center_bottom()], Stroke::new(1.0, t.divider)); + let w = (width - SETTINGS_W - 24.0).max(300.0); + ui.allocate_ui_with_layout(vec2(w, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(w, height)); + preview(app, ui, t, &mut pu, &st, &ids, pages); + }); + }); + app.ui.print = pu; +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + heading(ui, t, "Templates"); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) + .hint(crate::i18n::tr("Template name")) + .width(140.0) + .show(ui); + if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { + run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); + } + }); + let names: Vec<String> = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { + run(app, &ctx, "print.template.delete", json!({"name": name})); + } + if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { + run(app, &ctx, "print.template.apply", json!({"name": name})); + pu.page = 0; + pu.selected = None; + } + }); + }); + } + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + heading(ui, t, "Image Settings"); + let im = &st.image; + if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { + patch(app, &ctx, json!({"image": {"zoomToFill": v}})); + } + if let Some(v) = check(ui, "printRotateFit", "Rotate to Fit", im.rotate_to_fit) { + patch(app, &ctx, json!({"image": {"rotateToFit": v}})); + } + if l.style.is_package() { + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printBorder", "Photo Border", im.border_enabled) { + patch(app, &ctx, json!({"image": {"borderEnabled": v}})); + } + if let Some(w) = length(ui, "printBorderWidth", im.border_width, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"image": {"borderWidth": w}})); + } + if let Some(c) = colour(ui, "printBorderColor", im.border_color) { + patch(app, &ctx, json!({"image": {"borderColor": c}})); + } + }); + } + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printStroke", "Inner Stroke", im.stroke_enabled) { + patch(app, &ctx, json!({"image": {"strokeEnabled": v}})); + } + let mut pt = im.stroke_width_pt; + let r = ui.add(egui::DragValue::new(&mut pt).range(0.0..=36.0).speed(0.05).max_decimals(2).suffix(" pt")); + register(ui.ctx(), "drag:printStrokeWidth", r.rect); + if r.changed() { + patch(app, &ctx, json!({"image": {"strokeWidthPt": pt}})); + } + if let Some(c) = colour(ui, "printStrokeColor", im.stroke_color) { + patch(app, &ctx, json!({"image": {"strokeColor": c}})); + } + }); + + heading(ui, t, "Layout"); + match l.style { + Style::SingleImage => { + ui.label(egui::RichText::new(crate::i18n::tr("One photo fills each page inside the margins.")).color(t.text_dim)); + } + Style::ContactSheet => { + let g = &l.grid; + row(ui, t, "Grid", |ui| { + let (mut c, mut r) = (g.columns, g.rows); + let a = ui.add(egui::DragValue::new(&mut c).range(1..=layout::MAX_GRID).suffix(" cols")); + register(ui.ctx(), "drag:printColumns", a.rect); + let b = ui.add(egui::DragValue::new(&mut r).range(1..=layout::MAX_GRID).suffix(" rows")); + register(ui.ctx(), "drag:printRows", b.rect); + if a.changed() || b.changed() { + patch(app, &ctx, json!({"layout": {"grid": {"columns": c, "rows": r}}})); + } + }); + row(ui, t, "Spacing", |ui| { + if let Some(v) = length(ui, "printSpacingX", g.spacing_x, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingX": v}}})); + } + if let Some(v) = length(ui, "printSpacingY", g.spacing_y, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingY": v}}})); + } + }); + if let Some(v) = check(ui, "printKeepSquare", "Keep Square", g.keep_square) { + patch(app, &ctx, json!({"layout": {"grid": {"keepSquare": v}}})); + } + } + Style::PicturePackage | Style::CustomPackage => { + ui.label(egui::RichText::new(crate::i18n::tr("Add a cell")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, (w, h)) in CELL_SIZES.iter().enumerate() { + if text_button(ui, &format!("printAddCell-{i}"), &format!("{} × {}", trim(*w), trim(*h)), false).clicked() { + add_cell(app, &ctx, pu, *w, *h); + } + } + }); + let (mut w, mut h) = pu.new_cell.unwrap_or((3.0, 2.0)); + row(ui, t, "Custom size", |ui| { + if let Some(v) = length(ui, "printNewCellW", w, unit, layout::MIN_CELL_IN, 30.0) { + w = v; + } + if let Some(v) = length(ui, "printNewCellH", h, unit, layout::MIN_CELL_IN, 30.0) { + h = v; + } + }); + pu.new_cell = Some((w, h)); + ui.horizontal(|ui| { + if text_button(ui, "printAddCustom", "Add Cell", false).clicked() { + add_cell(app, &ctx, pu, w, h); + } + if l.style == Style::PicturePackage && text_button(ui, "printAutoLayout", "Auto Layout", false).clicked() { + run(app, &ctx, "print.layout.autoLayout", json!({})); + pu.page = 0; + } + }); + ui.horizontal(|ui| { + if text_button(ui, "printNewPage", "New Page", false).clicked() + && let Some(r) = run(app, &ctx, "print.layout.newPage", json!({})) + { + pu.page = r["page"].as_u64().unwrap_or(0) as usize; + } + if text_button(ui, "printClear", "Clear Layout", false).clicked() { + run(app, &ctx, "print.layout.clear", json!({})); + pu.page = 0; + pu.selected = None; + } + }); + row(ui, t, "Gap", |ui| { + if let Some(v) = length(ui, "printGap", l.spacing, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"spacing": v}})); + } + }); + } + } + + heading(ui, t, "Rulers, Grid & Guides"); + let g = &st.guides; + if let Some(v) = check(ui, "printRulers", "Show Rulers", g.show_rulers) { + patch(app, &ctx, json!({"guides": {"showRulers": v}})); + } + row(ui, t, "Units", |ui| { + let units = [(Unit::Inches, "in"), (Unit::Centimeters, "cm"), (Unit::Millimeters, "mm"), (Unit::Points, "pt")]; + if let Some(u) = choice(ui, "printUnits", &units, g.units) { + patch(app, &ctx, json!({"guides": {"units": u}})); + } + }); + row(ui, t, "Snap", |ui| { + if let Some(s) = choice(ui, "printSnap", &[(Snap::Off, "Off"), (Snap::Cells, "Cells"), (Snap::Grid, "Grid")], g.snap) { + patch(app, &ctx, json!({"guides": {"snap": s}})); + } + }); + row(ui, t, "Grid size", |ui| { + if let Some(v) = length(ui, "printGridSize", g.grid_size, unit, 0.05, 10.0) { + patch(app, &ctx, json!({"guides": {"gridSize": v}})); + } + }); + for (id, label, on, key) in [ + ("printShowMargins", "Page Margins", g.show_margins, "showMargins"), + ("printShowBleed", "Page Bleed", g.show_bleed, "showBleed"), + ("printShowGrid", "Page Grid", g.show_page_grid, "showPageGrid"), + ("printShowCells", "Image Cells", g.show_cells, "showCells"), + ("printShowDims", "Dimensions", g.show_dimensions, "showDimensions"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"guides": {(key): v}})); + } + } + + heading(ui, t, "Page Setup"); + row(ui, t, "Paper", |ui| { + let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(¤t)).show_ui(ui, |ui| { + for name in layout::paper_names().chain(std::iter::once("custom")) { + if ui.selectable_label(current == name, paper_label(name)).clicked() { + patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); + } + } + }); + register(ui.ctx(), "combo:printPaper", c.response.rect); + }); + if l.page.paper.eq_ignore_ascii_case("custom") { + row(ui, t, "Size", |ui| { + if let Some(v) = length(ui, "printCustomW", l.page.custom_width, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customWidth": v}}})); + } + if let Some(v) = length(ui, "printCustomH", l.page.custom_height, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customHeight": v}}})); + } + }); + } + row(ui, t, "Orientation", |ui| { + let o = [(layout::Orientation::Portrait, "Portrait"), (layout::Orientation::Landscape, "Landscape")]; + if let Some(v) = choice(ui, "printOrientation", &o, l.page.orientation) { + patch(app, &ctx, json!({"layout": {"page": {"orientation": v}}})); + } + }); + let m = &l.page.margins; + for (label, id, key, v) in [ + ("Left", "printMarginLeft", "left", m.left), + ("Right", "printMarginRight", "right", m.right), + ("Top", "printMarginTop", "top", m.top), + ("Bottom", "printMarginBottom", "bottom", m.bottom), + ] { + row(ui, t, label, |ui| { + if let Some(v) = length(ui, id, v, unit, 0.0, layout::MAX_MARGIN_IN) { + patch(app, &ctx, json!({"layout": {"page": {"margins": {(key): v}}}})); + } + }); + } + + heading(ui, t, "Page"); + row(ui, t, "Background", |ui| { + if let Some(c) = colour(ui, "printBackground", st.page.background) { + patch(app, &ctx, json!({"page": {"background": c}})); + } + }); + + heading(ui, t, "Print Job"); + row(ui, t, "Resolution", |ui| { + let mut ppi = st.job.ppi; + let r = ui.add(egui::DragValue::new(&mut ppi).range(72.0..=720.0).speed(1.0).max_decimals(0).suffix(" ppi")); + register(ui.ctx(), "drag:printPpi", r.rect); + if r.changed() { + patch(app, &ctx, json!({"job": {"ppi": ppi}})); + } + }); + ui.label(egui::RichText::new(crate::i18n::tr("PDF · sRGB · current edits")).color(t.text_dim)); +} + +fn trim(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn add_cell(app: &mut LightcraftApp, ctx: &egui::Context, pu: &mut PrintUi, w: f64, h: f64) { + if let Some(r) = run(app, ctx, "print.cell.add", json!({"width": w, "height": h})) { + pu.selected = r["id"].as_u64().and_then(|v| u32::try_from(v).ok()); + pu.page = r["cell"]["page"].as_u64().unwrap_or(0) as usize; + } +} + +// ---- preview ------------------------------------------------------------------------------- + +/// Screen geometry of the page on show. +struct Sheet { + page: Rect, + /// Screen points per inch. + scale: f32, +} + +impl Sheet { + fn rect(&self, r: &layout::Rect) -> Rect { + Rect::from_min_size(self.page.min + vec2(r.x as f32, r.y as f32) * self.scale, vec2(r.w as f32, r.h as f32) * self.scale) + } +} + +fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, ids: &[PhotoId], pages: usize) { + let ctx = ui.ctx().clone(); + // room for the page buttons and, for a package, the selected cell's row (which may wrap) + let footer = if st.layout.style.is_package() { 104.0 } else { 44.0 }; + let size = vec2(ui.available_width(), (ui.available_height() - footer).max(120.0)); + let (area, _) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(area); + painter.rect_filled(area, 4.0, t.canvas); + let Ok((pw, ph)) = st.layout.page_size() else { + painter.text(area.center(), Align2::CENTER_CENTER, crate::i18n::tr("This page setup is not valid"), t.font(13.0), t.caution); + return; + }; + let g = &st.guides; + let ruler = if g.show_rulers { 22.0 } else { 0.0 }; + let inner = Rect::from_min_max(area.min + vec2(ruler, ruler), area.max).shrink(14.0); + let scale = ((inner.width() as f64 / pw).min(inner.height() as f64 / ph) as f32).max(1.0); + let sheet = Sheet { page: Rect::from_center_size(inner.center(), vec2(pw as f32, ph as f32) * scale), scale }; + register(&ctx, "print:page", sheet.page); + + painter.rect_filled(sheet.page.translate(vec2(2.0, 3.0)), 0.0, Color32::from_black_alpha(70)); + let bg = st.page.background; + painter.rect_filled(sheet.page, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + let guide = Stroke::new(1.0, Color32::from_rgb(90, 160, 240)); + if g.show_rulers { + rulers(&painter, t, &sheet, area, pw, ph, g.units); + } + let area_in = st.layout.printable().ok(); + if g.show_bleed { + let b = 0.125; + dashed(&painter, sheet.page.expand(b as f32 * scale), Stroke::new(1.0, t.caution)); + } + if g.show_page_grid + && let Some(a) = area_in + { + let faint = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 70)); + let step = g.grid_size.max(0.05); + let mut x = 0.0; + while a.x + x <= a.right() + 1e-9 && x < 200.0 { + let sx = sheet.page.left() + (a.x + x) as f32 * scale; + painter.line_segment([pos2(sx, sheet.page.top()), pos2(sx, sheet.page.bottom())], faint); + x += step; + } + let mut y = 0.0; + while a.y + y <= a.bottom() + 1e-9 && y < 200.0 { + let sy = sheet.page.top() + (a.y + y) as f32 * scale; + painter.line_segment([pos2(sheet.page.left(), sy), pos2(sheet.page.right(), sy)], faint); + y += step; + } + } + if g.show_margins + && let Some(a) = area_in + { + dashed(&painter, sheet.rect(&layout::Rect::new(a.x, a.y, a.w, a.h)), guide); + } + + let package = st.layout.style.is_package(); + if package { + package_cells(app, ui, &painter, t, pu, st, ids, &sheet); + } else { + let plan = compose::plan_cells(&st.layout, ids).ok(); + let cells: Vec<(layout::Rect, Option<PhotoId>)> = match plan.as_ref().and_then(|p| p.get(pu.page)) { + Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), + None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), + }; + for (i, (r, photo)) in cells.iter().enumerate() { + let rect = sheet.rect(r); + register(&ctx, format!("print:gridcell:{i}"), rect); + paint_cell(app, &painter, t, st, rect, *photo, &sheet, r); + } + } + + // footer: page navigation and the selected cell + ui.horizontal(|ui| { + if text_button(ui, "printPagePrev", "◀", false).clicked() && pu.page > 0 { + pu.page -= 1; + pu.selected = None; + } + let label = ui.label(egui::RichText::new(format!("{} {} / {}", crate::i18n::tr("Page"), pu.page + 1, pages)).color(t.text)); + register(ui.ctx(), "print:pageLabel", label.rect); + if text_button(ui, "printPageNext", "▶", false).clicked() && pu.page + 1 < pages { + pu.page += 1; + pu.selected = None; + } + ui.label(egui::RichText::new(fmt_len(pw, g.units) + " × " + &fmt_len(ph, g.units)).color(t.text_dim)); + if package { + ui.label(egui::RichText::new(format!("{} {}", st.layout.cells.len(), crate::i18n::tr("cells"))).color(t.text_dim)); + if st.layout.has_overlaps() { + ui.label(egui::RichText::new(crate::i18n::tr("cells overlap")).color(t.caution)); + } + } + }); + if package { + inspector(app, ui, t, pu, st); + } +} + +fn press_origin(ui: &Ui, resp: &egui::Response) -> Pos2 { + ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).unwrap_or_default() +} + +/// Draws and handles every cell of a package page, back to front. +#[allow(clippy::too_many_arguments)] +fn package_cells( + app: &mut LightcraftApp, + ui: &mut Ui, + painter: &egui::Painter, + t: &Tokens, + pu: &mut PrintUi, + st: &PrintSettings, + ids: &[PhotoId], + sheet: &Sheet, +) { + let ctx = ui.ctx().clone(); + let first = ids.first().copied(); + let cells: Vec<layout::Cell> = st.layout.cells_on_page(pu.page as u32).cloned().collect(); + for c in &cells { + let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first }; + let rect = sheet.rect(&c.rect); + paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect); + let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag()); + register(&ctx, format!("print:cell:{}", c.id), rect); + if resp.clicked() || resp.drag_started() { + pu.selected = Some(c.id); + } + if resp.drag_started() { + pu.drag = Some(Drag { cell: c.id, origin: c.rect, resize: false, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == c.id && !d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (x, y) = (d.origin.x + f64::from(d.total.x / sheet.scale), d.origin.y + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run(app, &ctx, "print.cell.move", json!({"id": c.id, "x": x, "y": y, "snap": true, "threshold": threshold})); + } + if resp.drag_stopped() { + pu.drag = None; + } + let id = c.id; + resp.context_menu(|ui| { + for (how, label) in + [("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")] + { + if ui.button(crate::i18n::tr(label)).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete Cell")).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + ui.close(); + } + }); + } + // the selected cell's outline and resize handle go over everything + let Some(sel) = pu.selected.and_then(|s| cells.iter().find(|c| c.id == s)) else { return }; + let rect = sheet.rect(&sel.rect); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE)); + painter.rect_filled(handle, 2.0, t.accent); + let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag()); + register(&ctx, format!("print:resize:{}", sel.id), handle); + if resp.drag_started() { + pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == sel.id && d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (w, h) = (d.origin.w + f64::from(d.total.x / sheet.scale), d.origin.h + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run( + app, + &ctx, + "print.cell.resize", + json!({"id": sel.id, "width": w.max(layout::MIN_CELL_IN), "height": h.max(layout::MIN_CELL_IN), "snap": true, "threshold": threshold}), + ); + } + if resp.drag_stopped() { + pu.drag = None; + } +} + +/// The selected cell's numbers, its place in the stack and its photo. +fn inspector(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let Some(cell) = pu.selected.and_then(|id| st.layout.cell(id)) else { + ui.label(egui::RichText::new(crate::i18n::tr("Click a cell to move, resize or arrange it")).color(t.text_dim)); + return; + }; + let (id, r, unit) = (cell.id, cell.rect, st.guides.units); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if let Some(v) = length(ui, "printCellX", r.x, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": v, "y": r.y})); + } + if let Some(v) = length(ui, "printCellY", r.y, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": r.x, "y": v})); + } + if let Some(v) = length(ui, "printCellW", r.w, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": v, "height": r.h})); + } + if let Some(v) = length(ui, "printCellH", r.h, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": r.w, "height": v})); + } + for (i, (how, label)) in [("toBack", "Back"), ("backward", "Backward"), ("forward", "Forward"), ("toFront", "Front")].into_iter().enumerate() + { + if text_button(ui, &format!("printStack-{i}"), label, false).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + } + } + if st.layout.style == Style::CustomPackage { + if text_button(ui, "printCellPhoto", "Use Active Photo", false).clicked() { + let active = app.session.active().map(|p| p.0); + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": active})); + } + if cell.photo.is_some() && text_button(ui, "printCellPhotoClear", "Clear Photo", false).clicked() { + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": null})); + } + } + if text_button(ui, "printCellDelete", "Delete", false).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + pu.selected = None; + } + }); +} + +/// One cell on screen: the photo as it will print (fit or fill, turned, border, stroke). +#[allow(clippy::too_many_arguments)] +fn paint_cell( + app: &mut LightcraftApp, + painter: &egui::Painter, + t: &Tokens, + st: &PrintSettings, + cell: Rect, + photo: Option<PhotoId>, + sheet: &Sheet, + inches: &layout::Rect, +) { + let im = &st.image; + if st.guides.show_cells { + painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); + } + let border = if st.layout.style.is_package() && im.border_enabled { + (im.border_width as f32 * sheet.scale).min(cell.width().min(cell.height()).max(2.0) / 2.0 - 1.0).max(0.0) + } else { + 0.0 + }; + let avail = cell.shrink(border); + if avail.width() < 1.0 || avail.height() < 1.0 { + return; + } + // the photo's shape: its cropped size, or 3:2 for a placeholder + let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { + let (w, h) = lightcraft_engine::export::output_size(p, &ExportOptions::default()); + (w as f64, h as f64) + }); + let scale = 8.0; // sub-point resolution for the rounding in `place` + let aw = (f64::from(avail.width()) * scale).round() as usize; + let ah = (f64::from(avail.height()) * scale).round() as usize; + let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return }; + let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); + let visible = shown.intersect(avail); + if border > 0.0 { + let frame = visible.expand(border); + let c = im.border_color; + painter.rect_filled(frame.intersect(cell), 0.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); + let tex = photo.and_then(|id| { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + app.renderer.thumb(id).map(|tex| tex.tex.id()) + }); + match tex { + Some(id) => { + let mut mesh = egui::Mesh::with_texture(id); + // an unturned quad maps the texture straight; a turned one (a quarter clockwise) takes + // the left edge to the top + let uv: [Pos2; 4] = if p.rotated { + [pos2(0.0, 1.0), pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)] + } else { + [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)] + }; + let corners = [shown.left_top(), shown.right_top(), shown.right_bottom(), shown.left_bottom()]; + for (pos, uv) in corners.into_iter().zip(uv) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + clip.add(egui::Shape::mesh(mesh)); + } + None => { + clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); + } + } + if im.stroke_enabled && im.stroke_width_pt > 0.0 { + let w = (im.stroke_width_pt as f32 / 72.0 * sheet.scale).max(1.0).min(visible.width().min(visible.height()) / 2.0); + let c = im.stroke_color; + painter.rect_stroke(visible, 0.0, Stroke::new(w, Color32::from_rgb(c[0], c[1], c[2])), StrokeKind::Inside); + } + if st.guides.show_dimensions { + let text = format!("{} × {}", fmt_len(inches.w, st.guides.units), fmt_len(inches.h, st.guides.units)); + let galley = painter.layout_no_wrap(text, t.font(10.0), Color32::WHITE); + let at = pos2(cell.center().x - galley.size().x / 2.0, cell.bottom() - galley.size().y - 3.0); + painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); + painter.galley(at, galley, Color32::WHITE); + } +} + +fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, stroke, 5.0, 4.0)); +} + +/// Rulers along the top and left of the preview, in `unit`, zero at the page's corner. +fn rulers(painter: &egui::Painter, t: &Tokens, sheet: &Sheet, area: Rect, pw: f64, ph: f64, unit: Unit) { + let (major, minor) = match unit { + Unit::Inches => (1.0, 0.25), + Unit::Centimeters => (1.0, 0.5), + Unit::Millimeters => (10.0, 5.0), + Unit::Points => (72.0, 18.0), + }; + let tick = Stroke::new(1.0, t.text_dim); + let ink = t.text_dim; + let top = Rect::from_min_max(area.min, pos2(area.max.x, area.min.y + 20.0)); + let left = Rect::from_min_max(area.min, pos2(area.min.x + 20.0, area.max.y)); + painter.rect_filled(top, 0.0, t.chrome); + painter.rect_filled(left, 0.0, t.chrome); + let ticks = |len_in: f64| (0..).map(move |k| f64::from(k) * minor).take_while(move |v| *v <= unit.from_inches(len_in) + 1e-9).take(2000); + for v in ticks(pw) { + let x = sheet.page.left() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(x, top.bottom()), pos2(x, top.bottom() - if is_major { 10.0 } else { 5.0 })], tick); + if is_major { + painter.text(pos2(x + 2.0, top.top() + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } + for v in ticks(ph) { + let y = sheet.page.top() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(left.right(), y), pos2(left.right() - if is_major { 10.0 } else { 5.0 }, y)], tick); + if is_major { + painter.text(pos2(left.left() + 1.0, y + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lengths_read_in_the_ruler_unit() { + assert_eq!(fmt_len(1.0, Unit::Inches), "1.00 in"); + assert_eq!(fmt_len(1.0, Unit::Millimeters), "25.4 mm"); + assert_eq!(fmt_len(1.0, Unit::Points), "72 pt"); + assert_eq!(trim(4.0), "4"); + assert_eq!(trim(2.5), "2.5"); + assert_eq!(paper_label("a4"), "A4"); + assert_eq!(paper_label("4x6"), "4 × 6 in"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d55..5e43cbe93 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -735,6 +735,9 @@ pub struct UiState { pub status: String, #[serde(skip)] pub dialog: Option<Dialog>, + /// The Print dialog's page, selection and drag. + #[serde(skip)] + pub print: crate::print_view::PrintUi, } /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything @@ -763,6 +766,9 @@ pub struct NameEdit { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, @@ -1133,6 +1139,7 @@ impl Default for UiState { last_find_missing: None, status: String::new(), dialog: None, + print: Default::default(), } } } diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs new file mode 100644 index 000000000..6dbea7a40 --- /dev/null +++ b/crates/ui-egui/src/tests_print.rs @@ -0,0 +1,300 @@ +//! The Print dialog, driven through the control channel: styles, cells dragged and resized with +//! real pointer input, Auto Layout, pages, templates and the PDF. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); + +fn demo(services: Services) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn drag(h: &mut Headless, id: &str, dx: f32, dy: f32) { + let r = h.request("ui.dragWidget", json!({"id": id, "dx": dx, "dy": dy, "steps": 8}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn cmd(h: &mut Headless, id: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": id, "params": params}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); + r["result"].clone() +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn open_print(h: &mut Headless) { + cmd(h, "dialog.print", json!({})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Print))); + assert!(has(h, "print:page"), "the page is on screen"); +} + +/// Screen points per inch of the page shown. +fn scale(h: &Headless) -> f32 { + rect(h, "print:page").width() / h.app.session.print.layout.page_size().unwrap().0 as f32 +} + +fn cell_rect(h: &Headless, id: u32) -> lightcraft_engine::print::layout::Rect { + h.app.session.print.layout.cell(id).unwrap().rect +} + +#[test] +fn the_print_dialog_opens_from_the_menu_command_and_closes() { + let mut h = demo(Services::default()); + open_print(&mut h); + assert!(has(&h, "button:printStyle-0") && has(&h, "check:printZoomFill") && has(&h, "button:dialogOk")); + assert!(rect(&h, "print:pageLabel").width() > 0.0); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn style_buttons_and_checkboxes_run_print_set() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + assert_eq!(h.app.session.print.layout.style, lightcraft_engine::print::layout::Style::ContactSheet); + assert!(has(&h, "drag:printColumns") && has(&h, "print:gridcell:0")); + click(&mut h, "check:printZoomFill"); + click(&mut h, "check:printStroke"); + assert!(h.app.session.print.image.zoom_to_fill && h.app.session.print.image.stroke_enabled); + click(&mut h, "button:printOrientation-1"); + assert_eq!(h.app.session.print.layout.page.orientation, lightcraft_engine::print::layout::Orientation::Landscape); + let p = rect(&h, "print:page"); + assert!(p.width() > p.height(), "the page is drawn landscape"); + click(&mut h, "button:printUnits-2"); + assert_eq!(h.app.session.print.guides.units, lightcraft_engine::print::layout::Unit::Millimeters); + click(&mut h, "check:printShowGrid"); + assert!(h.app.session.print.guides.show_page_grid); +} + +#[test] +fn cells_are_added_dragged_resized_and_arranged() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + // snapping off, so the pointer maths is exact + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + click(&mut h, "button:printAddCell-4"); + click(&mut h, "button:printAddCell-1"); + let cells: Vec<u32> = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(cells.len(), 2); + assert_eq!(h.app.session.print.layout.cells[0].rect.w, 4.0); + assert!(has(&h, &format!("print:cell:{}", cells[0]))); + + // drag the first cell with the pointer: the move is in inches, through print.cell.move + let before = cell_rect(&h, cells[0]); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", cells[0]), 0.0, 100.0); + let after = cell_rect(&h, cells[0]); + assert!((after.y - before.y - f64::from(100.0 / s)).abs() < 0.05, "moved {} in, expected {}", after.y - before.y, 100.0 / s); + assert!((after.x - before.x).abs() < 0.05); + assert_eq!(h.app.ui.print.selected, Some(cells[0])); + + // resize with its corner handle + let r0 = cell_rect(&h, cells[0]); + drag(&mut h, &format!("print:resize:{}", cells[0]), 30.0, 20.0); + let r1 = cell_rect(&h, cells[0]); + assert!((r1.w - r0.w - f64::from(30.0 / s)).abs() < 0.05 && (r1.h - r0.h - f64::from(20.0 / s)).abs() < 0.05, "{r0:?} → {r1:?}"); + assert_eq!((r1.x, r1.y), (r0.x, r0.y), "the top-left corner stays"); + + // arrange: Front moves it last + click(&mut h, "button:printStack-3"); + assert_eq!(h.app.session.print.layout.cells.last().unwrap().id, cells[0]); + click(&mut h, "button:printStack-0"); + assert_eq!(h.app.session.print.layout.cells.first().unwrap().id, cells[0]); + + // the inspector's delete + click(&mut h, "button:printCellDelete"); + assert_eq!(h.app.session.print.layout.cells.len(), 1); + assert_eq!(h.app.ui.print.selected, None); +} + +#[test] +fn dragging_a_cell_snaps_to_its_neighbour_when_snap_is_on() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + cmd(&mut h, "print.set", json!({"layout": {"spacing": 0.0}})); + click(&mut h, "button:printAddCell-0"); + click(&mut h, "button:printAddCell-0"); + let ids: Vec<u32> = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(h.app.session.print.guides.snap, lightcraft_engine::print::layout::Snap::Cells); + let a = cell_rect(&h, ids[0]); + // park the second cell away from the first, then drag it back to 0.06 in from the first's right edge + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - a.right()).abs() < 1e-6, "snapped to the neighbour's edge: {} vs {}", r.x, a.right()); + // with snapping off the same drag lands where the pointer put it + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - (a.right() + 0.06)).abs() < 0.05 && r.x > a.right() + 0.01, "unsnapped: {}", r.x - a.right()); +} + +#[test] +fn auto_layout_new_page_and_clear_buttons() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-2"); + for _ in 0..3 { + click(&mut h, "button:printAddCell-4"); + } + for _ in 0..3 { + click(&mut h, "button:printAddCell-5"); + } + let before_pages = h.app.session.print.layout.pages; + // scramble everything onto one spot, then pack + cmd(&mut h, "print.layout.autoLayout", json!({})); + click(&mut h, "button:printAutoLayout"); + assert!(!h.app.session.print.layout.has_overlaps()); + assert!(h.app.session.print.layout.pages >= before_pages.min(2)); + let pages = h.app.session.print.layout.pages; + click(&mut h, "button:printNewPage"); + assert_eq!(h.app.session.print.layout.pages, pages + 1); + assert_eq!(h.app.ui.print.page as u32, pages, "the new page is shown"); + // the next-page button stays on the last page + click(&mut h, "button:printPageNext"); + assert_eq!(h.app.ui.print.page as u32, pages); + click(&mut h, "button:printPagePrev"); + assert_eq!(h.app.ui.print.page as u32, pages - 1); + click(&mut h, "button:printClear"); + assert_eq!((h.app.session.print.layout.cells.len(), h.app.session.print.layout.pages), (0, 1)); + assert_eq!(h.app.ui.print.page, 0); +} + +#[test] +fn templates_save_apply_and_delete_from_the_dialog() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + assert_eq!(h.app.session.print_templates.len(), 1); + cmd(&mut h, "print.reset", json!({})); + assert!(has(&h, "button:printTemplateApply-0")); + click(&mut h, "button:printTemplateApply-0"); + assert_eq!(h.app.session.print.layout.grid.columns, 5); + click(&mut h, "button:printTemplateDelete-0"); + assert!(h.app.session.print_templates.is_empty()); +} + +#[test] +fn print_to_pdf_picks_a_file_and_runs_in_the_background() { + let asked = Arc::new(Mutex::new(Vec::<crate::pick::PickRequest>::new())); + let requests = asked.clone(); + let bytes = Arc::new(Mutex::new(Vec::new())); + let written = bytes.clone(); + let services = Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec!["Print.pdf".into()]).unwrap(); + Ok(rx) + })), + write_shared: Some(Arc::new(move |path, data| { + assert_eq!(path, "Print.pdf"); + *written.lock().unwrap() = data.to_vec(); + Ok(()) + })), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + cmd(&mut h, "print.set", json!({"layout": {"page": {"paper": "5x7", "orientation": "landscape"}}, "job": {"ppi": 100.0}})); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the dialog closes once the print has started"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.last_export_result.is_some()), "the print did not finish"); + let req = asked.lock().unwrap(); + assert_eq!(req[0].file_name.as_deref(), Some("Print.pdf")); + let b = bytes.lock().unwrap(); + assert!(b.starts_with(b"%PDF-")); + let (pw, ph) = media_box(&b); + assert!((pw - 504.0).abs() < 1.0 && (ph - 360.0).abs() < 1.0, "7 x 5 in, got {pw} x {ph} pt"); + assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["print"], true); +} + +#[test] +fn printing_shows_a_row_in_the_activity_stack_and_stops() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { write_shared: Some(Arc::new(|_, _| panic!("a stopped print wrote a file"))), ..Default::default() }, + ); + app.session.execute("print.set", &json!({"layout": {"style": "contactSheet"}, "job": {"ppi": 300.0}})).unwrap(); + let ids: Vec<_> = app.session.catalog.photos().take(12).map(|p| p.id.0).collect(); + crate::export_task::start_print(&mut app, &json!({"path": "Stop.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("export", "Printing to PDF")); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + let task = app.export.take().unwrap(); + assert!(task.cancel.load(std::sync::atomic::Ordering::Relaxed)); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the print"); +} + +#[test] +fn the_print_menu_item_needs_a_photo_and_is_listed() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + assert!(crate::menus::ui_enabled(&app, "dialog.print")); + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(!crate::menus::ui_enabled(&empty, "dialog.print")); +} + +#[test] +fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { + for size in [[1400.0, 900.0], [1100.0, 760.0]] { + for style in ["singleImage", "customPackage"] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, size, 1.0); + h.settle(T); + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "customPackage" { + cmd(&mut h, "print.cell.add", json!({"width": 4, "height": 6})); + } + open_print(&mut h); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(&h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{style} at {size:?}: dialog {dialog:?} runs off the window"); + for b in ["button:dialogOk", "button:dialogCancel"] { + assert!(dialog.contains_rect(rect(&h, b)), "{style} at {size:?}: {b} is outside the dialog"); + } + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 72649a5bc..f24cbf5bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: no Print view (layouts, cells, image settings and templates exist as `print.*` commands only), printer colour management, identity plate / watermark / page numbers / crop marks / photo info, Custom Package Auto Layout | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: printer colour management (profile, rendering intent, black point compensation, print adjustment and sharpening), identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing (the Print dialog writes a PDF) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | | 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 75dc988ae..4edb4eff5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 34 | 19 | 32 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 54 | 69 | 35 | 193/201 (96%) | 145/157 (92%) | +| Lightroom Classic extras | 38 | 16 | 31 | 8 | — | 24/26 (92%) | +| **Total** | 412 | 51 | 68 | 35 | 193/201 (96%) | 145/157 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.9% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.4%** of 531 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 52.3% of 172. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -747,14 +747,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | | LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | Single Image, Contact Sheet, Picture Package and Custom Package are print layout styles reachable from commands, the CLI, control channel and MCP; no Print module UI yet (File → Contact Sheet PDF still the only dialog) | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | 🟡 | `cmd:print.set`, `crates/engine/src/print/compose.rs` | Zoom to Fill, Rotate to Fit, Photo Border (package styles), Inner Stroke (width in points, colour) through `print.set {image}`; pixel-tested; no panel yet | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi; commands only, no panel yet | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | `cmd:print.set` | guide options (ruler units in/cm/mm/pt, snap off/cells/grid, grid size, show margins/bleed/page grid/cells/dimensions) are stored and used by snapping in `print.cell.move`/`resize`; nothing is drawn until the Print UI lands | -| LRC-PRINT-CELLS | Picture-package cells | P2 | 🟡 | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout` | add/move/resize/delete cells of any size, stacking order, per-cell photos (Custom Package), deterministic Auto Layout packer, snapping to cells or grid; no UI (drag placement) yet | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | page background colour and margins through `print.set`; optional filename captions in the contact-sheet PDF; no identity plates, watermarks, page numbers or crop marks | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | +| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete` | named templates of layout, image settings, page and job saved with the library (prefs.json); no built-in templates, no UI, no template import/export | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | | 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | From 068caf361c494cb25c7ce34337e02e17cf3fec52 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:16:57 +0530 Subject: [PATCH 049/315] LRC-PRINT-JOB: print colour transform (printer / paper profiles, intent, black point compensation, print adjustment) codecs::print converts a composed print page (16-bit RGB in the Rec2020 output encoding) to the samples a print-ready file carries: - PrintTarget::Profile(icc): a printer / paper profile, RGB, CMYK or grey, matrix or LUT based, converted with moxcms; PrintTarget::Managed(space): the printer manages colour and the page goes in sRGB or Adobe RGB with that profile embedded. - Perceptual or relative colorimetric (ProofIntent, as soft proofing). - Black point compensation for relative colorimetric: the device's darkest is measured by a round trip of black through the profile and linear values are scaled to land the page's black there (Y' = Y(1 - Yb) + Yb, the published mapping for a neutral destination black), so shadows below the ink's black no longer clip to one value. Perceptual tables keep their own black mapping. - Print adjustment brightness / contrast (-100..100) on the sRGB-encoded values, black and white held, monotonic at every setting. Everything before the profile is per channel and folds into one 16-bit table; pages convert in bounded batches (parallel with the `parallel` feature). Hostile profiles, half pixels and NaN settings are errors or neutral, never panics. Not wired to the print job yet. --- crates/codecs/src/lib.rs | 1 + crates/codecs/src/print.rs | 375 +++++++++++++++++++++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 crates/codecs/src/print.rs diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334a..9886e8064 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -31,6 +31,7 @@ mod jxl; mod jxl_encode; mod other; mod png_codec; +pub mod print; pub mod proof; mod psd; mod sniff; diff --git a/crates/codecs/src/print.rs b/crates/codecs/src/print.rs new file mode 100644 index 000000000..560e2710d --- /dev/null +++ b/crates/codecs/src/print.rs @@ -0,0 +1,375 @@ +//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the +//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space +//! of the chosen printer / paper profile. +//! +//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the +//! page to the device's own values. When the printer manages colour instead, the page is sent in a +//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it. +//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]). +//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is +//! measured by a round trip of black through the profile, and the page's linear values are scaled +//! so its black lands there instead of every shadow below it clipping to the same ink. Linear +//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping +//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black +//! mapping and are left alone. +//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or +//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown +//! on screen (the print is what it corrects). +//! +//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table +//! indexed by the 16-bit sample; the conversion itself is moxcms's. +//! +//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point +//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise. + +use std::sync::Arc; + +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions}; + +use crate::display::MAX_PROFILE_BYTES; +use crate::proof::ProofIntent; +use crate::space::{NamedSpace, Trc}; + +/// Where a print's colours go. +#[derive(Clone, Debug, PartialEq)] +pub enum PrintTarget { + /// The printer manages colour: the page is sent in this RGB space, with its profile embedded. + Managed(NamedSpace), + /// A printer / paper ICC profile: the page is converted to the device's values. + Profile(Arc<Vec<u8>>), +} + +/// How the page is rendered into the target. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrintColor { + pub intent: ProofIntent, + /// Map the page's black to the device's darkest (relative colorimetric only). + pub black_point_compensation: bool, + /// −100..100; 0 leaves the page as rendered. + pub brightness: f32, + /// −100..100; 0 leaves the page as rendered. + pub contrast: f32, +} + +impl Default for PrintColor { + fn default() -> Self { + PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 } + } +} + +/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size. +const BATCH: usize = 1 << 16; + +/// The darkest black point compensation will lift the page's black to. A profile whose round trip +/// says more is broken or not a print profile; leave the page alone rather than wash it out. +const MAX_BLACK: f32 = 0.5; + +type Executor = Arc<dyn TransformExecutor<f32> + Send + Sync>; + +/// A page conversion, built once per print job and applied to every page. +pub struct PrintTransform { + forward: Executor, + layout: Layout, + channels: usize, + icc: Arc<Vec<u8>>, + /// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation. + pre: Vec<f32>, + black: f32, + description: Option<String>, +} + +impl std::fmt::Debug for PrintTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PrintTransform") + .field("channels", &self.channels) + .field("black", &self.black) + .field("description", &self.description) + .finish_non_exhaustive() + } +} + +fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +fn rendering_intent(intent: ProofIntent) -> RenderingIntent { + match intent { + ProofIntent::Perceptual => RenderingIntent::Perceptual, + ProofIntent::Relative => RenderingIntent::RelativeColorimetric, + } +} + +/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0). +fn amount(v: f32) -> f32 { + if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 } +} + +/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a +/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black, +/// mid grey and white where they are and stays monotonic for |k| < 1. +pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 { + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + let (b, c) = (amount(brightness), amount(contrast)); + let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e }; + let k = 0.6 * c; + let tau = std::f32::consts::TAU; + (e - k * (tau * e).sin() / tau).clamp(0.0, 1.0) +} + +impl PrintTransform { + /// Build the conversion for a target. Errors say what's wrong with the profile; nothing here + /// panics on a hostile one. + pub fn new(target: &PrintTarget, color: &PrintColor) -> Result<PrintTransform, String> { + let icc = match target { + PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)), + PrintTarget::Profile(bytes) => Arc::clone(bytes), + }; + if icc.len() > MAX_PROFILE_BYTES { + return Err("the profile is too large".into()); + } + let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?; + let (layout, channels) = match dev.color_space { + DataColorSpace::Rgb => (Layout::Rgb, 3), + DataColorSpace::Cmyk => (Layout::Rgba, 4), + DataColorSpace::Gray => (Layout::Gray, 1), + _ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()), + }; + let lin = crate::icc::linear_rec2020_profile(); + let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() }; + let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok()) + .ok_or("can't convert to this profile")?; + + let black = if color.black_point_compensation && color.intent == ProofIntent::Relative { + let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok()) + .ok_or("can't convert from this profile")?; + device_black(&forward, &back, channels) + } else { + 0.0 + }; + + let decode = Trc::Rec709.lut16(); + let (b, c) = (color.brightness, color.contrast); + let adjust = amount(b) != 0.0 || amount(c) != 0.0; + let pre = decode + .iter() + .map(|&lin| { + let lin = if adjust { + let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0)); + lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c)) + } else { + lin.clamp(0.0, 1.0) + }; + lin * (1.0 - black) + black + }) + .collect(); + let description = crate::icc::parse(&icc).and_then(|i| i.description); + Ok(PrintTransform { forward, layout, channels, icc, pre, black, description }) + } + + /// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey). + pub fn channels(&self) -> usize { + self.channels + } + + /// The profile the converted samples are in: embed it with them. + pub fn icc(&self) -> &Arc<Vec<u8>> { + &self.icc + } + + /// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise, + /// so writers need to know). + pub fn is_cmyk(&self) -> bool { + self.layout == Layout::Rgba + } + + /// The linear black black point compensation lifts the page's black to (0 when off). + pub fn black(&self) -> f32 { + self.black + } + + /// The profile's description, for the job's settings and error messages. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result + /// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel). + pub fn apply(&self, rgb: &[u16]) -> Result<Vec<u16>, String> { + if !rgb.len().is_multiple_of(3) { + return Err("the page isn't whole RGB pixels".into()); + } + let pixels = rgb.len() / 3; + let ch = self.channels; + let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?; + let mut out = vec![0u16; out_len]; + let run = |src: &[u16], dst: &mut [u16]| -> Option<()> { + let lin: Vec<f32> = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect(); + let mut dev = vec![0f32; dst.len()]; + self.forward.transform(&lin, &mut dev).ok()?; + for (d, v) in dst.iter_mut().zip(dev) { + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + *d = (v * 65535.0 + 0.5) as u16; + } + Some(()) + }; + let src = rgb.chunks(BATCH * 3); + let dst = out.chunks_mut(BATCH * ch); + #[cfg(feature = "parallel")] + let ok = { + use rayon::prelude::*; + let pairs: Vec<_> = src.zip(dst).collect(); + pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some()) + }; + #[cfg(not(feature = "parallel"))] + let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some()); + if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) } + } +} + +/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative +/// colorimetric both ways (for a device that reaches true black, 0). +fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 { + let mut device = vec![0f32; channels]; + let mut lin = [0f32; 3]; + let ok = guarded(|| { + forward.transform(&[0.0; 3], &mut device).ok()?; + back.transform(&device, &mut lin).ok() + }); + if ok.is_none() { + return 0.0; + } + // BT.2020 luminance weights + let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2]; + if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use moxcms::ToneReprCurve; + + /// A page of one 16-bit pixel per linear grey level. + fn greys(levels: &[f32]) -> Vec<u16> { + levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect() + } + + fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec<u16> { + PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap() + } + + /// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey. + fn raised_black_profile() -> Arc<Vec<u8>> { + let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap(); + let (g, e) = (2.2f32, 0.03f32); + let a = (1.0 - e).powf(1.0 / g); + // ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below + let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]); + p.red_trc = Some(trc.clone()); + p.green_trc = Some(trc.clone()); + p.blue_trc = Some(trc); + Arc::new(p.encode().unwrap()) + } + + #[test] + fn managed_srgb_keeps_black_grey_and_white() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap(); + assert_eq!(t.channels(), 3); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb)); + let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap(); + let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32; + assert!(out[..3].iter().all(|&v| v < 64), "{out:?}"); + assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}"); + assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}"); + } + + #[test] + fn adobe_rgb_target_embeds_its_profile() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap(); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb)); + // a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped + let px = [0u16, 65535, 0]; + let out = t.apply(&px).unwrap(); + assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}"); + } + + #[test] + fn brightness_lifts_the_mid_tones_and_holds_the_ends() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.0, 0.18, 1.0]; + let plain = convert(&target, PrintColor::default(), &levels); + let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels); + let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels); + assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}"); + for v in [&plain, &bright, &dark] { + assert!(v[0] < 64 && v[6] > 65400, "{v:?}"); + } + } + + #[test] + fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.03, 0.6]; + let plain = convert(&target, PrintColor::default(), &levels); + let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels); + assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}"); + for c in [-100.0, 100.0] { + for b in [-100.0, 100.0] { + let ramp: Vec<f32> = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect(); + assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}"); + assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6); + } + } + } + + #[test] + fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() { + let target = PrintTarget::Profile(raised_black_profile()); + let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0]; + let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() }; + let clipped = convert(&target, relative, &shadows); + let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows); + let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::<Vec<_>>(); + let (clipped, kept) = (reds(&clipped), reds(&kept)); + // everything below the ink's black prints as (within rounding) the same device value without + // compensation … + assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}"); + // … and as distinct, rising values with it; white still prints as white + assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}"); + assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}"); + let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap(); + assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black()); + } + + #[test] + fn black_point_compensation_is_relative_colorimetric_only() { + let target = PrintTarget::Profile(raised_black_profile()); + let levels = [0.0, 0.01, 0.2]; + let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels); + let without = + convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels); + assert_eq!(with, without); + let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap(); + assert_eq!(t.black(), 0.0); + } + + #[test] + fn hostile_input_is_an_error_not_a_panic() { + let color = PrintColor::default(); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err()); + let good = crate::icc::write_named(NamedSpace::DisplayP3); + for i in (0..good.len()).step_by(7) { + let mut b = good.clone(); + b[i] ^= 0x5a; + if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) { + let _ = t.apply(&greys(&[0.0, 0.5, 1.0])); + } + } + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color }) + .unwrap(); + assert!(t.apply(&[1, 2]).is_err(), "half a pixel"); + assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18])); + assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite()); + } +} From d289fafbe2c6ec6b493e8b7f050e7d331b28d92c Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:22:07 +0530 Subject: [PATCH 050/315] LRC-PRINT-JOB: PDF writer for print output (ICC-tagged 8 / 16-bit images, output intent) engine::pdf writes print-ready documents: pages of any size in points with raster images placed on them, each image tagged with its ICC profile as an ICCBased colour space (RGB, CMYK or grey), stored losslessly with Flate at 8 or 16 bits per component (16-bit samples big-endian), or copied as a JPEG stream where file size matters more. A profile shared by several images is written once. An optional output intent names the printer / paper profile the file was made for (PDF/X-style /OutputIntents in the catalog); the Info dictionary carries the title, as UTF-16 when it isn't plain ASCII. PDF 1.6 (16-bit images need 1.5), classic cross-reference table, deterministic bytes. Pages, images per page, page and image sizes are capped; bad sizes, placements, sample counts and component counts are errors, never panics. Tests read the files back through the xref table: image samples and profiles inflate to what went in, placements reach the content streams, one profile object is shared. Not wired to the print job yet. --- crates/engine/src/lib.rs | 1 + crates/engine/src/pdf.rs | 486 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 crates/engine/src/pdf.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4cce1d8df..2193c5178 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,7 @@ pub mod memory; pub mod merge; mod model_download; pub mod originals; +pub mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs new file mode 100644 index 000000000..29f0b73f7 --- /dev/null +++ b/crates/engine/src/pdf.rs @@ -0,0 +1,486 @@ +//! A small PDF writer for print output: pages of a given size in points, each with raster images +//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in +//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as +//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent: +//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers +//! expect it in the catalog. Profiles shared by several images are written once. +//! +//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams, +//! deterministic (no dates or IDs), so the same pages always give the same bytes. +//! +//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces, +//! §8.9 images, §14.11.5 output intents). Own design. + +use std::collections::HashMap; +use std::sync::Arc; + +/// Pages per document: a print job beyond this is a mistake, not a book. +pub const MAX_PAGES: usize = 1000; +/// Images per page. +pub const MAX_IMAGES_PER_PAGE: usize = 256; +/// Largest page side in points (200 inches): more than any printer's roll. +pub const MAX_PAGE_POINTS: f64 = 14_400.0; +/// Largest image, in pixels per side. +pub const MAX_IMAGE_SIDE: u32 = 65_535; + +/// How an image's samples are stored. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageData { + /// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed + /// losslessly in the file. + Raw { bits: u8, samples: Vec<u8> }, + /// A baseline JPEG stream, copied into the file as it is. + Jpeg(Vec<u8>), +} + +/// A raster image and the colour space its samples are in. +#[derive(Clone, Debug, PartialEq)] +pub struct PdfImage { + pub width: u32, + pub height: u32, + /// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK). + pub channels: u8, + pub data: ImageData, + /// The ICC profile the samples are in; without one the image is device colour. + pub icc: Option<Arc<Vec<u8>>>, +} + +impl PdfImage { + /// An image from interleaved 16-bit samples. + pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option<Arc<Vec<u8>>>) -> PdfImage { + let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc } + } + + fn check(&self) -> Result<(), String> { + if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE { + return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height)); + } + if !matches!(self.channels, 1 | 3 | 4) { + return Err(format!("images have 1, 3 or 4 components, not {}", self.channels)); + } + match &self.data { + ImageData::Raw { bits, samples } => { + if !matches!(bits, 8 | 16) { + return Err(format!("images have 8 or 16 bits per component, not {bits}")); + } + let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8); + if samples.len() as u64 != want { + return Err(format!("the image needs {want} bytes of samples, not {}", samples.len())); + } + } + ImageData::Jpeg(data) => { + if !data.starts_with(&[0xFF, 0xD8]) { + return Err("the image isn't a JPEG stream".into()); + } + } + } + Ok(()) + } +} + +/// Where an image goes on its page, in points from the page's bottom-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// One page: its size in points and the images on it, drawn in order (later ones on top). +#[derive(Clone, Debug, PartialEq)] +pub struct PdfPage { + pub width: f64, + pub height: f64, + pub images: Vec<(PdfImage, Placement)>, +} + +/// The printing condition the document is made for (PDF/X-style output intent). +#[derive(Clone, Debug, PartialEq)] +pub struct OutputIntent { + /// The printer / paper profile. + pub icc: Arc<Vec<u8>>, + /// Its components: 1, 3 or 4. + pub channels: u8, + /// A name for the condition (the profile's description). + pub condition: String, +} + +/// Document-level settings. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PdfInfo { + pub title: Option<String>, + pub output_intent: Option<OutputIntent>, +} + +fn finite(v: f64) -> bool { + v.is_finite() +} + +/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros. +fn num(v: f64) -> String { + let s = format!("{:.4}", if finite(v) { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.into() } +} + +/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark. +fn text(s: &str) -> String { + if s.bytes().all(|b| (0x20..0x7f).contains(&b)) { + let mut out = String::with_capacity(s.len() + 2); + out.push('('); + for c in s.chars() { + if matches!(c, '(' | ')' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push(')'); + out + } else { + let mut out = String::from("<FEFF"); + for unit in s.encode_utf16() { + out.push_str(&format!("{unit:04X}")); + } + out.push('>'); + out + } +} + +struct Writer { + out: Vec<u8>, + offsets: Vec<usize>, +} + +impl Writer { + /// Reserve an object number (filled later by [`Writer::object`]). + fn reserve(&mut self) -> usize { + self.offsets.push(0); + self.offsets.len() + } + + fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) { + if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) { + *slot = self.out.len(); + } + self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes()); + if let Some(data) = stream { + self.out.extend_from_slice(b"\nstream\n"); + self.out.extend_from_slice(data); + self.out.extend_from_slice(b"\nendstream"); + } + self.out.extend_from_slice(b"\nendobj\n"); + } +} + +fn deflate(data: &[u8]) -> Vec<u8> { + miniz_oxide::deflate::compress_to_vec_zlib(data, 6) +} + +/// Write a document. Errors name the first page or image that can't be written. +pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result<Vec<u8>, String> { + if pages.is_empty() { + return Err("there are no pages to write".into()); + } + if pages.len() > MAX_PAGES { + return Err(format!("a document holds at most {MAX_PAGES} pages")); + } + for (n, page) in pages.iter().enumerate() { + let n = n + 1; + if !(finite(page.width) && finite(page.height)) + || page.width <= 0.0 + || page.height <= 0.0 + || page.width > MAX_PAGE_POINTS + || page.height > MAX_PAGE_POINTS + { + return Err(format!("page {n} has no usable size")); + } + if page.images.len() > MAX_IMAGES_PER_PAGE { + return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images")); + } + for (image, at) in &page.images { + image.check().map_err(|e| format!("page {n}: {e}"))?; + if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 { + return Err(format!("page {n}: an image has no usable placement")); + } + } + } + if let Some(intent) = &info.output_intent + && !matches!(intent.channels, 1 | 3 | 4) + { + return Err("the output intent's profile has 1, 3 or 4 components".into()); + } + + let mut w = Writer { out: Vec::new(), offsets: Vec::new() }; + w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n"); + let catalog = w.reserve(); + let pages_id = w.reserve(); + let info_id = w.reserve(); + + // ICC profiles, each written once (keyed by content) with its component count + let mut profiles: HashMap<Vec<u8>, usize> = HashMap::new(); + let mut profile = |w: &mut Writer, icc: &Arc<Vec<u8>>, n: u8| -> usize { + if let Some(&id) = profiles.get(icc.as_slice()) { + return id; + } + let id = w.reserve(); + let data = deflate(icc); + let alternate = match n { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data)); + profiles.insert(icc.to_vec(), id); + id + }; + + let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels)); + + let mut kids = Vec::with_capacity(pages.len()); + for page in pages { + let page_id = w.reserve(); + let mut xobjects = String::new(); + let mut content = String::new(); + for (k, (image, at)) in page.images.iter().enumerate() { + let space = match &image.icc { + Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)), + None => match image.channels { + 1 => "/DeviceGray".into(), + 4 => "/DeviceCMYK".into(), + _ => "/DeviceRGB".into(), + }, + }; + let id = w.reserve(); + let (bits, filter, data) = match &image.data { + ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples)), + ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.clone()), + }; + w.object( + id, + &format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>", + image.width, + image.height, + data.len() + ), + Some(&data), + ); + xobjects.push_str(&format!("/Im{k} {id} 0 R ")); + content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y))); + } + let content_id = w.reserve(); + let stream = deflate(content.as_bytes()); + w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream)); + w.object( + page_id, + &format!( + "<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>", + num(page.width), + num(page.height) + ), + None, + ); + kids.push(format!("{page_id} 0 R")); + } + w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None); + + let intents = match (&info.output_intent, intent_id) { + (Some(intent), Some(id)) => format!( + " /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]", + text(&intent.condition) + ), + _ => String::new(), + }; + w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None); + let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default(); + w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None); + + let xref = w.out.len(); + let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1); + for offset in &w.offsets { + table.push_str(&format!("{offset:010} 00000 n \n")); + } + table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1)); + w.out.extend_from_slice(table.as_bytes()); + Ok(w.out) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::NamedSpace; + + /// The body of object `id` (dictionary and raw stream bytes), found through the xref table. + fn object(pdf: &[u8], id: usize) -> (String, Vec<u8>) { + // byte offsets: the streams are binary, so search the bytes, not a lossy string + let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let tail = std::str::from_utf8(&pdf[start..]).unwrap(); + let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap(); + let table = std::str::from_utf8(&pdf[xref..]).unwrap(); + let line = table.lines().nth(2 + id).unwrap(); + let offset: usize = line[..10].parse().unwrap(); + let head = format!("{id} 0 obj\n"); + assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}"); + let rest = &pdf[offset + head.len()..]; + let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap(); + let body = &rest[..end]; + match body.windows(8).position(|w| w == b"\nstream\n") { + Some(p) => { + let data = &body[p + 8..body.len() - "\nendstream".len()]; + (String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec()) + } + None => (String::from_utf8_lossy(body).into_owned(), vec![]), + } + } + + fn inflate(data: &[u8]) -> Vec<u8> { + miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap() + } + + /// Object ids of every image XObject, in file order. + fn images(pdf: &[u8]) -> Vec<usize> { + let s = String::from_utf8_lossy(pdf); + s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect() + } + + fn reference(dict: &str, key: &str) -> usize { + let at = dict.find(key).unwrap() + key.len(); + dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap() + } + + fn srgb() -> Arc<Vec<u8>> { + Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb)) + } + + fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage { + PdfPage { width: 612.0, height: 792.0, images } + } + + fn full(w: f64, h: f64) -> Placement { + Placement { x: 0.0, y: 0.0, width: w, height: h } + } + + #[test] + fn a_16_bit_icc_image_round_trips() { + let samples: Vec<u16> = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect(); + let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb())); + let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF-1.6\n")); + assert!(pdf.ends_with(b"%%EOF\n")); + let ids = images(&pdf); + assert_eq!(ids.len(), 1); + let (dict, data) = object(&pdf, ids[0]); + assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}"); + let back: Vec<u16> = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect(); + assert_eq!(back, samples); + let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased")); + assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}"); + assert_eq!(inflate(&icc), *srgb()); + assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb)); + } + + #[test] + fn pages_sizes_placements_and_a_shared_profile() { + let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb())); + let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 }; + let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }]; + let pdf = write(&pages, &PdfInfo::default()).unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/Type /Pages /Count 2"), "two pages"); + assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]")); + // the sRGB profile is written once for all three images + assert_eq!(s.matches("/N 3 /Alternate").count(), 1); + let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect(); + assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}"); + // placements reach the content streams + let contents: Vec<String> = s + .match_indices("/Contents ") + .map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::<usize>().unwrap()) + .map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap()) + .collect(); + assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n"); + assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic"); + } + + #[test] + fn output_intent_and_title() { + let cmyk_like = srgb(); + let info = PdfInfo { + title: Some("Garden – prints (2)".into()), + output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }), + }; + let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like)); + let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap(); + let (catalog, _) = object(&pdf, 1); + assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}"); + assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}"); + // the intent and the image share one profile object + let dest = reference(&catalog, "/DestOutputProfile"); + assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest); + let (info_dict, _) = object(&pdf, 3); + assert!(info_dict.contains("/Title <FEFF"), "non-ASCII titles are UTF-16: {info_dict}"); + assert!(info_dict.contains("2013"), "the en dash: {info_dict}"); + } + + #[test] + fn cmyk_grey_jpeg_and_device_images() { + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9]; + let imgs = vec![ + ( + PdfImage { width: 1, height: 1, channels: 4, data: ImageData::Raw { bits: 8, samples: vec![0, 10, 20, 30] }, icc: None }, + full(1.0, 1.0), + ), + (PdfImage { width: 2, height: 1, channels: 1, data: ImageData::Raw { bits: 8, samples: vec![0, 255] }, icc: None }, full(1.0, 1.0)), + (PdfImage { width: 4, height: 4, channels: 3, data: ImageData::Jpeg(jpeg.clone()), icc: None }, full(1.0, 1.0)), + ]; + let pdf = write(&[page(imgs)], &PdfInfo::default()).unwrap(); + let ids = images(&pdf); + let dicts: Vec<_> = ids.iter().map(|&id| object(&pdf, id)).collect(); + assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8")); + assert!(dicts[1].0.contains("/ColorSpace /DeviceGray")); + assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB")); + assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are"); + } + + #[test] + fn hostile_documents_are_errors() { + let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None); + let at = full(10.0, 10.0); + let bad = |pages: Vec<PdfPage>| write(&pages, &PdfInfo::default()).is_err(); + assert!(bad(vec![])); + assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }])); + assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])])); + assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])])); + let mut short = ok(); + short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] }; + assert!(bad(vec![page(vec![(short, at)])])); + let mut empty = ok(); + empty.width = 0; + assert!(bad(vec![page(vec![(empty, at)])])); + let mut odd = ok(); + odd.channels = 2; + assert!(bad(vec![page(vec![(odd, at)])])); + let mut not_jpeg = ok(); + not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]); + assert!(bad(vec![page(vec![(not_jpeg, at)])])); + let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect(); + assert!(bad(too_many)); + let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) }; + assert!(write(&[page(vec![])], &info).is_err()); + } + + #[test] + fn numbers_and_strings() { + assert_eq!(num(612.0), "612"); + assert_eq!(num(72.5), "72.5"); + assert_eq!(num(-0.00001), "0"); + assert_eq!(num(f64::NAN), "0"); + assert_eq!(num(1.0 / 3.0), "0.3333"); + assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)"); + assert_eq!(text("é"), "<FEFF00E9>"); + } +} From 9bc76a4e5868a1fd330cd66585b9857c10255751 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:27:58 +0530 Subject: [PATCH 051/315] LRC-PRINT-JOB: print job colour settings: profile, intent, print adjustment, print sharpening, depth engine::print_color turns composed pages into a print-ready PDF: - Profile: Managed by Printer sends the page in sRGB or Adobe RGB with that profile embedded and no output intent (the driver converts); a printer / paper .icc / .icm converts the page to the device's values here and becomes the document's output intent (named by the profile's description), so the driver must not convert again. Profiles over the size cap, unreadable or invalid files are errors that name the file. - Intent (perceptual / relative colorimetric), black point compensation and print adjustment brightness / contrast, through codecs::print. - Print sharpening Off / Low / Standard / High for Matte or Glossy paper, per photo at its print size, with the export sharpener. - 16 bits per component by default, 8 for smaller files. PrintColorSettings is serde (camelCase, defaults for missing fields) so print templates can save it. Page sizes are checked with overflow-safe arithmetic. Not wired to the print layout yet. --- crates/engine/src/lib.rs | 1 + crates/engine/src/print_color.rs | 353 +++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 crates/engine/src/print_color.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2193c5178..cf0fa1637 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,6 +58,7 @@ pub mod preset_import; pub mod preset_luminar; pub mod presets; pub mod print; +pub mod print_color; pub mod proof; pub mod rename; pub mod segment; diff --git a/crates/engine/src/print_color.rs b/crates/engine/src/print_color.rs new file mode 100644 index 000000000..540e48952 --- /dev/null +++ b/crates/engine/src/print_color.rs @@ -0,0 +1,353 @@ +//! The print job's colour: which profile composed pages are converted to and how, print sharpening +//! for the paper, and the print-ready PDF they end up in. +//! +//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile +//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's +//! own values here and names the profile as the document's output intent, so the driver must not +//! convert again. +//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`]. +//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened +//! at its print size before it goes on the page, with the export sharpener. +//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits +//! for smaller files. +//! +//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lightcraft_codecs::display::MAX_PROFILE_BYTES; +use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform}; +use lightcraft_codecs::{NamedSpace, proof::ProofIntent}; +use serde::{Deserialize, Serialize}; + +use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep}; +use crate::pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement}; + +/// The colour space a printer that manages colour receives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ManagedSpace { + #[default] + Srgb, + AdobeRgb, +} + +/// Where the print's colours go. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PrintProfile { + /// Managed by Printer: the page goes in this space and the driver converts it. + Managed { space: ManagedSpace }, + /// A printer / paper profile (an `.icc` / `.icm` file): converted here. + Icc { path: PathBuf }, +} + +impl Default for PrintProfile { + fn default() -> Self { + PrintProfile::Managed { space: ManagedSpace::Srgb } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintIntent { + #[default] + Perceptual, + Relative, +} + +/// Print sharpening strength (Off leaves the photos as rendered). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintSharpen { + Off, + Low, + #[default] + Standard, + High, +} + +/// The paper print sharpening is tuned for. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintMedia { + Matte, + #[default] + Glossy, +} + +/// The print job's colour settings, as saved with print templates. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintColorSettings { + pub profile: PrintProfile, + pub intent: PrintIntent, + /// Map the page's black to the paper's darkest (relative colorimetric). + pub black_point_compensation: bool, + /// Print adjustment, −100..100. + pub brightness: f32, + /// Print adjustment, −100..100. + pub contrast: f32, + pub sharpen: PrintSharpen, + pub media: PrintMedia, + /// 16 bits per component in the PDF (else 8). + pub sixteen_bit: bool, +} + +impl Default for PrintColorSettings { + fn default() -> Self { + PrintColorSettings { + profile: PrintProfile::default(), + intent: PrintIntent::default(), + black_point_compensation: true, + brightness: 0.0, + contrast: 0.0, + sharpen: PrintSharpen::default(), + media: PrintMedia::default(), + sixteen_bit: true, + } + } +} + +/// Read a printer profile, refusing files that can't be one (the parse happens later). +fn read_profile(path: &Path) -> Result<Vec<u8>, String> { + let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len(); + if len > MAX_PROFILE_BYTES as u64 { + return Err(format!("the printer profile {shown} is too large")); + } + std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}")) +} + +/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know. +#[derive(Debug)] +pub struct PrintColorJob { + pub settings: PrintColorSettings, + transform: PrintTransform, + /// The printing condition named in the PDF (only for a printer profile). + intent: Option<OutputIntent>, +} + +impl PrintColorJob { + /// Build the job's conversion. Errors name the profile and what's wrong with it. + pub fn new(settings: &PrintColorSettings) -> Result<PrintColorJob, String> { + let s = settings.clone(); + let color = PrintColor { + intent: match s.intent { + PrintIntent::Perceptual => ProofIntent::Perceptual, + PrintIntent::Relative => ProofIntent::Relative, + }, + black_point_compensation: s.black_point_compensation, + brightness: s.brightness, + contrast: s.contrast, + }; + let (target, name) = match &s.profile { + PrintProfile::Managed { space } => { + (PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None) + } + PrintProfile::Icc { path } => { + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + (PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name)) + } + }; + let transform = PrintTransform::new(&target, &color).map_err(|e| match &name { + Some(n) => format!("the printer profile {n}: {e}"), + None => e, + })?; + let intent = name.map(|file| OutputIntent { + icc: Arc::clone(transform.icc()), + channels: transform.channels() as u8, + condition: transform.description().map(str::to_owned).unwrap_or(file), + }); + Ok(PrintColorJob { settings: s, transform, intent }) + } + + /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + let amount = match self.settings.sharpen { + PrintSharpen::Off => return, + PrintSharpen::Low => SharpenAmount::Low, + PrintSharpen::Standard => SharpenAmount::Standard, + PrintSharpen::High => SharpenAmount::High, + }; + let target = match self.settings.media { + PrintMedia::Matte => SharpenFor::Matte, + PrintMedia::Glossy => SharpenFor::Glossy, + }; + output_sharpen_deep(photo, target, amount); + } + + /// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers + /// it, in the target's colours. + pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result<PdfImage, String> { + let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3)); + if width == 0 || height == 0 || want != Some(rgb.len() as u64) { + return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len())); + } + let device = self.transform.apply(rgb)?; + let channels = self.transform.channels() as u8; + let icc = Some(Arc::clone(self.transform.icc())); + Ok(if self.settings.sixteen_bit { + PdfImage::from_u16(width, height, channels, &device, icc) + } else { + let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc } + }) + } + + /// The print-ready document: each page image covers its page (`size` in points). + pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result<Vec<u8>, String> { + let pages: Vec<PdfPage> = pages + .into_iter() + .map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] }) + .collect(); + let title = (!title.trim().is_empty()).then(|| title.to_owned()); + crate::pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() }) + } + + /// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey). + pub fn channels(&self) -> usize { + self.transform.channels() + } + + /// The profile's description (or file name) for the job's summary. + pub fn profile_name(&self) -> Option<&str> { + self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description()) + } +} + +/// A photo rendered for the page as the layout gets it from the export renderer. +pub fn is_print_ready(photo: &DeepImage) -> bool { + photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::print::print_adjust; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A small, flat page of one 16-bit colour. + fn page(w: u32, h: u32, v: [u16; 3]) -> Vec<u16> { + (0..w * h).flat_map(|_| v).collect() + } + + #[test] + fn settings_default_and_round_trip() { + let d = PrintColorSettings::default(); + assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb }); + assert!(d.black_point_compensation && d.sixteen_bit); + let s = PrintColorSettings { + profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() }, + intent: PrintIntent::Relative, + brightness: 12.0, + sharpen: PrintSharpen::High, + media: PrintMedia::Matte, + ..d.clone() + }; + let json = serde_json::to_value(&s).unwrap(); + assert_eq!(json["profile"]["kind"], "icc"); + assert_eq!(json["intent"], "relative"); + assert_eq!(serde_json::from_value::<PrintColorSettings>(json).unwrap(), s); + // partial and unknown-free JSON fills in the defaults + let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap(); + assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d }); + assert!(serde_json::from_str::<PrintColorSettings>(r#"{"profile":{"kind":"cmyk"}}"#).is_err()); + } + + #[test] + fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap(); + assert_eq!((img.channels, img.width, img.height), (3, 4, 2)); + let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") }; + assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white"); + let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]")); + assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent"); + assert!(s.contains("/Title (Prints)")); + } + + #[test] + fn a_printer_profile_converts_and_becomes_the_output_intent() { + let dir = scratch("icc"); + let path = dir.join("Studio Glossy.icc"); + std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + let settings = + PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintColorJob::new(&settings).unwrap(); + assert_eq!(job.channels(), 3); + let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap(); + assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. })); + let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]); + assert!(!s.contains("/Title"), "an empty title is left out"); + assert!(job.profile_name().is_some()); + } + + #[test] + fn profile_errors_name_the_file() { + let dir = scratch("bad"); + let garbage = dir.join("Broken Paper.icc"); + std::fs::write(&garbage, [0u8; 300]).unwrap(); + let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err(); + assert!(err.contains("Broken Paper"), "{err}"); + let missing = + PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err(); + assert!(missing.contains("nope.icc"), "{missing}"); + } + + #[test] + fn print_adjustment_reaches_the_page() { + let mid = [32768u16; 3]; + let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap(); + let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap(); + let first = |i: &PdfImage| match &i.data { + ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]), + ImageData::Jpeg(_) => 0, + }; + assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain)); + assert!(print_adjust(0.5, 60.0, 0.0) > 0.5); + } + + #[test] + fn print_sharpening_follows_the_setting() { + // a hard vertical edge in a 16-bit Rec2020 photo + let (w, h) = (8usize, 4usize); + let samples: Vec<u16> = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect(); + let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) }; + assert!(is_print_ready(&photo)); + let sharpened = |s: PrintSharpen| { + let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap(); + let mut p = photo.clone(); + job.sharpen_photo(&mut p); + p + }; + assert_eq!(sharpened(PrintSharpen::Off), photo); + let edge = |p: &DeepImage| match &p.samples { + DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]), + DeepSamples::F32(_) => (0, 0), + }; + let (dark, light) = edge(&sharpened(PrintSharpen::High)); + assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}"); + } + + #[test] + fn hostile_pages_are_errors() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + assert!(job.page_image(0, 1, &[]).is_err()); + assert!(job.page_image(2, 2, &[0; 11]).is_err()); + assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err()); + let img = job.page_image(1, 1, &[0; 3]).unwrap(); + assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err()); + assert!(job.pdf(vec![], "x").is_err()); + } +} From 65775b47160a24e350bcaabee0f44a9143f9826d Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 14:50:12 +0530 Subject: [PATCH 052/315] LRC-PRINT-JOB: colour-managed print PDFs (printer profiles, intent, black point compensation, print adjustment, print sharpening) print.exportPdf now converts each composed page to the job's colour target and writes it into a print-ready PDF: - PrintJob gains `color` (PrintColorSettings): Managed by Printer (sRGB / Adobe RGB, profile embedded, no output intent) or a printer / paper .icc / .icm converted here and named as the document's output intent; perceptual or relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte or Glossy; 16 or 8 bits per component. print.set / print.get and print templates carry it; out-of-range values are refused when set. - Each photo is sharpened for the paper right after it renders at its print size. - print/output.rs converts and Flate-compresses one page at a time (memory holds one page's device samples) into ICC-tagged images through engine::pdf; the PDF is 1.6. The 256 MiB cap counts the compressed pages. - A printer profile that can't be used (missing, too large, not a profile) fails the print before anything renders, naming the file. - contact_sheet's PDF writer goes back to private: only the contact sheet uses it. Tests: a printer profile reaches the PDF as ICCBased images plus an output intent, a junk profile fails naming the file, colour settings validate with the job, sharpening and media round-trip through print.set / print.get, a missing profile fails before rendering with no activity row; the existing print and CLI tests follow the PDF 1.6 output. Docs: LRC-PRINT-JOB and gap 15. --- crates/engine/src/cmd/print.rs | 4 +- crates/engine/src/contact_sheet.rs | 10 ++--- crates/engine/src/pdf.rs | 40 +++++++++++++++++-- crates/engine/src/print/compose.rs | 19 +++++++-- crates/engine/src/print/mod.rs | 26 +++++++----- crates/engine/src/print/output.rs | 63 ++++++++++++++++++++++-------- crates/engine/src/print_color.rs | 59 ++++++++++++++++++++++------ crates/engine/src/tests_print.rs | 27 +++++++++++++ docs/gaps.md | 5 ++- docs/parity-checklist.md | 5 ++- 10 files changed, 203 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4fa77df86..b9033ad05 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -247,6 +247,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { s.check_write_target(path).map_err(|e| bad(ID, e))?; let ids = s.targets(p); let settings = s.print.clone(); + // a printer profile that can't be used fails the print before anything renders + crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?; let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?; let total = work.len() as u64; @@ -322,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", always, get), cmd!(query "print.set", "Set Print Settings", [], None, - "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", always, set), cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), cmd!(query "print.cell.add", "Add Print Cell", [], None, diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index af4228e67..e83ad8c67 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -197,14 +197,14 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> } // Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -pub(crate) struct Pdf { - pub(crate) bytes: Vec<u8>, +struct Pdf { + bytes: Vec<u8>, offsets: Vec<usize>, points: (f64, f64), } impl Pdf { - pub(crate) fn new(pages: usize, points: (f64, f64)) -> Self { + fn new(pages: usize, points: (f64, f64)) -> Self { let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" "); @@ -226,7 +226,7 @@ impl Pdf { self.object(id, &body); } - pub(crate) fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { + fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { let id = 3 + index * 3; let (w, h) = self.points; self.object( @@ -246,7 +246,7 @@ impl Pdf { ); } - pub(crate) fn finish(mut self) -> Vec<u8> { + fn finish(mut self) -> Vec<u8> { let start = self.bytes.len(); self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); for offset in self.offsets.iter().skip(1) { diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs index 29f0b73f7..abce4061e 100644 --- a/crates/engine/src/pdf.rs +++ b/crates/engine/src/pdf.rs @@ -31,6 +31,8 @@ pub enum ImageData { Raw { bits: u8, samples: Vec<u8> }, /// A baseline JPEG stream, copied into the file as it is. Jpeg(Vec<u8>), + /// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component. + Flate { bits: u8, data: Vec<u8> }, } /// A raster image and the colour space its samples are in. @@ -52,6 +54,17 @@ impl PdfImage { PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc } } + /// The same image with its samples compressed now (checked first), so a long print doesn't + /// hold every page's uncompressed samples until the file is written. + pub fn deflated(self) -> Result<PdfImage, String> { + self.check()?; + let data = match self.data { + ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) }, + other => other, + }; + Ok(PdfImage { data, ..self }) + } + fn check(&self) -> Result<(), String> { if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE { return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height)); @@ -74,6 +87,11 @@ impl PdfImage { return Err("the image isn't a JPEG stream".into()); } } + ImageData::Flate { bits, data } => { + if !matches!(bits, 8 | 16) || data.is_empty() { + return Err("a compressed image has 8 or 16 bits per component and some data".into()); + } + } } Ok(()) } @@ -253,9 +271,10 @@ pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result<Vec<u8>, String> { }, }; let id = w.reserve(); - let (bits, filter, data) = match &image.data { - ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples)), - ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.clone()), + let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data { + ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()), + ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()), + ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()), }; w.object( id, @@ -444,6 +463,21 @@ mod tests { assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are"); } + #[test] + fn compressing_early_writes_the_same_file() { + let samples: Vec<u16> = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect(); + let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb())); + let early = raw.clone().deflated().unwrap(); + assert!(matches!(early.data, ImageData::Flate { bits: 16, .. })); + let at = full(100.0, 75.0); + let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap(); + let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap(); + assert_eq!(a, b); + let mut short = PdfImage::from_u16(4, 3, 3, &samples, None); + short.width = 5; + assert!(short.deflated().is_err(), "checked before compressing"); + } + #[test] fn hostile_documents_are_errors() { let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None); diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index 58b29b0d9..91e5db69e 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -402,6 +402,8 @@ pub struct Prepared { ppi: f32, background: [u16; 3], style: CellStyle, + /// Print sharpening, applied to each photo once it's rendered at its print size. + color: crate::print_color::PrintColorSettings, } impl Prepared { @@ -437,7 +439,7 @@ impl Prepared { let at = match at { Some(at) => at, None => { - let img = render(item.job).map_err(|e| format!("{}: {e}", item.name))?; + let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?; if cache.len() >= RENDER_CACHE { cache.remove(0); } @@ -457,9 +459,11 @@ impl Prepared { } } -fn render(job: RenderJob) -> Result<Image16, String> { +fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result<Image16, String> { let rendered = job.run().rendered?; - let deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + // print sharpening for the paper, at the size the photo prints + color.sharpen_photo(&mut deep); let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) }; if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) { return Err("the render is not Rec. 2020 RGB".into()); @@ -498,7 +502,14 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI items.push(Item { page, name, cell, key: (id.0, rw, rh), job }); } } - Ok(Prepared { pages: vec![(pw, ph); plan.len()], items, ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style }) + Ok(Prepared { + pages: vec![(pw, ph); plan.len()], + items, + ppi: ppi as f32, + background: rec2020_from_srgb8(settings.page.background), + style, + color: settings.job.color.clone(), + }) } #[cfg(test)] diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs index a9ca3e35f..c984cbf54 100644 --- a/crates/engine/src/print/mod.rs +++ b/crates/engine/src/print/mod.rs @@ -31,17 +31,20 @@ pub struct PageRaster { pub rgb: Vec<u16>, } -/// How a print job is produced (Lightroom's Print Job panel; colour management joins it later). +/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour +/// management, print adjustment, print sharpening and depth of [`crate::print_color`]. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase", deny_unknown_fields)] pub struct PrintJob { /// Print resolution in pixels per inch, 72 to 720. pub ppi: f32, + /// Profile, rendering intent, black point compensation, print adjustment, print sharpening. + pub color: crate::print_color::PrintColorSettings, } impl Default for PrintJob { fn default() -> Self { - Self { ppi: 240.0 } + Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() } } } @@ -54,11 +57,10 @@ impl PrintJob { /// Rejects a resolution outside the supported range (commands and templates, which should /// say so rather than silently clamp). pub fn validate(&self) -> Result<(), String> { - if self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi) { - Ok(()) - } else { - Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")) + if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) { + return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")); } + self.color.validate() } } @@ -70,14 +72,20 @@ mod tests { fn ppi_is_clamped_and_validated() { assert_eq!(PrintJob::default().effective_ppi(), 240.0); for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] { - assert_eq!(PrintJob { ppi: v }.effective_ppi(), e); + assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e); } - assert!(PrintJob { ppi: 300.0 }.validate().is_ok()); + assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok()); for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] { - assert!(PrintJob { ppi: bad }.validate().is_err()); + assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err()); } let j: PrintJob = serde_json::from_str("{}").unwrap(); assert_eq!(j, PrintJob::default()); assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err()); + // the colour settings ride along, defaults filled in, and are validated with the job + let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap(); + assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative); + assert!(j.color.black_point_compensation && j.validate().is_ok()); + let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap(); + assert!(j.validate().is_err()); } } diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs index 937884a8e..e1c69e373 100644 --- a/crates/engine/src/print/output.rs +++ b/crates/engine/src/print/output.rs @@ -1,10 +1,11 @@ -//! Writes composed pages as a PDF. -//! -//! Interim writer: each page is converted from Rec. 2020 to 8-bit sRGB and embedded as a JPEG -//! covering the paper. Colour-managed output replaces the body of [`write`], not its signature. +//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target +//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its +//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded +//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer +//! profile is also named as the document's output intent ([`crate::pdf`]). use super::{MAX_JOB_PAGES, PageRaster, PrintJob}; -use crate::contact_sheet::Pdf; -use crate::export::{ExportFormat, ExportOptions, encode_image}; +use crate::pdf::ImageData; +use crate::print_color::PrintColorJob; use lightcraft_color::transfer::encode_srgb8; use lightcraft_color::{REC2020, SRGB}; use lightcraft_pipeline::OutputSpace; @@ -34,9 +35,9 @@ pub fn page_to_srgb8(page: &PageRaster) -> Result<Rgba8, String> { } /// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in -/// points (`pixels / ppi * 72`) with the image covering it. +/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed +/// in turn, so memory holds one page's device samples at a time. pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8>, String> { - let _ = (job, title); let first = pages.first().ok_or("nothing to print")?; if pages.len() > MAX_JOB_PAGES { return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); @@ -46,19 +47,23 @@ pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8 } let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0; let size = (points(first.width), points(first.height)); - let mut pdf = Pdf::new(pages.len(), size); - for (i, page) in pages.iter().enumerate() { + let color = PrintColorJob::new(&job.color)?; + let mut out = Vec::with_capacity(pages.len()); + let mut bytes = 0usize; + for page in pages { if (page.width, page.height) != (first.width, first.height) { return Err("all pages of a PDF must be the same size".into()); } - let rgba = page_to_srgb8(page)?; - let jpg = encode_image(&rgba, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; - if pdf.bytes.len().saturating_add(jpg.len()) > MAX_PDF_BYTES { - return Err("the PDF would exceed 256 MiB; lower the print resolution or print fewer pages".into()); + let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?; + if let ImageData::Flate { data, .. } = &image.data { + bytes = bytes.saturating_add(data.len()); } - pdf.page(i, &jpg, page.width as usize, page.height as usize); + if bytes > MAX_PDF_BYTES { + return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into()); + } + out.push((size, image)); } - Ok(pdf.finish()) + color.pdf(out, title) } #[cfg(test)] @@ -94,6 +99,8 @@ mod tests { let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); assert!(text.contains("/Width 425 /Height 550")); + // managed by the printer: sRGB, 16 bits, tagged, no output intent + assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents")); assert!(write(&[], &PrintJob::default(), "t").is_err()); let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; assert!(write(&mixed, &PrintJob::default(), "t").is_err()); @@ -102,4 +109,28 @@ mod tests { let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) }; assert!(write(&[zero], &PrintJob::default(), "t").is_err()); } + + #[test] + fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() { + use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile}; + let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Lustre Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = + PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintJob { color, ..Default::default() }; + let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]); + assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)")); + // a profile that isn't one fails the print and says which file + std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap(); + let junk = PrintJob { + color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() }, + ..Default::default() + }; + let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err(); + assert!(err.contains("Junk"), "{err}"); + } } diff --git a/crates/engine/src/print_color.rs b/crates/engine/src/print_color.rs index 540e48952..c897f8990 100644 --- a/crates/engine/src/print_color.rs +++ b/crates/engine/src/print_color.rs @@ -110,6 +110,39 @@ impl Default for PrintColorSettings { } } +impl PrintColorSettings { + /// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty + /// profile path); whether the profile file is usable is checked when the job is built. + pub fn validate(&self) -> Result<(), String> { + for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] { + if !(v.is_finite() && (-100.0..=100.0).contains(&v)) { + return Err(format!("{name} must be a number from -100 to 100")); + } + } + if let PrintProfile::Icc { path } = &self.profile + && path.as_os_str().is_empty() + { + return Err("the printer profile needs a path".into()); + } + Ok(()) + } + + /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + let amount = match self.sharpen { + PrintSharpen::Off => return, + PrintSharpen::Low => SharpenAmount::Low, + PrintSharpen::Standard => SharpenAmount::Standard, + PrintSharpen::High => SharpenAmount::High, + }; + let target = match self.media { + PrintMedia::Matte => SharpenFor::Matte, + PrintMedia::Glossy => SharpenFor::Glossy, + }; + output_sharpen_deep(photo, target, amount); + } +} + /// Read a printer profile, refusing files that can't be one (the parse happens later). fn read_profile(path: &Path) -> Result<Vec<u8>, String> { let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); @@ -163,19 +196,9 @@ impl PrintColorJob { Ok(PrintColorJob { settings: s, transform, intent }) } - /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + /// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]). pub fn sharpen_photo(&self, photo: &mut DeepImage) { - let amount = match self.settings.sharpen { - PrintSharpen::Off => return, - PrintSharpen::Low => SharpenAmount::Low, - PrintSharpen::Standard => SharpenAmount::Standard, - PrintSharpen::High => SharpenAmount::High, - }; - let target = match self.settings.media { - PrintMedia::Matte => SharpenFor::Matte, - PrintMedia::Glossy => SharpenFor::Glossy, - }; - output_sharpen_deep(photo, target, amount); + self.settings.sharpen_photo(photo); } /// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers @@ -312,7 +335,7 @@ mod tests { let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap(); let first = |i: &PdfImage| match &i.data { ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]), - ImageData::Jpeg(_) => 0, + ImageData::Jpeg(_) | ImageData::Flate { .. } => 0, }; assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain)); assert!(print_adjust(0.5, 60.0, 0.0) > 0.5); @@ -340,6 +363,16 @@ mod tests { assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}"); } + #[test] + fn validation_refuses_out_of_range_settings() { + assert!(PrintColorSettings::default().validate().is_ok()); + for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] { + assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}"); + } + assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err()); + assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok()); + } + #[test] fn hostile_pages_are_errors() { let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index eb7ab032c..5b890a7f0 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -213,6 +213,33 @@ fn export_pdf_prints_the_current_settings() { fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); } +#[test] +fn export_pdf_with_a_printer_profile() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(1).map(|p| p.id.0).collect(); + let dir = std::env::temp_dir().join(format!("lightcraft-print-icc-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Fine Art Matte.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = json!({"profile": {"kind": "icc", "path": icc}, "intent": "relative", "sharpen": "high", "media": "matte", "brightness": 10}); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0, "color": color}}), + ); + assert_eq!(run(&mut s, "print.get", json!({}))["settings"]["job"]["color"]["media"], "matte"); + let path = temp_pdf("icc"); + run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/ICCBased") && text.contains("/BitsPerComponent 16")); + // out-of-range adjustments are refused when set; a missing profile fails before rendering + fails(&mut s, "print.set", json!({"job": {"color": {"contrast": 150}}})); + run(&mut s, "print.set", json!({"job": {"color": {"profile": {"kind": "icc", "path": dir.join("gone.icc")}}}})); + let err = fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert!(err.contains("gone.icc"), "{err}"); + assert!(s.activity.list().is_empty(), "nothing started"); +} + #[test] fn custom_package_prints_each_cells_photo() { let mut s = Session::with_demo(); diff --git a/docs/gaps.md b/docs/gaps.md index f24cbf5bc..8bea73112 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 15: print layout model; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 15: printer colour management; gap 15: print layout model; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); HLG; the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read) | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: printer colour management (profile, rendering intent, black point compensation, print adjustment and sharpening), identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing (the Print dialog writes a PDF) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 15: printer colour management lands (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening, 16-bit ICC-tagged PDF with an output intent); page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | | 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | | 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4edb4eff5..010506b9f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LRC-PRINT-JOB: print colour management and ICC-tagged print PDFs; LRC-PRINT-GUIDES and the other print rows: the Print dialog (File → Print…); LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-CELLS, LRC-PRINT-TEMPLATES and the other print rows: `print.*` commands; LRC-PRINT-IMAGESETTINGS: print page composition (fit / Zoom to Fill, Rotate to Fit, border, stroke, background); LRC-PRINT-LAYOUT: print layout model; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -753,7 +753,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | -| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | +| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | | LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-JOB: print colour management (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening) and a 16-bit ICC-tagged PDF with an output intent; still 🟡 (no OS print dialog or JPEG output) | | 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | | 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | From 5cbaf64579795c1605dea891ba898619dff1e24a Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 15:39:08 +0530 Subject: [PATCH 053/315] LRC-PRINT-GUIDES: the Print preview stops re-rendering thumbnails every frame A thumbnail is kept at one size. The page preview asked for each cell's photo at the cell's size on every frame, and the library grid behind the dialog asked for its own size, so the two replaced each other's request: while the Print dialog was open a thumbnail render was always in flight and about two render jobs were rebuilt per frame, keeping a core busy. The preview now uses the photo's thumbnail when there is one and asks for one only when there is none. Regression test: with the dialog open in Contact Sheet, Picture Package (the same photo in cells of different sizes) and Single Image, the dialog settles, no render is in flight and no thumbnail job is rebuilt over 60 idle frames. Before the fix the job count rose by about 100 every 50 frames. The Print dialog's UI tests now finish in seconds (they were waiting out each settle timeout). --- crates/ui-egui/src/print_view.rs | 9 +++++++-- crates/ui-egui/src/tests_print.rs | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 7c1ab85fb..245c126ae 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -729,8 +729,13 @@ fn paint_cell( } let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); let tex = photo.and_then(|id| { - let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; - crate::panels::grid::request_thumb(app, id, px, 10); + // Use the library's thumbnail when there is one. A thumbnail is kept at one size, so asking + // for another size here every frame made the preview and the grid behind the dialog + // re-render each other's thumbnail for as long as the dialog stayed open. + if app.renderer.thumb(id).is_none() { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + } app.renderer.thumb(id).map(|tex| tex.tex.id()) }); match tex { diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 6dbea7a40..a75191d03 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -298,3 +298,26 @@ fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { } } } + +#[test] +fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { + let mut h = demo(Services { png: None, ..Default::default() }); + let before = h.app.renderer.thumb_jobs_built; + cmd(&mut h, "dialog.print", json!({})); + for style in ["contactSheet", "picturePackage", "singleImage"] { + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "picturePackage" { + // the same photo in cells of different sizes + cmd(&mut h, "print.cell.add", json!({"width": 2.0, "height": 2.5})); + cmd(&mut h, "print.cell.add", json!({"width": 5.0, "height": 7.0})); + } + assert!(h.settle(Duration::from_secs(5)), "{style}: the dialog settles"); + let built = h.app.renderer.thumb_jobs_built; + for _ in 0..60 { + h.step(); + } + assert_eq!(h.app.renderer.thumb_jobs_built, built, "{style}: no thumbnail is rebuilt while nothing changes"); + assert_eq!(h.app.renderer.in_flight(), 0, "{style}"); + } + assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); +} From 64e0395c956efd8d34736e3dde7f65a637b56a6d Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Mon, 12 Oct 2026 15:40:04 +0530 Subject: [PATCH 054/315] LRC-PRINT-JOB: Print Job colour controls in the Print dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Print dialog's Print Job panel, under Resolution: - Profile: Managed by Printer (sRGB), Managed by Printer (Adobe RGB), or Other… to pick a printer / paper .icc / .icm with the colour-profile file picker the soft proof uses; the picked profile's name is shown. - Intent: Perceptual / Relative; Black Point Compensation (enabled for Relative). - Print Adjustment: Brightness and Contrast, -100..100. - Sharpening: Off / Low / Standard / High; Media: Matte / Glossy (enabled while sharpening is on). - 16 bits per channel. - A summary line: PDF, profile, depth, current edits. Each control sends print.set {job: {color}}, so the dialog, CLI, control channel, MCP and print templates share the settings. UI test: every control reaches the job (the Print Job panel is scrolled into view first, as a person would), Other… picks a profile from the picker, and the PDF printed with it carries the output intent at 8 bits. --- crates/ui-egui/src/print_view.rs | 86 ++++++++++++++++++++++++++++++- crates/ui-egui/src/tests_print.rs | 57 ++++++++++++++++++++ docs/parity-checklist.md | 3 +- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 245c126ae..b7f6d44aa 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -11,6 +11,7 @@ use lightcraft_engine::export::ExportOptions; use lightcraft_engine::print::compose::{self, place}; use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; use lightcraft_engine::print::template::PrintSettings; +use lightcraft_engine::print_color::{ManagedSpace, PrintColorSettings, PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -428,7 +429,90 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"job": {"ppi": ppi}})); } }); - ui.label(egui::RichText::new(crate::i18n::tr("PDF · sRGB · current edits")).color(t.text_dim)); + print_job_color(app, ui, t, &st.job.color); +} + +/// Send one colour setting of the print job (the rest stay as they are). +fn patch_color(app: &mut LightcraftApp, ctx: &egui::Context, key: &str, value: Value) { + patch(app, ctx, json!({"job": {"color": {(key): value}}})); +} + +fn to_value<V: serde::Serialize>(v: V) -> Value { + serde_json::to_value(v).unwrap_or(Value::Null) +} + +/// The Print Job panel's colour management, print adjustment, print sharpening and depth. +fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &PrintColorSettings) { + let ctx = ui.ctx().clone(); + let managed = |space| PrintProfile::Managed { space }; + let shown = match &color.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => crate::i18n::tr("Managed by Printer (sRGB)").to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => crate::i18n::tr("Managed by Printer (Adobe RGB)").to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + let mut pick = false; + row(ui, t, "Profile", |ui| { + let c = egui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| { + for (space, label) in [(ManagedSpace::Srgb, "Managed by Printer (sRGB)"), (ManagedSpace::AdobeRgb, "Managed by Printer (Adobe RGB)")] { + let r = ui.selectable_label(color.profile == managed(space), crate::i18n::tr(label)); + register(ui.ctx(), format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect); + if r.clicked() && color.profile != managed(space) { + patch_color(app, &ctx, "profile", to_value(managed(space))); + } + } + if let PrintProfile::Icc { .. } = &color.profile { + let _ = ui.selectable_label(true, shown.as_str()); + } + if app.services.pick_display_profile.is_some() { + let r = ui.selectable_label(false, crate::i18n::tr("Other…")); + register(ui.ctx(), "item:printProfile-other", r.rect); + pick |= r.clicked(); + } + }); + register(ui.ctx(), "combo:printProfile", c.response.rect); + }); + if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) { + patch_color(app, &ctx, "profile", json!({"kind": "icc", "path": path})); + } + row(ui, t, "Intent", |ui| { + if let Some(v) = choice(ui, "printIntent", &[(PrintIntent::Perceptual, "Perceptual"), (PrintIntent::Relative, "Relative")], color.intent) { + patch_color(app, &ctx, "intent", to_value(v)); + } + }); + ui.add_enabled_ui(color.intent == PrintIntent::Relative, |ui| { + if let Some(v) = check(ui, "printBpc", "Black Point Compensation", color.black_point_compensation) { + patch_color(app, &ctx, "blackPointCompensation", json!(v)); + } + }); + for (key, label, value) in [("brightness", "Brightness", color.brightness), ("contrast", "Contrast", color.contrast)] { + row(ui, t, label, |ui| { + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).range(-100.0..=100.0).speed(0.5).max_decimals(0)); + register(ui.ctx(), format!("drag:print{label}"), r.rect); + if r.changed() { + patch_color(app, &ctx, key, json!(v)); + } + }); + } + row(ui, t, "Sharpening", |ui| { + let options = [(PrintSharpen::Off, "Off"), (PrintSharpen::Low, "Low"), (PrintSharpen::Standard, "Standard"), (PrintSharpen::High, "High")]; + if let Some(v) = choice(ui, "printSharpen", &options, color.sharpen) { + patch_color(app, &ctx, "sharpen", to_value(v)); + } + }); + ui.add_enabled_ui(color.sharpen != PrintSharpen::Off, |ui| { + row(ui, t, "Media", |ui| { + if let Some(v) = choice(ui, "printMedia", &[(PrintMedia::Matte, "Matte"), (PrintMedia::Glossy, "Glossy")], color.media) { + patch_color(app, &ctx, "media", to_value(v)); + } + }); + }); + if let Some(v) = check(ui, "print16bit", "16 bits per channel", color.sixteen_bit) { + patch_color(app, &ctx, "sixteenBit", json!(v)); + } + let bits = if color.sixteen_bit { "16-bit" } else { "8-bit" }; + let summary = format!("PDF · {shown} · {bits} · {}", crate::i18n::tr("current edits")); + ui.label(egui::RichText::new(summary).color(t.text_dim)); } fn trim(v: f64) -> String { diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index a75191d03..29c214e47 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -299,6 +299,63 @@ fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { } } +#[test] +fn print_job_colour_controls_reach_the_job_and_the_pdf() { + use lightcraft_engine::print_color::{PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; + let dir = std::env::temp_dir().join(format!("lightcraft-print-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Baryta Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let picked = icc.to_string_lossy().into_owned(); + let services = Services { png: None, pick_display_profile: Some(Box::new(move || vec![picked.clone()])), ..Default::default() }; + let mut h = demo(services); + open_print(&mut h); + // the Print Job panel is at the bottom of the settings column: scroll down to it, as a person would + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": -600.0}), T); + h.settle(T); + } + let job = |h: &Headless| h.app.session.print.job.color.clone(); + assert_eq!(job(&h).intent, PrintIntent::Perceptual); + assert!(!has(&h, "item:printProfile-other"), "the menu is closed"); + + click(&mut h, "button:printIntent-1"); + assert_eq!(job(&h).intent, PrintIntent::Relative); + click(&mut h, "check:printBpc"); + assert!(!job(&h).black_point_compensation); + click(&mut h, "button:printSharpen-3"); + click(&mut h, "button:printMedia-0"); + assert_eq!((job(&h).sharpen, job(&h).media), (PrintSharpen::High, PrintMedia::Matte)); + click(&mut h, "check:print16bit"); + assert!(!job(&h).sixteen_bit); + drag(&mut h, "drag:printBrightness", 30.0, 0.0); + assert!(job(&h).brightness > 0.0, "{}", job(&h).brightness); + + // a printer profile from the picker + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-other"); + assert_eq!(job(&h).profile, PrintProfile::Icc { path: icc.clone() }); + + // the PDF uses it: converted pages, the profile named as the output intent, 8 bits + cmd( + &mut h, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}}), + ); + let ids: Vec<u64> = h.app.session.catalog.photos().take(1).map(|p| p.id.0).collect(); + let out = dir.join("colour.pdf"); + cmd(&mut h, "print.exportPdf", json!({"path": out, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/BitsPerComponent 8") && text.contains("/ICCBased")); + + // back to Managed by Printer + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-adobeRgb"); + assert_eq!(job(&h).profile, PrintProfile::Managed { space: lightcraft_engine::print_color::ManagedSpace::AdobeRgb }); +} + #[test] fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { let mut h = demo(Services { png: None, ..Default::default() }); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 010506b9f..2fe882e49 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -753,7 +753,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | | LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | -| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | +| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | | LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LRC-PRINT-JOB: the Print dialog's Print Job panel sets the profile (Managed by Printer or a printer / paper profile picked from disk), intent, black point compensation, print adjustment, print sharpening and depth | | 2026-10-12 | minor | LRC-PRINT-JOB: print colour management (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening) and a 16-bit ICC-tagged PDF with an output intent; still 🟡 (no OS print dialog or JPEG output) | | 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | | 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | From 18f583ca5f83628151c2dc44ffb0fff76192d37b Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Mon, 12 Oct 2026 14:06:23 +0200 Subject: [PATCH 055/315] LR-VIEW-ZOOM: render workers convert their texture pixels, not the UI thread (#330) A 10 MP zoom window's ColorImage conversion ran on the UI thread on every slider step. The pool now hands back the render with its texture pixels; offloaded (browser) results and results presented again on the UI thread are still converted there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/render.rs | 60 ++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..231ac1447 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -80,6 +80,14 @@ pub trait RenderOffload { fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>; } +/// A job's result as the pool hands it back: the render and its texture pixels, converted on the +/// worker (`None` for prefetches and failures). A 10 MP zoom window's conversion took a few ms of +/// the UI thread on every slider step. +struct Finished { + result: RenderResult, + color: Option<Arc<egui::ColorImage>>, +} + struct Queued { slot: Slot, priority: u32, @@ -179,7 +187,7 @@ pub struct Renderer { thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool)>, /// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it). embedded_thumbs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>, - pool: JobPool<Slot, RenderResult>, + pool: JobPool<Slot, Finished>, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. offload: Option<Box<dyn RenderOffload>>, queue: Vec<Queued>, @@ -237,7 +245,7 @@ impl Slot { impl Default for Renderer { fn default() -> Self { - let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) }; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, Finished>::default_threads().min(6) }; Self::with_worker_threads(threads) } } @@ -372,12 +380,13 @@ impl Renderer { } /// Run `job` and make its image ready for `display` (on the worker thread). - fn run_for(job: impl FnOnce() -> RenderResult, display: &Option<Arc<lightcraft_engine::display::Display>>) -> RenderResult { + fn run_for(job: impl FnOnce() -> RenderResult, display: &Option<Arc<lightcraft_engine::display::Display>>) -> Finished { let mut r = job(); if let Some(d) = display { lightcraft_engine::display::present(&mut r, d); } - r + let color = r.rendered.as_ref().ok().map(|x| Arc::new(color_image(&x.image))); + Finished { result: r, color } } fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { @@ -472,7 +481,12 @@ impl Renderer { job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); + self.pool.submit( + slot, + key, + priority, + Box::new(move || Finished { result: lightcraft_engine::memory::in_background(|| job.run()), color: None }), + ); } /// Is a request for `slot` queued or running? @@ -651,14 +665,14 @@ impl Renderer { let inline_ms = 0.0; let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { - finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); + finished.push((done.slot, done.result.result, done.result.color, if done.ms > 0.0 { done.ms } else { inline_ms })); } if let Some(off) = self.offload.as_mut() { - finished.extend(off.finished()); + finished.extend(off.finished().into_iter().map(|(slot, r, ms)| (slot, r, None, ms))); } self.dispatch(); let mut changed = false; - for (mut slot, mut r, ms) in finished { + for (mut slot, mut r, mut color, ms) in finished { self.completed += 1; // made for another display profile than the one in use: obsolete if r.display.is_some_and(|id| Some(id) != self.display_id()) { @@ -687,7 +701,11 @@ impl Renderer { } // (results from an offload, which doesn't know the display profile: converted here) if let Some(d) = &self.display { + let before = r.display; lightcraft_engine::display::present(&mut r, d); + if r.display != before { + color = None; // converted after the worker made its texture pixels + } } if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); @@ -721,7 +739,7 @@ impl Renderer { self.textures.remove(&Slot::ThumbQuick(id)); } let img = &rendered.image; - let color = std::sync::Arc::new(color_image(img)); + let color = color.unwrap_or_else(|| Arc::new(color_image(img))); let pixels = self.keep_pixels.then(|| color.clone()); let name = format!("{slot:?}"); match self.textures.get_mut(&slot) { @@ -1395,6 +1413,30 @@ mod color_image_tests { assert_eq!(got.pixels, want.pixels); } + // Given a finished render, the worker hands back its texture pixels with it (the UI thread no + // longer converts a 10 MP window on every slider step); a failed render has none + #[test] + fn the_worker_converts_the_texture_pixels() { + let image = lightcraft_raster::Rgba8::from_fn(5, 3, |x, y| [(x * 40) as u8, (y * 80) as u8, 7, 255]); + let result = |rendered| RenderResult { + request_id: 1, + source_key: None, + photo: PhotoId(1), + level: lightcraft_engine::SourceLevel::Preview, + key: 1, + rendered, + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + let histogram = Histogram::of_srgb8(&image); + let ok = Renderer::run_for(|| result(Ok(lightcraft_pipeline::Rendered { image: image.clone(), histogram, deep: None })), &None); + assert_eq!(ok.color.map(|c| c.pixels.clone()), Some(color_image(&image).pixels)); + let failed = Renderer::run_for(|| result(Err("unreadable".into())), &None); + assert!(failed.color.is_none()); + } + #[test] fn an_empty_image_is_an_empty_texture() { let img = lightcraft_raster::Rgba8::new(0, 0); From b446a5b8290fb452300f9d8ec7be0610025d7b9a Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Mon, 12 Oct 2026 14:06:23 +0200 Subject: [PATCH 056/315] LR-VIEW-ZOOM: time drag drafts with the frame that shows them, give up early (#330) - A full-size draft's time is its render plus the CPU time of the frame that uploaded it (eframe's cpu_usage, Perf::host_frame_ms): render times alone said 231 of 234 1:1 drafts fit 16 ms while no complete update did (PR #1009 benchmarks). - A drag whose full-size drafts take more than twice the budget drops to 0.6 after three of them instead of at its end (a refined mask ran at 190 ms a step for the whole first drag). - Under a zoom window the hidden whole-frame draft is the 0.6 one, and it is not timed (the frame's time there is the window's upload). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/lightcraft/src/main.rs | 3 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/detail.rs | 8 +- crates/ui-egui/src/region.rs | 153 +++++++++++++++++++++++----- docs/parity-checklist.md | 5 +- 5 files changed, 141 insertions(+), 31 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index cc71cc06d..5b9afbb83 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -73,7 +73,8 @@ impl eframe::App for App { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0); self.0.ui(ui); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bd825d467..1fa554c06 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -238,6 +238,9 @@ pub struct Perf { pub update_ms: f64, /// The slowest whole update since start. pub max_update_ms: f64, + /// The previous frame's whole time on this thread, painting (texture uploads) included, as + /// the host reports it (eframe's `cpu_usage`); `None` without a host report (headless). + pub host_frame_ms: Option<f64>, pub fps: f64, } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0b17684d3..ba14091a7 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -446,9 +446,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); - if let Some((key, ms)) = shown { - app.drag_scale.observe(key, ms); - } // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -468,6 +465,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); } + // time the full-size drafts where they are what shows: under a window the whole-frame draft + // is small anyway, and the frame's time is the window's upload + if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { + app.drag_scale.observe(key, ms, app.perf.host_frame_ms); + } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index bd7c57c94..4fb2e587b 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -97,25 +97,37 @@ pub struct LoupePlan { pub const CPU_DRAFT_SCALE: f32 = 0.6; /// The slider budget (ms): a drag whose full-size drafts take longer gets smaller drafts next time. +/// A draft's time is its render plus the frame that shows it (the texture upload is part of it). pub const DRAFT_BUDGET_MS: f64 = 16.0; +/// Full-size drafts this many times over budget give up on full size in the middle of the drag: +/// a drag at 200 ms a step is worse than one jump in resolution. +const GIVE_UP_FACTOR: f64 = 2.0; + +/// The full-size drafts (after the first) a drag times before it may give up on full size. +const GIVE_UP_AFTER: usize = 3; + /// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. const SLOW_DRAGS_KEPT: usize = 256; /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it -/// ends (a change in the middle would make the resolution jump). A drag renders at full size when -/// the GPU draws the view and no earlier drag of the same control on this photo went over budget -/// at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue #330). Whether the GPU drew a render is -/// not enough on its own: some views (a LUT profile, refined masks, a photo past a GPU limit) fall -/// back to the CPU per render, which only the time a draft took shows. +/// ends (a change in the middle would make the resolution jump), unless its full-size drafts are +/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of +/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] +/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT +/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large +/// draft costs a texture upload too, which only the time a draft took to show tells. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. current: Option<((lightcraft_catalog::PhotoId, String), f32)>, - /// How long the current drag's full-size drafts took (ms). + /// How long the current drag's full-size drafts took to show (ms). samples: Vec<f64>, /// Key of the whole-frame render last timed (each render is timed once). seen: Option<u64>, + /// The render time (ms) of a draft that reached the screen this frame: the time of the frame + /// that uploads it is only known on the next one. + pending: Option<f64>, /// Drags whose full-size drafts went over budget. slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, } @@ -141,16 +153,36 @@ impl DragScale { Some(s) } - /// The whole-frame render on screen: `key` identifies it, `ms` is how long it took. Times the - /// current drag's full-size drafts. - pub fn observe(&mut self, key: u64, ms: f64) { + /// Once per frame: `key` identifies the whole-frame render on screen and `render_ms` is how + /// long it took; `last_frame_ms` is how long the previous frame took, painting included (the + /// host's report; `None` without one, e.g. headless). Times the current drag's full-size + /// drafts, each as its render plus the frame that uploaded and showed it, and gives up on full + /// size when they are far over budget. + pub fn observe(&mut self, key: u64, render_ms: f64, last_frame_ms: Option<f64>) { let full = matches!(self.current, Some((_, s)) if s >= 1.0); - if !full || self.seen == Some(key) { + if !full { + self.pending = None; + return; + } + if let Some(render) = self.pending.take() { + let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + if self.samples.len() < 1024 { + self.samples.push(render + frame); + } + if self.samples.len() > GIVE_UP_AFTER + && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS * GIVE_UP_FACTOR) + && let Some((_, s)) = self.current.as_mut() + { + *s = CPU_DRAFT_SCALE; + return; + } + } + if self.seen == Some(key) { return; } self.seen = Some(key); - if ms.is_finite() && ms >= 0.0 && self.samples.len() < 1024 { - self.samples.push(ms); + if render_ms.is_finite() && render_ms >= 0.0 { + self.pending = Some(render_ms); } } @@ -163,7 +195,7 @@ impl DragScale { /// The drag ended: remember it if its full-size drafts were over budget. fn finish(&mut self) { if let Some((key, _)) = self.current.take() - && over_budget(&self.samples) + && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) { if self.slow.len() >= SLOW_DRAGS_KEPT { self.slow.clear(); @@ -172,15 +204,16 @@ impl DragScale { } self.samples.clear(); self.seen = None; + self.pending = None; } } -/// Whether a drag's draft times (ms) are over [`DRAFT_BUDGET_MS`]: the median, leaving out the -/// first draft (it rebuilds the stage caches the others reuse). -fn over_budget(samples: &[f64]) -> bool { +/// The median of a drag's draft times (ms), leaving out the first draft (it rebuilds the stage +/// caches the others reuse); `None` before a second draft. +fn median_after_first(samples: &[f64]) -> Option<f64> { let mut rest: Vec<f64> = samples.iter().skip(1).copied().collect(); rest.sort_by(f64::total_cmp); - rest.get(rest.len() / 2).is_some_and(|median| *median > DRAFT_BUDGET_MS) + rest.get(rest.len() / 2).copied() } /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame @@ -201,13 +234,15 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { // original is not decoded for the whole-frame render let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize }; let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap); - let main_edge = edge_at(scale); let frame_edge = settings.window_frame_edge(drawn_long, v.native_long); // a user who capped the preview size has chosen speed over sharpness at fit let fit = drawn_long <= canvas_long * 1.05; // (against the undrafted whole-frame render: a drag must not switch the window off and on) let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); - LoupePlan { main_edge, window_edge } + // with a window over it, the whole-frame draft is hidden (it feeds the histogram and stands in + // where the window has no pixels yet): the small draft does, and the window keeps the frame time + let scale = if window_edge.is_some() && v.draft.is_some() { scale.min(CPU_DRAFT_SCALE) } else { scale }; + LoupePlan { main_edge: edge_at(scale), window_edge } } /// How long the render sizes stay as they were after a pinch or two-finger scroll stops (s). @@ -422,11 +457,12 @@ mod tests { let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); - d.observe(7, 400.0); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 40.0), (10, 45.0), (11, 9.0)] { - d.observe(key, ms); - d.observe(key, ms); // a render is timed once, however many frames show it + d.observe(7, 400.0, None); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + d.observe(key, ms, None); + d.observe(key, ms, None); // a render is timed once, however many frames show it } + assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); assert_eq!(d.scale(None, true, None), None); assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); @@ -442,18 +478,85 @@ mod tests { let drag = Some((PhotoId(1), "Exposure")); d.scale(drag, true, None); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { - d.observe(key, ms); + d.observe(key, ms, None); } d.scale(None, true, None); assert_eq!(d.scale(drag, true, None), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { - d.observe(key, ms); + d.observe(key, ms, None); } d.scale(None, true, None); assert_eq!(d.scale(drag, true, None), Some(1.0)); } + // Given renders within budget whose frames (the texture upload) take it over, the drag counts + // as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms + #[test] + fn a_drafts_time_includes_the_frame_that_shows_it() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for key in 1..=5 { + d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(12.0)); + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + // the same renders without the host's frame times (headless) stay within budget + let mut headless = DragScale::default(); + headless.scale(drag, true, None); + for key in 1..=6 { + headless.observe(key, 8.0, None); + } + headless.scale(None, true, None); + assert_eq!(headless.scale(drag, true, None), Some(1.0)); + // a hostile frame time counts as nothing, never a panic + let mut hostile = DragScale::default(); + hostile.scale(drag, true, None); + for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { + hostile.observe(key, 8.0, Some(frame)); + } + hostile.scale(None, true, None); + assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + } + + // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), + // the drag drops to the small draft after a few of them instead of at its end, and the next + // drag starts small + #[test] + fn a_drag_far_over_budget_drafts_small_before_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + let mut scales = Vec::new(); + for key in 1..=8 { + d.observe(key, 190.0, None); + scales.push(d.scale(drag, true, None)); + } + // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame + assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); + assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + } + + // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is + // the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees + #[test] + fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { + let zoomed = sizes(24000.0, 2800.0, 6000); + let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); + let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); + assert!(full.window_edge.is_some(), "{full:?}"); + assert_eq!(full, small); + // at fit without a window, full size stays full size + let fit = sizes(2800.0, 2800.0, 6000); + assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); + } + // Given a drag at fit at full size, the draft is as sharp as the render at rest #[test] fn a_full_size_drag_at_fit_keeps_the_resolution() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8fc8117b1..55044ce82 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, timed with their frame, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -129,7 +129,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); the size is chosen when a drag starts and kept until it ends (`region::DragScale`, issue #330). A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-VIEW-ZOOM: slider-drag drafts are timed with the frame that shows them, drop to 0.6 mid-drag when far over budget, stay 0.6 under a zoom window; workers convert texture pixels (issue #330); status unchanged | | 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); status unchanged | | 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) | From 6a5158d4ed14962770b321cb9e494c99910f6dbd Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Tue, 13 Oct 2026 03:01:31 +1100 Subject: [PATCH 057/315] i18n: translate dialog notes, toasts and tooltips (Contact Sheet, HDR export, slideshow, auto advance, import tokens) Refs #911 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/ui-egui/locales/de-formats.json | 3 ++- crates/ui-egui/locales/de.json | 14 ++++++++++- crates/ui-egui/locales/es-formats.json | 3 ++- crates/ui-egui/locales/es.json | 14 ++++++++++- crates/ui-egui/locales/fr-formats.json | 3 ++- crates/ui-egui/locales/fr.json | 14 ++++++++++- crates/ui-egui/locales/ja-formats.json | 3 ++- crates/ui-egui/locales/ja.json | 14 ++++++++++- crates/ui-egui/locales/pt-br-formats.json | 3 ++- crates/ui-egui/locales/pt-br.json | 14 ++++++++++- crates/ui-egui/locales/ru-formats.json | 3 ++- crates/ui-egui/locales/ru.json | 14 ++++++++++- crates/ui-egui/locales/uk-formats.json | 3 ++- crates/ui-egui/locales/uk.json | 14 ++++++++++- crates/ui-egui/locales/zh-hans-formats.json | 3 ++- crates/ui-egui/locales/zh-hans.json | 14 ++++++++++- crates/ui-egui/locales/zh-hant-formats.json | 3 ++- crates/ui-egui/locales/zh-hant.json | 14 ++++++++++- crates/ui-egui/src/import.rs | 2 +- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 26 ++++++++++----------- crates/ui-egui/src/panels/faces.rs | 6 ++--- crates/ui-egui/src/panels/settings.rs | 5 +++- crates/ui-egui/src/shortcuts.rs | 2 +- 25 files changed, 160 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 7bcaf9f20..780b6c307 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}", "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", - "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt" + "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0ed..d700d6c71 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2109,5 +2109,17 @@ "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", - "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt" + "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Contact Sheet PDF": "Kontaktabzug-PDF", + "Letter": "Letter", + "Filename captions": "Dateinamen als Bildunterschrift", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Nach dem Bestätigen den Speicherort des PDFs wählen. Das PDF aus dem Anzeigeprogramm drucken.", + "HDR output": "HDR-Ausgabe", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "In HDR bearbeitete Fotos: JPEG mit HDR-Gain-Map (sieht überall richtig aus, hellere Lichter auf HDR-Bildschirmen), AVIF mit 10 Bit, PNG und JPEG XL mit 16 Bit Rec. 2020 PQ; TIFF braucht 32-Bit-Gleitkomma", + "HDR TIFF needs 32-bit float.": "HDR-TIFF braucht 32-Bit-Gleitkomma.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Farbraum: Rec. 2020 PQ (HDR-AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Farbraum: Rec. 2020 PQ, 16 Bit (HDR-PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Farbraum: Rec. 2020 PQ (HDR-JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Programme, die den cICP-Block des PNGs nicht lesen, zeigen PQ-Werte als sRGB: flau und blass. Für ein Bild, das überall richtig aussieht, JPEG (Gain-Map) verwenden." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 3dd94a934..5dd49b996 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + "{n} selected photos · {pages} pages": "{n} fotos seleccionadas · {pages} páginas" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ff672e88d..7c5053cd7 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", - "Adding to Apple Photos": "Añadiendo a Fotos de Apple" + "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "Contact Sheet PDF": "PDF de hoja de contactos", + "Letter": "Carta", + "Filename captions": "Pies con el nombre de archivo", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRGB · ediciones actuales · ajustado sin recortar", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Elige dónde guardar el PDF después de confirmar. Imprime el PDF desde tu visor.", + "HDR output": "Salida HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas en HDR: JPEG con mapa de ganancia HDR (se ve bien en todas partes, con luces más brillantes en pantallas HDR), AVIF a 10 bits, PNG y JPEG XL a 16 bits Rec. 2020 PQ; TIFF necesita coma flotante de 32 bits", + "HDR TIFF needs 32-bit float.": "El TIFF HDR necesita coma flotante de 32 bits.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espacio de color: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espacio de color: Rec. 2020 PQ, 16 bits (PNG HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espacio de color: Rec. 2020 PQ (JPEG XL HDR)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Los visores que no leen el bloque cICP del PNG muestran los valores PQ como sRGB: planos y desvaídos. Para una imagen que se vea bien en todas partes, usa JPEG (mapa de ganancia)." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 65cf97311..bb3e9d1c3 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}", "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", - "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original" + "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + "{n} selected photos · {pages} pages": "{n} photos sélectionnées · {pages} pages" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f7c469ffd..d8e5f634f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", - "Adding to Apple Photos": "Ajout à Apple Photos" + "Adding to Apple Photos": "Ajout à Apple Photos", + "Contact Sheet PDF": "Planche contact PDF", + "Letter": "Lettre US", + "Filename captions": "Légendes avec le nom de fichier", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRVB · retouches actuelles · ajusté sans recadrage", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Choisissez l’emplacement du PDF après confirmation. Imprimez le PDF depuis votre visionneuse.", + "HDR output": "Sortie HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Photos retouchées en HDR : JPEG avec carte de gain HDR (correct partout, hautes lumières plus vives sur les écrans HDR), AVIF en 10 bits, PNG et JPEG XL en 16 bits Rec. 2020 PQ ; le TIFF exige du 32 bits flottant", + "HDR TIFF needs 32-bit float.": "Le TIFF HDR exige du 32 bits flottant.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espace colorimétrique : Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espace colorimétrique : Rec. 2020 PQ, 16 bits (PNG HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espace colorimétrique : Rec. 2020 PQ (JPEG XL HDR)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Les visionneuses qui ne lisent pas le bloc cICP du PNG affichent les valeurs PQ comme du sRVB : plates et délavées. Pour une image correcte partout, utilisez le JPEG (carte de gain)." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ca286386d..c24e26501 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}", "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", - "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています" + "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + "{n} selected photos · {pages} pages": "選択した写真 {n} 枚 · {pages} ページ" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9b9ae4c35..062f8a7c6 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1953,5 +1953,17 @@ "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", - "Adding to Apple Photos": "Appleの写真に追加中" + "Adding to Apple Photos": "Appleの写真に追加中", + "Contact Sheet PDF": "コンタクトシート PDF", + "Letter": "レター", + "Filename captions": "ファイル名のキャプション", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 現在の編集 · トリミングせずに収める", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確定後に PDF の保存先を選びます。PDF はビューアから印刷してください。", + "HDR output": "HDR 出力", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "HDR で編集した写真:JPEG は HDR ゲインマップ付き(どこでも正しく表示され、HDR ディスプレイではハイライトがより明るい)、AVIF は 10 ビット、PNG と JPEG XL は 16 ビット Rec. 2020 PQ。TIFF には 32 ビット浮動小数点が必要です", + "HDR TIFF needs 32-bit float.": "HDR TIFF には 32 ビット浮動小数点が必要です。", + "Color space: Rec. 2020 PQ (HDR AVIF)": "カラースペース:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "カラースペース:Rec. 2020 PQ、16 ビット(HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "カラースペース:Rec. 2020 PQ(HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "PNG の cICP チャンクを読まないビューアは PQ の値を sRGB として表示するため、平坦で色あせて見えます。どこでも正しく見せるには JPEG(ゲインマップ)を使ってください。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 0b3ebe80d..ed904f3a6 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}", "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", - "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original" + "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + "{n} selected photos · {pages} pages": "{n} fotos selecionadas · {pages} páginas" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9c16f1110..541482dda 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", - "Adding to Apple Photos": "Adicionando ao Fotos da Apple" + "Adding to Apple Photos": "Adicionando ao Fotos da Apple", + "Contact Sheet PDF": "Folha de contato em PDF", + "Letter": "Carta", + "Filename captions": "Legendas com o nome do arquivo", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · edições atuais · ajustado sem cortar", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Escolha onde salvar o PDF depois de confirmar. Imprima o PDF pelo seu visualizador.", + "HDR output": "Saída HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas em HDR: JPEG com mapa de ganho HDR (fica certo em qualquer lugar, realces mais brilhantes em telas HDR), AVIF em 10 bits, PNG e JPEG XL em 16 bits Rec. 2020 PQ; TIFF requer ponto flutuante de 32 bits", + "HDR TIFF needs 32-bit float.": "TIFF HDR requer ponto flutuante de 32 bits.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espaço de cor: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espaço de cor: Rec. 2020 PQ, 16 bits (PNG HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espaço de cor: Rec. 2020 PQ (JPEG XL HDR)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Visualizadores que não leem o bloco cICP do PNG mostram os valores PQ como sRGB: chapados e desbotados. Para uma imagem que fique certa em qualquer lugar, use JPEG (mapa de ganho)." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11651aaa0..d0c1d6ed6 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом" + "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + "{n} selected photos · {pages} pages": "Выбрано фото: {n} · страниц: {pages}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a84297a05..8b49cfdb7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2143,5 +2143,17 @@ "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", - "Adding to Apple Photos": "Добавление в Apple Фото" + "Adding to Apple Photos": "Добавление в Apple Фото", + "Contact Sheet PDF": "PDF контактного листа", + "Letter": "Letter", + "Filename captions": "Подписи с именем файла", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · текущие правки · вписано без обрезки", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "После подтверждения выберите, куда сохранить PDF. Печатайте PDF из программы просмотра.", + "HDR output": "Вывод HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, обработанные в HDR: JPEG с картой усиления HDR (выглядит правильно везде, на HDR-экранах светлые участки ярче), AVIF — 10 бит, PNG и JPEG XL — 16 бит Rec. 2020 PQ; для TIFF нужен 32-битный формат с плавающей точкой", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF нужен 32-битный формат с плавающей точкой.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Цветовое пространство: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Цветовое пространство: Rec. 2020 PQ, 16 бит (HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Цветовое пространство: Rec. 2020 PQ (HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Программы, не читающие блок cICP в PNG, показывают значения PQ как sRGB: плоско и блекло. Чтобы изображение везде выглядело правильно, используйте JPEG (карта усиления)." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 7aca485e0..e2b086687 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "не додано до Apple Фото: {e}", "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", - "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом" + "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + "{n} selected photos · {pages} pages": "Вибрано фото: {n} · сторінок: {pages}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 839de65f7..23fa50f64 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2145,5 +2145,17 @@ "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", - "Adding to Apple Photos": "Додавання до Apple Фото" + "Adding to Apple Photos": "Додавання до Apple Фото", + "Contact Sheet PDF": "PDF контактного аркуша", + "Letter": "Letter", + "Filename captions": "Підписи з назвою файлу", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · поточні правки · вписано без обрізання", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Після підтвердження виберіть, куди зберегти PDF. Друкуйте PDF із програми перегляду.", + "HDR output": "Вивід HDR", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, оброблені в HDR: JPEG із картою підсилення HDR (виглядає правильно всюди, на HDR-екранах світлі ділянки яскравіші), AVIF — 10 біт, PNG і JPEG XL — 16 біт Rec. 2020 PQ; для TIFF потрібен 32-бітний формат із рухомою комою", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF потрібен 32-бітний формат із рухомою комою.", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Колірний простір: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Колірний простір: Rec. 2020 PQ, 16 біт (HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Колірний простір: Rec. 2020 PQ (HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Програми, які не читають блок cICP у PNG, показують значення PQ як sRGB: пласко й вицвіло. Щоб зображення всюди виглядало правильно, використовуйте JPEG (карта підсилення)." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index bf27ca6b2..1d43e79a4 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起" + "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + "{n} selected photos · {pages} pages": "已选 {n} 张照片 · {pages} 页" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2b48b8e9b..2f8003f7b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1989,5 +1989,17 @@ "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", - "Adding to Apple Photos": "正在添加到 Apple 照片" + "Adding to Apple Photos": "正在添加到 Apple 照片", + "Contact Sheet PDF": "联系表 PDF", + "Letter": "Letter", + "Filename captions": "文件名标题", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 当前编辑 · 完整适合,不裁剪", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "确认后选择 PDF 的保存位置。请在查看器中打印 PDF。", + "HDR output": "HDR 输出", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 编辑的照片:JPEG 带 HDR 增益图(在任何地方都显示正确,在 HDR 显示器上高光更亮),AVIF 为 10 位,PNG 和 JPEG XL 为 16 位 Rec. 2020 PQ;TIFF 需要 32 位浮点", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位浮点。", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空间:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空间:Rec. 2020 PQ,16 位(HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空间:Rec. 2020 PQ(HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不读取 PNG cICP 块的查看器会把 PQ 值当作 sRGB 显示:平淡且发灰。要在任何地方都显示正确,请使用 JPEG(增益图)。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 37b2db2ba..7541c84a8 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -244,5 +244,6 @@ "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", - "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起" + "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + "{n} selected photos · {pages} pages": "已選取 {n} 張照片 · {pages} 頁" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0e81c6e92..9b49062b9 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1951,5 +1951,17 @@ "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", - "Adding to Apple Photos": "正在加入 Apple 照片" + "Adding to Apple Photos": "正在加入 Apple 照片", + "Contact Sheet PDF": "縮圖目錄 PDF", + "Letter": "Letter", + "Filename captions": "檔名說明", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 目前的編輯 · 完整符合,不裁切", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確認後選擇 PDF 的儲存位置。請在檢視器中列印 PDF。", + "HDR output": "HDR 輸出", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 編輯的照片:JPEG 附 HDR 增益圖(在任何地方都顯示正確,在 HDR 顯示器上亮部更亮),AVIF 為 10 位元,PNG 與 JPEG XL 為 16 位元 Rec. 2020 PQ;TIFF 需要 32 位元浮點", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位元浮點。", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空間:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空間:Rec. 2020 PQ,16 位元(HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空間:Rec. 2020 PQ(HDR JPEG XL)", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不讀取 PNG cICP 區塊的檢視器會把 PQ 值當作 sRGB 顯示:平淡且泛白。要在任何地方都顯示正確,請使用 JPEG(增益圖)。" } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..a56289a10 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1384,7 +1384,7 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: for (i, tok) in TOKENS.iter().enumerate() { let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( if tok.aliases.is_empty() { - "Insert at the cursor".to_string() + crate::i18n::tr("Insert at the cursor").to_string() } else { crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..67184040d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -679,7 +679,7 @@ impl LightcraftApp { } (None, true) => { self.ui.unsaved_seen = false; - self.toast(ctx, "Library saved"); + self.toast(ctx, crate::i18n::tr("Library saved")); } (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry (None, false) => {} @@ -1014,7 +1014,7 @@ impl LightcraftApp { if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) { - self.toast(ctx, "Updated the edits saved in the external editor"); + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); } } self.ui.was_focused = focused; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e0..70b267d83 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -524,7 +524,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re "compare.makeSelect" => crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } "view.back" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca9..4fb0fab70 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -632,19 +632,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); let n = app.session.targets(&json!({})).len(); - ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), "Letter"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); }); }); - ui.checkbox(&mut options.landscape, "Landscape"); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, "Filename captions"); - ui.label("150 dpi · sRGB · current edits · fit without cropping"); - ui.label("Choose a PDF destination after confirming. Print the PDF from your viewer."); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; @@ -705,20 +705,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.bit_depth = Some(bd); } if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"; - ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); + let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); } } if !rendered { } else if opts.format == F::Avif && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR AVIF)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)")).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) - .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR JPEG XL)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); } else if opts.format == F::Avif { ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..55e459480 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -368,9 +368,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, Some("done") => {} _ => { if let Some(url) = m["source"].as_str() { - let r = ui - .button("Open page") - .on_hover_text("Opens the model's own page in your browser, to read about it or get the file yourself."); + let r = ui.button("Open page").on_hover_text(crate::i18n::tr( + "Opens the model's own page in your browser, to read about it or get the file yourself.", + )); register(ui.ctx(), format!("faces:get:{id}"), r.rect); if r.clicked() { open_page(app, url); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 3c7261e20..817179e66 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -444,7 +444,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); let used = app.session.media.rendered.disk().map(|d| d.size()); row(ui, t, crate::i18n::tr("In use"), |ui| { - ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); + ui.label( + RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) + .color(t.text), + ); let r = ui.button(crate::i18n::tr("Clear Cache")); register(ui.ctx(), "button:settingsClearCache", r.rect); if r.clicked() { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..d5bf1fa7c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -405,7 +405,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { { let now = ctx.input(|i| i.time); app.ui.slideshow = Some((interval, now + interval, !paused)); - app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" }); + app.toast(ctx, crate::i18n::tr(if paused { "Slideshow resumed" } else { "Slideshow paused" })); continue; } // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, From 1a5c4665da63d72fc2415079573ed15ae11572a3 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Mon, 12 Oct 2026 10:57:55 -0400 Subject: [PATCH 058/315] Fit a DNG's tone to its embedded preview when the file defines none For a DNG with its own colour matrix, an identity or absent ProfileToneCurve, no LookTable and no ProfileGainTableMap, fit only the tone and chroma curves to the camera JPEG, with the file's colour model untouched (profile tables and embedded vignetting applied to the sensor proxy as the loader renders it). The fit is used only when the preview is at least 90% of the raw's long edge, shows the same picture (structure gate), passes the existing held-out acceptance, and does not put more of the photo near white than the plain render (highlight gate, at 2x proxy luminance). Otherwise the render is unchanged. Other formats and catalogued photos are untouched. Render cache version 62. --- crates/engine/src/camera_preview.rs | 297 ++++++++++++++++++++++++++-- crates/engine/src/files.rs | 4 +- crates/engine/src/media.rs | 2 +- 3 files changed, 288 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..6521d4a15 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -69,8 +69,26 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option<CameraLook>) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&lightcraft_develop::EmbeddedLens>) -> (Xy, CameraTransform, Option<CameraLook>) { + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, lens); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| (0..=32).all(|i| { let x = i as f32 / 32.0; (c.eval(x) - x).abs() < 0.005 })) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.look_table.is_none() + && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -112,7 +130,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -265,14 +283,151 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { }) } +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near +/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than +/// the plain render ([`clips_more`]). +fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> { + let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION)?; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return None; + } + if !structure_agrees(&sensor, &reference) { + return None; + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges })) + }); + let look = look?; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + if clips_more(&sensor, &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), &plain) { + return None; + } + Some(look) +} + +/// Whether the fitted tone map puts more of the photo's proxy pixels at or near white than the plain render does. +fn clips_more(sensor: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(CLIP_HEADROOM * luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let (a, b) = (near_white(fitted), near_white(plain)); + a > b +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} + +/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a +/// small thumbnail is not fitted. +const MIN_PREVIEW_FRACTION: f64 = 0.9; + +/// Luminance headroom of the highlight gate: the 96 px proxy averages small bright spots away, so [`clips_more`] +/// looks at the proxy at this multiple of its luminance (chosen on the CC0 and user DNGs: 1 passes a fit that clips +/// the brightest 0.04% of a photo, 2 refuses it while keeping most fits). +const CLIP_HEADROOM: f32 = 2.0; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec<f64> { + let l: Vec<f64> = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> { +fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize, min_fraction: f64) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; - let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) { + return None; + } + let mut reference = decoded.to_working(); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { return None; } @@ -474,7 +629,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3 return None; } let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0)?; let to_camera = transform.matrix.inverse()?; // clipped sensor pixels don't record the scene's colour (see `fit_look`) let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); @@ -912,7 +1067,7 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0)?; if !monochrome(&reference) { return None; } @@ -1748,7 +1903,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -1807,13 +1962,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -1836,7 +1991,7 @@ mod tests { let own = lightcraft_raw::color::has_matrix(&raw.color); let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, None); let spectral = !own && fitted.is_none() && covered; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2224,7 +2379,7 @@ mod tests { let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -2810,4 +2965,122 @@ mod tests { reference.data.iter_mut().for_each(|p| *p = [0.4; 3]); assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG"); } + + /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. + fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) + } + + /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a + /// flat and a differently sized preview. + #[test] + fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); + } + + /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that + /// renders mid-grey brighter than the default tone map is followed. + #[test] + fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false }).expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &fitted, &fitted), "a tone never clips more than itself"); + } + + /// The highlight gate compares the share of near-white pixels of two tone maps, in one direction only. + #[test] + fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + assert!(clips_more(&sensor, &bright, &dark) != clips_more(&sensor, &dark, &bright)); + assert!(!clips_more(&sensor, &bright, &bright)); + } + + /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. + #[test] + fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); + } + + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the + /// identity, no look table; and no other raw format. + #[test] + fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{ToneCurve, HsvTable}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..afed9b77d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -403,7 +403,7 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -472,7 +472,7 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)).then(|| { let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; From f89b70e1c11fb8df0c05cf6d0332aab7a53551d2 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Mon, 12 Oct 2026 11:36:22 -0400 Subject: [PATCH 059/315] Tone-only DNG fit: train on dark pixels, compare highlights with the camera preview too --- crates/engine/src/camera_preview.rs | 105 ++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 6521d4a15..86d6f5673 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -289,7 +289,14 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { /// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than /// the plain render ([`clips_more`]). fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> { - let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION)?; + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION) else { return refuse("preview") }; // As the loader renders it: the profile's tables around the baseline exposure. let xy = lightcraft_raw::color::as_shot_white_xy(raw); if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { @@ -298,36 +305,42 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: } correct_vignetting(&mut sensor, lens, raw.orientation); if (sensor.width, sensor.height) != (reference.width, reference.height) { - return None; + return refuse("preview"); } if !structure_agrees(&sensor, &reference) { - return None; + return refuse("structure"); } let unclipped = without_clipped(&sensor, &clipped); let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges })) + [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) }); - let look = look?; + let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), None => ToneMap::new(0.0, 0.0, 0.0), }; - if clips_more(&sensor, &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), &plain) { - return None; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); } Some(look) } -/// Whether the fitted tone map puts more of the photo's proxy pixels at or near white than the plain render does. -fn clips_more(sensor: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { let near_white = |tone: &ToneMap| { - let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(CLIP_HEADROOM * luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); n as f64 / sensor.data.len().max(1) as f64 }; - let (a, b) = (near_white(fitted), near_white(plain)); - a > b + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE } /// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the @@ -349,10 +362,14 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb /// small thumbnail is not fitted. const MIN_PREVIEW_FRACTION: f64 = 0.9; -/// Luminance headroom of the highlight gate: the 96 px proxy averages small bright spots away, so [`clips_more`] -/// looks at the proxy at this multiple of its luminance (chosen on the CC0 and user DNGs: 1 passes a fit that clips -/// the brightest 0.04% of a photo, 2 refuses it while keeping most fits). -const CLIP_HEADROOM: f32 = 2.0; +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +const CLIP_TOLERANCE: f64 = 0.001; /// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. const MIN_STRUCTURE: f64 = 0.85; @@ -633,7 +650,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3 let to_camera = transform.matrix.inverse()?; // clipped sensor pixels don't record the scene's colour (see `fit_look`) let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); - let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?; + let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) } @@ -705,7 +722,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 { /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). type Pairs = Vec<([f64; 3], [f64; 3])>; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -724,7 +741,8 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) { + let (ylo, clo) = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !output.iter().all(|v| *v > clo && *v < 0.98) || !(ylo..0.85).contains(&y) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -805,6 +823,8 @@ struct Attempt { tone: ToneFit, profile: bool, away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + dark: bool, } /// The photo's look and the attempt that produced it: its own matrix (with and without a @@ -821,7 +841,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges }; + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) }) }) @@ -830,7 +850,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) } fn fit_pairs_on( @@ -839,10 +859,11 @@ fn fit_pairs_on( colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>, tone_fit: ToneFit, + dark: bool, ) -> Option<CameraLook> { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; let candidates = match colour { Some(given) => vec![given], None => { @@ -988,7 +1009,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> { fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> { // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1098,7 +1119,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> { // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?; + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; let related = rank_correlation_ties(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1699,7 +1720,7 @@ mod tests { let mut pool = Vec::new(); for seed in 0..4 { let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -2645,7 +2666,7 @@ mod tests { assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false }); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3017,17 +3038,18 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false }).expect("tone fitted"); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }).expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let target = 1.0 - (-2.5f32 * 0.18).exp(); assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); - assert!(!clips_more(&sensor, &fitted, &fitted), "a tone never clips more than itself"); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); } - /// The highlight gate compares the share of near-white pixels of two tone maps, in one direction only. + /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the + /// camera preview's, in one direction only: clipping what the camera clips is not refused. #[test] fn dng_highlight_gate_compares_near_white_shares() { let mut sensor = Rgb32f::new(32, 32); @@ -3035,8 +3057,29 @@ mod tests { *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; } let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); - assert!(clips_more(&sensor, &bright, &dark) != clips_more(&sensor, &dark, &bright)); - assert!(!clips_more(&sensor, &bright, &bright)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); + } + + /// A dark scene's shadows below the usual training floor still reach the tone-only fit. + #[test] + fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); } /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. From 0391362f2fef3db6cd659fde469eb2995dd2c7b3 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Mon, 12 Oct 2026 12:48:47 -0400 Subject: [PATCH 060/315] Format the DNG tone fit with rustfmt --- crates/engine/src/camera_preview.rs | 30 ++++++++++++++++++++++------- crates/engine/src/files.rs | 12 +++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 86d6f5673..39b0d1b1c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -69,7 +69,11 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&lightcraft_develop::EmbeddedLens>) -> (Xy, CameraTransform, Option<CameraLook>) { +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option<CameraLook>) { let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, lens); @@ -80,14 +84,22 @@ pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&li /// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { - curve.is_none_or(|c| (0..=32).all(|i| { let x = i as f32 / 32.0; (c.eval(x) - x).abs() < 0.005 })) + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) } /// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no /// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { let p = &raw.color.profile; - raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.look_table.is_none() + raw.format == RawFormat::Dng + && !t.matrix_is_fallback + && identity_tone_curve(p.tone_curve.as_ref()) + && p.look_table.is_none() && p.gain_table_map.is_none() } @@ -314,7 +326,9 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); let look = TONE_FITS.iter().find_map(|&tone| { - [false, true].into_iter().find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + [false, true] + .into_iter() + .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) }); let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { @@ -441,7 +455,8 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?; let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any - if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) { + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { return None; } let mut reference = decoded.to_working(); @@ -3038,7 +3053,8 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }).expect("tone fitted"); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) + .expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); @@ -3105,7 +3121,7 @@ mod tests { /// identity, no look table; and no other raw format. #[test] fn dng_tone_gates_follow_the_files_own_tags() { - use lightcraft_raw::profile::{ToneCurve, HsvTable}; + use lightcraft_raw::profile::{HsvTable, ToneCurve}; let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index afed9b77d..d1c0e2747 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -403,7 +403,8 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = + rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -472,10 +473,11 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)).then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); let (temp, tint) = scale.as_shot(xy); From 6246ea67a46c7186bd3a798648006b385d01c588 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:55:01 +0200 Subject: [PATCH 061/315] LRC-SS-PLAYBACK: during a slideshow the toast shows at the top, clear of the playback controls (toasts take the pointer since #7ccff3b9) --- crates/ui-egui/src/panels/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ef7f13111..a7eff832c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -92,7 +92,9 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { .order(egui::Order::Foreground) .movable(false) .pivot(Align2::CENTER_CENTER) - .fixed_pos(pos2(canvas.center().x, canvas.bottom() - 60.0)) + // a slideshow's playback controls (and the template buttons) sit at the bottom: the toast, + // which takes the pointer (its text can be selected), goes to the top there + .fixed_pos(pos2(canvas.center().x, if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 })) .show(ctx, |ui| { ui.set_max_width(wrap); egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show( From 21b417fe3c12e5e995a7b394d36b4bbceddcbd3f Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:04:13 +0200 Subject: [PATCH 062/315] LRC-SS-PLAYBACK: the toast also goes to the top while a dialog is open, clear of its buttons --- crates/ui-egui/src/panels/mod.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a7eff832c..cb19353e8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -92,9 +92,12 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { .order(egui::Order::Foreground) .movable(false) .pivot(Align2::CENTER_CENTER) - // a slideshow's playback controls (and the template buttons) sit at the bottom: the toast, - // which takes the pointer (its text can be selected), goes to the top there - .fixed_pos(pos2(canvas.center().x, if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 })) + // the toast takes the pointer (its text can be selected), so it must not sit on buttons: a + // slideshow's playback controls and a dialog's buttons are at the bottom, so it goes to the top there + .fixed_pos(pos2( + canvas.center().x, + if app.ui.slideshow.is_some() || app.ui.dialog.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 }, + )) .show(ctx, |ui| { ui.set_max_width(wrap); egui::Frame::NONE.fill(background).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::symmetric(20, 11)).show( From 146cb7b975fa0b542018e20924448e6a9beda226 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 13 Oct 2026 03:57:03 +0000 Subject: [PATCH 063/315] LR-MERGE-HDR: the merge no longer depends on the order the frames come in The same bracket merged in another order gave a different result: on real ARW brackets, the app's selection order and the CLI's file order differed on 0.6 % of the pixels (up to 102 levels), while the same order gave byte-identical files. Alignment seeds were the frames' input positions, and sums, deghost masks and brightness ties followed them too. merge_hdr now puts the frames in brightness order first (ties by median, then a content fingerprint, never by position), runs everything in that order, and returns the per-frame results (EV, alignment, reference index) in the caller's order. Test: every permutation of a bracket gives the same radiance and deghost overlay bit for bit, and the same per-frame results (fails before: "the radiance differs"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/merge/src/hdr.rs | 47 ++++++++++++++++++++++++++++++++------- crates/merge/tests/hdr.rs | 25 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..aff017892 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -1,8 +1,9 @@ //! HDR merge of exposure brackets. //! //! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median -//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's -//! default brightness come from it). +//! luminance without EXIF; ties by content); the middle one is the reference (geometry, colour +//! and the output's default brightness come from it). Everything below runs in that order, so +//! the result is the same whatever order the frames come in. //! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled //! into its geometry. //! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is @@ -125,9 +126,25 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(), _ => medians.iter().map(|m| (*m as f64).log2()).collect(), }; - let mut order: Vec<usize> = (0..n).collect(); - order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b))); - let reference = order[n / 2]; + // ties (equal brightness) by content, never by position + let fingerprint: Vec<f64> = + frames.par_iter().map(|f| f.image.data.iter().step_by(997).map(|p| p[0] as f64 + 2.0 * p[1] as f64 + 3.0 * p[2] as f64).sum()).collect(); + let mut input: Vec<usize> = (0..n).collect(); + input.sort_by(|&a, &b| { + brightness[a].total_cmp(&brightness[b]).then(medians[a].total_cmp(&medians[b])).then(fingerprint[a].total_cmp(&fingerprint[b])) + }); + // From here on frames are in brightness order (`input[p]` is the caller's index of position `p`): alignment seeds, + // sums and deghost masks no longer depend on the order the caller passed. Per-frame results go back at the end. + let mut slots: Vec<Option<Frame>> = frames.into_iter().map(Some).collect(); + let mut frames: Vec<Frame> = input.iter().filter_map(|&i| slots.get_mut(i).and_then(Option::take)).collect(); + if frames.len() != n { + return Err(MergeError::Mismatch("HDR merge lost a frame while ordering them".into())); + } + let exif: Option<Vec<f64>> = exif.map(|e| input.iter().map(|&i| e[i]).collect()); + let medians: Vec<f32> = input.iter().map(|&i| medians[i]).collect(); + let brightness: Vec<f64> = input.iter().map(|&i| brightness[i]).collect(); + let order: Vec<usize> = (0..n).collect(); + let reference = n / 2; // 2. alignment let mut alignments = vec![Alignment::identity(); n]; @@ -264,14 +281,28 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) } None => Plane::new(0, 0), }; + // per-frame results in the caller's order + let mut ev_in = vec![0.0; n]; + let mut alignments_in = vec![Alignment::identity(); n]; + for (p, &i) in input.iter().enumerate() { + ev_in[i] = ev[p]; + alignments_in[i] = alignments[p].clone(); + } + let ev_exif = ev_exif.map(|e: Vec<f64>| { + let mut v = vec![0.0; n]; + for (p, &i) in input.iter().enumerate() { + v[i] = e[p]; + } + v + }); let rf = &frames[reference]; Ok(HdrResult { radiance: out, ghost, - reference, - ev, + reference: input[reference], + ev: ev_in, ev_exif, - alignments, + alignments: alignments_in, color: rf.color.clone(), raw: rf.raw, orientation: rf.orientation, diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index 68b9ba4b9..abfc471ec 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -125,6 +125,31 @@ fn bracket_merge_recovers_radiance_and_alignment() { assert!(rmed < 0.05, "recovered highlights: median log error {rmed}"); } +/// The result doesn't depend on the order the frames come in: every permutation of a bracket gives the same radiance and +/// deghost overlay, bit for bit, and the same per-frame results for each frame. (Real brackets merged in the app's +/// selection order and in the CLI's file order differed on 0.6 % of their pixels: alignment seeds followed the input +/// positions.) +#[test] +fn merge_does_not_depend_on_the_frame_order() { + let t = truth(); + let shifts = [(3.25f32, -2.0f32), (0.0, 0.0), (-4.0, 1.5)]; + let ks = [0.25f32, 1.0, 4.0]; + let shot: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], shifts[i].0, shifts[i].1, i as u64 + 1)).collect(); + let opts = HdrOptions { align: true, deghost: Deghost::Medium }; + let base = merge_hdr(shot.clone(), &opts, &no_progress).unwrap(); + assert_eq!(base.reference, 1); + for perm in [[2usize, 0, 1], [1, 2, 0], [0, 2, 1]] { + let r = merge_hdr(perm.iter().map(|&i| shot[i].clone()).collect(), &opts, &no_progress).unwrap(); + assert!(r.radiance.data == base.radiance.data, "{perm:?}: the radiance differs"); + assert!(r.ghost.data == base.ghost.data, "{perm:?}: the deghost overlay differs"); + assert_eq!(perm[r.reference], base.reference, "{perm:?}"); + for (p, &i) in perm.iter().enumerate() { + assert_eq!(r.ev[p], base.ev[i], "{perm:?}"); + assert_eq!(r.alignments[p].h, base.alignments[i].h, "{perm:?}"); + } + } +} + #[test] fn deghost_removes_a_moving_object() { let t = truth(); From 64f354fca37bc10b2dd9473aa4d3f35f17d65a9d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 13 Oct 2026 06:37:11 +0000 Subject: [PATCH 064/315] LR-MERGE-HDR: merged DNGs declare the white balance their source shows (follow-up to #1123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1123 gives HDR and panorama merges of raws without colour matrices the colour they open with, but writes ColorMatrix1 from the forward matrix, so the as-shot white the DNG declares lands wherever the fitted look and the generic camera model take it. On real ILCE-7M3 brackets the HDRs opened at As Shot 6113–7307 K / +115 (Duv +0.038) where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the ARW at the same Temp/Tint). The render at As Shot was right (0.4–0.8). The colour matrices now declare the white balance the source shows, decided by files::WbScaleOf (new dng_white): the camera's measured matrices when they give the source's scale (lightcraft_raw::spectral; the DNG then reads every white as the source does), else a matrix under which the as-shot neutral is the white the scale shows (Sony's presets' reading, 6500 K / 0 on the relative scale, the file's own white otherwise). Forward matrix and neutral are unchanged, so is the render at As Shot. Measured on the same brackets with this change on a fork build: As Shot 4813 / 5268 / 3932 K, +8 / +8 / +7 as the ARWs (declared white Duv +0.003); same Temp/Tint within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for the largest (5268 → 3200 K, 3932 → 6500 K: the ARW adapts its look with Bradford, the DNG rebalances in camera space). Tests: a merged DNG shows its source's As Shot on all four scales (measured matrices, presets, relative, own); with a look that tints white the DNG declares the shown white, gives the measured model's multipliers at other whites and renders at As Shot as the source does. Both fail on main (ILCE-7M3 case: (7342, 115) for (5268, 8)). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/engine/src/camera_preview.rs | 87 ++++++++++++++++++++++++++--- crates/engine/src/files.rs | 67 +++++++++++++++++++++- docs/camera-preview-colour.md | 20 +++++-- docs/parity-checklist.md | 2 +- docs/whats-new.md | 6 ++ 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..cc278ffcf 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -119,8 +119,11 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C if lightcraft_raw::color::has_matrix(&raw.color) { return None; } + // the white the source shows, decided before `starting_colour` may fill in measured matrices + let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw)); let (_, t, look) = starting_colour(raw, bytes); - dng_colour(&raw.color, &t, look.as_ref()) + dng_colour(&raw.color, &white, &t, look.as_ref()) } /// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward @@ -129,8 +132,13 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C /// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two /// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo /// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]), +/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices +/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges +/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green. +/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them. /// `None` when the white balance or the matrix can't give tags. -fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> { +pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> { let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes // into the baseline exposure @@ -143,11 +151,19 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); let wb = t.wb.map(f64::from); let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); - let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2])); + let (illuminant, color_matrix) = match declared { + crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), + crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), + crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), + }; + if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + return None; + } Some(ColorData { - illuminant: [21, 0], - color_matrix: [Some(camera_to_xyz.inverse()?), None], - forward_matrix: [Some(forward), None], + illuminant, + color_matrix, + // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white + forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], camera_calibration: [None, None], analog_balance: None, as_shot_neutral: Some(neutral), @@ -163,6 +179,17 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) }) } +/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per +/// channel. `None` when the model gives no positive neutral there. +fn declaring(xy: Xy, neutral: [f64; 3]) -> Option<Mat3> { + let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); + let n = g.apply(xy.to_xyz()); + if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { + return None; + } + Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) +} + /// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the /// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> { @@ -2379,7 +2406,7 @@ mod tests { let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); for look in [Some(&look), None] { - let dng = dng_colour(&ColorData::default(), &t, look).unwrap(); + let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); for i in 0..3 { assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); @@ -2404,6 +2431,52 @@ mod tests { } } + /// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white + /// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their + /// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. + #[test] + fn dng_colour_declares_the_white_balance_the_source_shows() { + use crate::files::DngWhite; + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; + let tags = ColorData::default(); + let as_shot = [2.5039f32, 1.0, 1.5078]; + let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); + let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); + let tint = look.matrix.apply([1.0; 3]); + assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); + let renders_as_shot = |dng: &ColorData| { + let r = camera_transform_of(dng, as_shot_white_of(dng, None)); + assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); + let m = look.matrix.mul(&t.matrix); + for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { + let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); + let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); + } + }; + // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) + for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { + let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); + renders_as_shot(&dng); + } + // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); + let mut model = tags.clone(); + assert!(camera.fill(&mut model)); + let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); + let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); + for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { + let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); + } + renders_as_shot(&dng); + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..a83a8d1a1 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -513,17 +513,28 @@ fn load_bytes_now( /// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one /// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the /// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). -struct WbScaleOf { +pub(crate) struct WbScaleOf { own_matrix: bool, relative: bool, /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. legacy_relative: bool, /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. scale_white: Option<lightcraft_color::Xy>, + /// The camera's measured matrices, when they give the scale. + camera: Option<&'static lightcraft_raw::spectral::Camera>, +} + +/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does +/// ([`WbScaleOf::dng_white`]). +pub(crate) enum DngWhite { + /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. + Matrices(Box<lightcraft_raw::ColorData>), + /// The white the source's scale shows at As Shot. + At(lightcraft_color::Xy), } impl WbScaleOf { - fn new( + pub(crate) fn new( format: lightcraft_raw::RawFormat, color: &lightcraft_raw::ColorData, wb_multipliers: Option<[f32; 3]>, @@ -540,7 +551,23 @@ impl WbScaleOf { None => None, }; let covered = camera.is_some() || presets; - WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white } + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } + } + + /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the + /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' + /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). + pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { + if let Some(camera) = self.camera { + let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; + if camera.fill(&mut c) { + return DngWhite::Matrices(Box::new(c)); + } + } + if self.relative { + return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + } + DngWhite::At(self.scale_white.unwrap_or(own)) } /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the @@ -1096,6 +1123,40 @@ mod tests { assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); } + /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source + /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative + /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets + /// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. + #[test] + fn a_merged_dng_shows_the_white_balance_its_source_shows() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; + let cases = [ + (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), + (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), + (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), + (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), + ]; + for (format, color, mul, meta) in cases { + let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); + let source = WbScaleOf::new(format, &color, Some(mul), &meta); + let t = lightcraft_raw::color::camera_transform_of(&color, own); + let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); + let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); + let shown = source.as_shot(own); + assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); + } + } + /// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> { let head = 160; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..5b231ce14 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -184,10 +184,22 @@ A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera tags. A raw without colour matrices of its own has none to give, so its merge used to render with no camera colour: an HDR merge of CR2s came out green and dark (issue #965). Each source now goes through `starting_colour` as when it is opened alone, and `starting_colour_tags` writes the result as DNG tags: one forward matrix (from the look's matrix on -top of the camera transform) with a colour matrix and `AsShotNeutral` that agree with it at the as-shot white, the -look's brightness of white as baseline exposure, its hue/saturation table as `ProfileHueSatMap` and its tone curve as -`ProfileToneCurve`. Raws with their own matrices (DNG) keep their tags. Without a look the tags give the colour the raw -falls back to (spectral matrices or the neutral fallback). +top of the camera transform) with `AsShotNeutral`, the look's brightness of white as baseline exposure, its +hue/saturation table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Raws with their own matrices (DNG) +keep their tags. Without a look the tags give the colour the raw falls back to (spectral matrices or the neutral +fallback). + +The colour matrices declare the white balance the source shows (`files::WbScaleOf::dng_white`), so the DNG opens at the +source's As Shot temperature and tint and a white-balance edit starts from there: the camera's measured matrices +(`lightcraft_raw::spectral`) when they give the source's scale, which reads every white as the source does; else a +matrix under which the as-shot neutral is the white the source's scale shows (Sony's presets' reading, 6500 K / 0 on +the relative scale, the file's own white otherwise). The forward matrix is used only with the as-shot neutral, so the +render at As Shot doesn't depend on them. A colour matrix derived from the forward matrix, as first written, declared +wherever the look and the generic camera model put the white: merges of real ILCE-7M3 brackets opened at 6113–7307 K / ++115 where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the +ARW at the same Temp/Tint). With the measured matrices they open at the ARWs' As Shot, and the same Temp/Tint renders +within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for the largest (5268 → 3200 K, 3932 → 6500 K): the ARW +adapts its fitted look with Bradford, the DNG rebalances in camera space. The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0ab638f35..e2c01f976 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -346,7 +346,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965, and the white balance they show (`a_merged_dng_shows_the_white_balance_its_source_shows`) | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 81d9ad7fc..dc9c32ec4 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-13 +### HDR merges keep the white balance +- An HDR merged from raws that LightCraft colours from the camera's JPEG (CR2, NEF, ARW…) now opens at the same + As Shot temperature and tint as its photos, and changing the white balance no longer turns it green. Sony ARW + merges used to open at about 7000 K / +115 where the photos show 4000–5300 K / +8. HDRs merged before this keep + their old white balance; merge them again. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From aeb21ac8136dea00ad04940df19232f4e5ad4514 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Sun, 11 Oct 2026 21:18:10 +0200 Subject: [PATCH 065/315] UI: align Local folder rows, paint dock gaps, bottom-align the tool strip - Local folders in the sidebar are drawn at their tree indent like Albums and Folders: the icon lines up with the rows above and the disclosure triangle sits in the gutter left of it; children nest by 16 pt (#1097). - The central panel that hosts the dock is filled with chrome, so the gaps the dock leaves between panes no longer show the dark clear colour (#1098). - The tool strip's bottom group is two buttons and one gap tall, so Info ends at the bottom margin as Presets starts at the top one (#1099). Each fix has a headless regression test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/panels/left.rs | 32 +++++++++++-- crates/ui-egui/src/panels/strip.rs | 48 +++++++++++++++---- .../ui-egui/src/tests_docking_acceptance.rs | 16 +++++++ 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..c6067f4fe 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1322,7 +1322,9 @@ impl LightcraftApp { panels::topbar::show(self, ui); panels::library_problem::banner(self, ui); panels::strip::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui)); + // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour + let chrome = theme::Tokens::get(&ctx).chrome; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); self.lights_out(&ctx); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 78c19b7e9..9dc192e14 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1059,7 +1059,9 @@ fn folder_tree( ui.data_mut(|d| d.insert_temp(revealed_id, target)); } } - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); + // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the + // icon lines up with the rows above + let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); if !tr.clicked() && resp.clicked() @@ -1112,9 +1114,9 @@ fn folder_tree( let _ = f(path); } }); - if open && indent < 12.0 * 8.0 { + if open && indent < 16.0 * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + 12.0, current, reveal, false); + folder_tree(app, ui, place, &n, &p, indent + 16.0, current, reveal, false); } } } @@ -2093,4 +2095,28 @@ mod tests { let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]); assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); } + + #[test] + fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { + use crate::Services; + use crate::headless::HeadlessView; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + let path = "/lightcraft-test/Pictures"; + let mut widgets = vec![]; + for frame in 0..3 { + let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| { + super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); + }); + widgets = crate::widgets::take_registry(&view.ctx); + } + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; + let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}"))); + assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does"); + let triangle = rect(&format!("folderToggle:{path}")); + // the icon is 16 points wide and ends 8 points before the name + assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}"); + } } diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs index ae1d6e557..164204b86 100644 --- a/crates/ui-egui/src/panels/strip.rs +++ b/crates/ui-egui/src/panels/strip.rs @@ -9,6 +9,11 @@ use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::icon_button; +/// Height of one tool button. +const BUTTON_H: f32 = 40.0; +/// Space between tool buttons. +const GAP: f32 = 6.0; + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::right("tool_strip") @@ -23,8 +28,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { let has_photo = app.session.active().is_some(); ui.vertical_centered(|ui| { - ui.spacing_mut().item_spacing.y = 6.0; - let sz = vec2(t.strip_w, 40.0); + ui.spacing_mut().item_spacing.y = GAP; + let sz = vec2(t.strip_w, BUTTON_H); if icon_button(ui, "presets", Icon::Presets, sz, app.ui.presets, has_photo, "Presets (Shift+P)").clicked() { let _ = app.run("panel.presets", json!({})); } @@ -54,14 +59,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.dialog = Some(crate::state::Dialog::About); } }); - // bottom icons + // bottom icons: two buttons and one gap tall, so Info ends at the bottom margin as + // Presets starts at the top one let bottom = ui.max_rect().bottom(); - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), bottom - 96.0), ui.max_rect().right_bottom())), - ); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left(), bottom - (2.0 * BUTTON_H + GAP)), + ui.max_rect().right_bottom(), + ))); child.vertical_centered(|ui| { - let sz = vec2(t.strip_w, 40.0); + ui.spacing_mut().item_spacing.y = GAP; + let sz = vec2(t.strip_w, BUTTON_H); if icon_button(ui, "keywords", Icon::Tag, sz, app.ui.right == RightPanel::Keywords, has_photo, "Keywords (K)").clicked() { let _ = app.run("panel.keywords", json!({})); } @@ -76,3 +84,27 @@ fn separator(ui: &mut egui::Ui, t: &Tokens) { let (r, _) = ui.allocate_exact_size(vec2(t.strip_w - 16.0, 1.0), egui::Sense::hover()); ui.painter().rect_filled(r, 0.0, t.button_border.gamma_multiply(0.7)); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use crate::headless::HeadlessView; + + #[test] + fn info_ends_as_far_above_the_bottom_as_presets_starts_below_the_top() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + let mut widgets = vec![]; + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(300.0, 700.0), 1.0, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| show(&mut app, ui)); + widgets = crate::widgets::take_registry(&view.ctx); + } + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; + let (presets, keywords, info) = (rect("icon:presets"), rect("icon:keywords"), rect("icon:info")); + let (top, bottom) = (presets.top(), 700.0 - info.bottom()); + assert!((top - bottom).abs() < 0.5, "Presets starts {top} below the top, Info ends {bottom} above the bottom"); + assert_eq!(info.top() - keywords.bottom(), GAP, "the bottom buttons are spaced like the top ones"); + } +} diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 0e3c499a7..1dc97892e 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -21,6 +21,22 @@ fn dock(h: &mut Headless, p: serde_json::Value) { fn rect(h: &Headless, id: &str) -> egui::Rect { h.app.widgets.iter().find(|(key, _)| key == id).unwrap().1 } + +#[test] +fn the_gaps_between_dock_panes_show_chrome_not_the_dark_clear_colour() { + let mut h = app(); + let image = h.snapshot(std::time::Duration::from_secs(5)); + let pixel = |p: egui::Pos2| image.pixels[p.y as usize * image.size[0] + p.x as usize]; + // each panel's own chrome just inside its edge stroke, and the dock's gap just outside it + let right = rect(&h, "panel:right_panel"); + let left = rect(&h, "panel:left_panel"); + for (panel, inside, gap) in [ + ("Tools", egui::pos2(right.left() + 3.0, right.center().y), egui::pos2(right.left() - 2.0, right.center().y)), + ("Library", egui::pos2(left.right() - 3.0, left.center().y), egui::pos2(left.right() + 2.0, left.center().y)), + ] { + assert_eq!(pixel(gap), pixel(inside), "the gap beside {panel} at {gap:?} is not chrome"); + } +} fn release(h: &mut Headless, pos: egui::Pos2) { h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); From f4b7175429c8d1419487a76da1afdd751afe7e4a Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:40:45 +0300 Subject: [PATCH 066/315] LR-IMP-FORMATS: property tests for the CRX decoder through the public decode path crates/raw/tests/crx_properties.rs drives lightcraft_raw::decode with synthetic CR3 containers built around CRX streams that the test encodes itself: reference entropy coders for the MED, Rice, MELCODE and QP-map layouts, and the exact algebraic inverse of one 5/3 lifting line. The tile-halo coefficients are free parameters of that inverse, so synthesis must reconstruct any encoded plane exactly. Tests only; no decoder change. - Wavelet round trip: random planes decode exactly across odd plane sizes and multi-tile halo configurations (eight geometries, arbitrary halo values). - Quantization: every uniform quant value 4..=44 and every adaptive QP value 131..=168 dequantizes within the 14-bit range. Overshoot is clamped, and an in-range plane lands exactly on level * step, so the step sequence itself is observable. Quant 3 and 45 and QP 130 and 169 are rejected by decode and probe_info alike. Random QP maps, including odd map heights, with random per-band base and gain stay in range. - Lossless samples outside the bit depth are Corrupt errors instead of being clamped; in-range planes decode exactly. - Adversarial entropy payloads planted in well-formed lossless, C-RAW, version 0x200 and QP-map streams either error or decode deterministically and never panic. A QP Rice prefix longer than the 23-zero escape is Corrupt. - Random IAD1 geometry either decodes with the active area inside the sensor and the crop inside the active area, or is rejected. - Decoding gives identical results across repeated and four-way parallel invocations. - Odd QP-map heights decode: the last QP row has no partner and is used alone, and QP values are averaged before the step lookup. Large QP jumps take the escape code. The case budget is proptest's default (256 per property); set PROPTEST_CASES to change it. --- crates/raw/tests/crx_properties.rs | 1048 ++++++++++++++++++++++++++++ 1 file changed, 1048 insertions(+) create mode 100644 crates/raw/tests/crx_properties.rs diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/crx_properties.rs new file mode 100644 index 000000000..b2bab66fb --- /dev/null +++ b/crates/raw/tests/crx_properties.rs @@ -0,0 +1,1048 @@ +//! Property-based invariants of the Canon CRX decoder, exercised through the public decode path. +//! +//! `vendor/crx` and the 5/3 synthesis in `vendor/crx_wavelet` are `pub(crate)`, so every property here +//! wraps a synthetic CRX stream in a minimal CR3 container (ftyp + moov + a CRAW track with CMP1/CDI1) +//! and calls [`lightcraft_raw::decode`]. The CRX entropy streams are produced by reference encoders in +//! this file that mirror the documented coders (MED prediction, signed mapping, adaptive Rice, the +//! MELCODE zero run, the QP map predictor — sources cited in `crates/raw/src/vendor/crx.rs`). +//! +//! Wavelet roundtrip: the analysis side is this file's exact algebraic inverse of one inverse-lifting +//! line, so the property tests the crate's own synthesis. It is exact by construction because the +//! tile-edge halo high-pass coefficients are free parameters of the encoding — synthesis absorbs any +//! stored value through the neighbouring low-pass coefficient — so random planes of every reachable +//! parity (odd plane sizes via even tile sizes ≡ 2 mod 4) and multi-tile halo configuration must decode +//! back to exactly the encoded sensor samples. +//! +//! Properties: +//! 1. C-RAW wavelet roundtrip is exact across sizes, parities and tile halos. +//! 2. Quantization: every supported uniform quant value (4–44) and adaptive QP value (131–168) +//! dequantizes within the CMP1 bit depth — C-RAW clamps the overshoot, lossless rejects it — and +//! the values just outside those bands (quant 3 and 45, QP 130 and 169) are rejected. +//! 3. Rice/Golomb bounds: adversarial entropy prefixes, in the plane bands and in the QP map, error +//! out, never panic, and stay deterministic. +//! 4. IAD1 geometry: `active_area ⊆ sensor dims` and `crop ⊆ active_area` hold or the file is rejected. +//! 5. Decoding is deterministic across repeated and parallel invocations (rayon plane decode). +//! 6. Odd QP-map heights decode: the last QP row has no partner row and is used alone. +//! +//! The case budget is proptest's default (256 per property); the `PROPTEST_CASES` environment variable +//! overrides it. + +use lightcraft_raw::{RawData, RawError, RawFormat, RawImage, decode, probe, probe_info}; +use proptest::prelude::*; + +// --- reference entropy coders (mirrors of the decoder's documented bitstream grammar) --- + +/// MELCODE run-index table (crx.rs `RUN_EXPONENT`). +const RUN_EXPONENT: [u8; 32] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + +/// MSB-first bit writer; `into_bytes` zero-pads the final byte (the decoder's `finish` allows ≤ 7 zero tail bits). +#[derive(Default)] +struct BitWriter { + bytes: Vec<u8>, + filled: usize, +} + +impl BitWriter { + fn put(&mut self, value: u32, count: u32) { + for shift in (0..count).rev() { + if self.filled.is_multiple_of(8) { + self.bytes.push(0); + } + if value.checked_shr(shift).unwrap_or(0) & 1 == 1 { + let byte = self.bytes.last_mut().unwrap(); + *byte |= 0x80 >> (self.filled % 8); + } + self.filled += 1; + } + } + + fn into_bytes(self) -> Vec<u8> { + self.bytes + } +} + +/// Adaptive Rice code: ≤ 40-zero prefix, otherwise the 41-zero escape with a 21-bit literal (crx.rs `Bits::rice`). +fn put_rice(bits: &mut BitWriter, k: u32, symbol: u32) { + let zeros = symbol >> k; + if zeros <= 40 { + bits.put(0, zeros); + bits.put(1, 1); + bits.put(symbol & ((1 << k) - 1), k); + } else { + assert!(symbol < 1 << 21, "reference encoder literals are bounded by the plane precision"); + bits.put(0, 41); + bits.put(1, 1); + bits.put(symbol & 0x001f_ffff, 21); + } +} + +/// QP Rice code (crx.rs `qp_rice`), a limited-length code: a quotient below 23 is a plain Rice code, anything +/// larger is 23 zeros, the terminating 1 and the whole symbol in 8 bits. +fn put_qp_rice(bits: &mut BitWriter, k: u32, symbol: u32) { + let zeros = symbol >> k; + if zeros < 23 { + bits.put(0, zeros); + bits.put(1, 1); + bits.put(symbol & ((1 << k) - 1), k); + } else { + assert!(symbol < 1 << 8, "reference QP escapes carry an 8-bit symbol"); + bits.put(0, 23); + bits.put(1, 1); + bits.put(symbol, 8); + } +} + +/// Emits the MELCODE bits whose decode (`crx.rs run`) yields exactly `target` with the same run-index bookkeeping. +fn put_run(bits: &mut BitWriter, index: &mut usize, remaining: usize, target: usize) { + assert!(target >= 1 && target <= remaining); + let mut length = 1usize; + while length < remaining { + let exponent = u32::from(RUN_EXPONENT[*index]); + if target - length >= 1usize << exponent { + bits.put(1, 1); + length += 1usize << exponent; + if length <= remaining { + *index = (*index + 1).min(RUN_EXPONENT.len() - 1); + } + } else { + bits.put(0, 1); + bits.put((target - length) as u32, exponent); + *index = index.saturating_sub(1); + return; + } + } + assert_eq!(length, target, "a run reaching the row end needs no terminator"); +} + +/// Inverse of the decoder's signed mapping (crx.rs `signed`). +fn zig(value: i32) -> u32 { + ((value << 1) ^ (value >> 31)) as u32 +} + +/// MED prediction (crx.rs `median`). +fn median(left: i32, above: i32, corner: i32) -> i32 { + if corner >= left.max(above) { + left.min(above) + } else if corner <= left.min(above) { + left.max(above) + } else { + left + above - corner + } +} + +/// Adaptive Rice parameter update (crx.rs `update_rice`). +fn update_rice(mut k: u32, symbol: u32) -> u32 { + if symbol >= 3 << k { + k += 1; + if symbol >= 3 << k { + k += 1; + } + } else if k > 0 && symbol < 1 << (k - 1) { + k -= 1; + } + k.min(20) +} + +/// Encodes a plane with the lossless coder: a zero run flag wherever the decoder reads one, then MED + Rice. +fn encode_lossless_plane(values: &[i32], width: usize, height: usize, bits: &mut BitWriter) { + let mut k = 0u32; // runs are never emitted, so the MELCODE run index stays unused here + let (mut previous, mut current) = (vec![0i32; width], vec![0i32; width]); + for y in 0..height { + let mut left = previous[0]; + for x in 0..width { + let above = previous[x]; + let right = previous.get(x + 1).copied().unwrap_or(above); + if x + 1 < width && left == above && left == right { + bits.put(0, 1); // a run is never required: regular prediction encodes any sample + } + let corner = if x == 0 { above } else { previous[x - 1] }; + let symbol = zig(values[y * width + x] - median(left, above, corner)); + put_rice(bits, k, symbol); + current[x] = values[y * width + x]; + left = values[y * width + x]; + let estimate = if y > 0 && x + 1 < width { (symbol + 2 * right.abs_diff(above)) / 2 } else { symbol }; + k = update_rice(k, estimate); + } + std::mem::swap(&mut current, &mut previous); + } +} + +/// Encodes a high-frequency band: zero-run flags in the all-zero context (forced for zero coefficients), +/// the nonzero-proven mapping after an interruption, and the upper-row Rice context (crx.rs `high_frequency_band`). +fn encode_high_frequency_band(values: &[i32], width: usize, height: usize, bits: &mut BitWriter) { + let (mut k, mut run_index) = (0u32, 0usize); + let (mut previous, mut current) = (vec![0i32; width], vec![0i32; width]); + let (mut previous_k, mut current_k) = (vec![0i32; width], vec![0i32; width]); + for y in 0..height { + let (mut x, mut left) = (0usize, 0i32); + while x < width { + let above = previous[x]; + let right = previous.get(x + 1).copied().unwrap_or(above); + let mut nonzero = false; + if x + 1 < width && left == 0 && above == 0 && right == 0 { + if values[y * width + x] == 0 { + bits.put(1, 1); // a zero in this context must take the run path + let mut length = 1usize; + while x + length < width && values[y * width + x + length] == 0 { + length += 1; + } + put_run(bits, &mut run_index, width - x, length); + for slot in &mut current[x..x + length] { + *slot = 0; + } + for slot in &mut current_k[x..x + length] { + *slot = 0; + } + left = 0; + x += length; + if x == width { + continue; + } + nonzero = true; // the interruption proves the next coefficient nonzero + } else { + bits.put(0, 1); + nonzero = true; + } + } + let coded = zig(values[y * width + x]) - u32::from(nonzero); + put_rice(bits, k, coded); + current[x] = values[y * width + x]; + left = values[y * width + x]; + k = update_rice(k, coded); + if y > 0 && x + 1 < width && previous_k[x + 1] as u32 > k + 1 { + k += 1; + } + current_k[x] = k as i32; + x += 1; + } + std::mem::swap(&mut current, &mut previous); + std::mem::swap(&mut current_k, &mut previous_k); + } +} + +/// Encodes an adaptive QP map: the first symbol as a 32-bit literal, then QP Rice codes over the MED +/// predictor (crx.rs `qp_map`). +fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec<u8> { + let mut k = 0u32; + let (mut previous, mut current) = (vec![0i32; width], vec![0i32; width]); + let mut bits = BitWriter::default(); + for y in 0..height { + let mut left = previous[0]; + for x in 0..width { + let above = previous[x]; + let corner = if x == 0 { above } else { previous[x - 1] }; + let right = previous.get(x + 1).copied().unwrap_or(above); + let symbol = zig(values[y * width + x] - median(left, above, corner)); + if x == 0 && y == 0 { + bits.put(symbol, 32); + } else { + put_qp_rice(&mut bits, k, symbol); + } + current[x] = values[y * width + x]; + left = values[y * width + x]; + let estimate = if y > 0 && x + 1 < width { (symbol + 2 * right.abs_diff(above)) / 2 } else { symbol }; + k = update_rice(k, estimate); + } + std::mem::swap(&mut current, &mut previous); + } + bits.into_bytes() +} + +// --- exact algebraic inverse of the crate's 5/3 synthesis (the forward transform under test) --- + +struct Band { + width: usize, + height: usize, + data: Vec<i32>, +} + +/// Produces the (low, high) pair whose synthesis (`crx_wavelet::inverse_line`) reproduces `line` exactly. +/// +/// Mirrors the inverse lifting: each odd sample fixes its stored high coefficient, each even sample its +/// stored low coefficient. The `before`/`after` halo coefficients are free parameters (any value works: +/// synthesis absorbs them through the boundary low coefficient), so they are supplied by the caller. +fn forward_lift(line: &[i32], before: bool, after: bool, halo_before: i32, halo_after: i32) -> (Vec<i32>, Vec<i32>) { + let n = line.len(); + let high_count = n / 2 + usize::from(before) + usize::from(after); + let low_count = n.div_ceil(2) + usize::from(after && n.is_multiple_of(2)); + if high_count == 0 { + return (line.to_vec(), Vec::new()); + } + // The right prediction of the final odd sample of an even, right-halo line (`after`: even(i + 1)). + let extension = if after && n.is_multiple_of(2) { Some(2 * (line[n - 1] - halo_after) - line[n - 2]) } else { None }; + let mut high = Vec::with_capacity(high_count); + if before { + high.push(halo_before); + } + for i in 0..n / 2 { + let right = match line.get(2 * i + 2) { + Some(&value) => value, + None => extension.unwrap_or(line[2 * i]), // no right neighbour: synthesis mirrors the last even sample + }; + high.push(line[2 * i + 1] - (line[2 * i] + right).div_euclid(2)); + } + if after { + high.push(halo_after); + } + let mut low = Vec::with_capacity(low_count); + let evens = n.div_ceil(2); + for i in 0..low_count { + let even_value = if i < evens { line[2 * i] } else { extension.unwrap_or_default() }; + let left_index = (i + usize::from(before)).saturating_sub(1).min(high.len() - 1); + let right_index = (i + usize::from(before)).min(high.len() - 1); + low.push(even_value + (high[left_index] + high[right_index] + 2).div_euclid(4)); + } + (low, high) +} + +/// One inverse level, reversed: vertical lifting first (CRX analysis order), then horizontal, with the +/// same tile-edge flags the decoder passes for every level. +fn forward_level(plane: &[i32], width: usize, height: usize, before: bool, after: bool, halo: i32) -> (Band, Band, Band, Band) { + let (low_h, high_h) = (height.div_ceil(2), height / 2); + let mut temporary = vec![0i32; width * height]; + for x in 0..width { + let column: Vec<i32> = (0..height).map(|y| plane[y * width + x]).collect(); + let (lo, hi) = forward_lift(&column, false, false, halo, halo); + for (y, value) in lo.into_iter().chain(hi).enumerate() { + temporary[y * width + x] = value; + } + } + let low_w = width.div_ceil(2) + usize::from(after && width.is_multiple_of(2)); + let high_w = width / 2 + usize::from(before) + usize::from(after); + let band = |w: usize, h: usize| Band { width: w, height: h, data: vec![0i32; w * h] }; + let (mut ll, mut hl, mut lh, mut hh) = (band(low_w, low_h), band(high_w, low_h), band(low_w, high_h), band(high_w, high_h)); + for y in 0..height { + let (lo, hi) = forward_lift(&temporary[y * width..(y + 1) * width], before, after, halo, halo); + let (low_band, high_band, by) = if y < low_h { (&mut ll, &mut hl, y) } else { (&mut lh, &mut hh, y - low_h) }; + low_band.data[by * low_w..(by + 1) * low_w].copy_from_slice(&lo); + high_band.data[by * high_w..(by + 1) * high_w].copy_from_slice(&hi); + } + (ll, hl, lh, hh) +} + +/// Subbands in CRX order: LL3, HL3, LH3, HH3, HL2, LH2, HH2, HL1, LH1, HH1. +fn forward_bands(plane: &[i32], width: usize, height: usize, before: bool, after: bool, halo: i32) -> Vec<Band> { + let (ll1, hl1, lh1, hh1) = forward_level(plane, width, height, before, after, halo); + let (ll2, hl2, lh2, hh2) = forward_level(&ll1.data, ll1.width, ll1.height, before, after, halo); + let (ll3, hl3, lh3, hh3) = forward_level(&ll2.data, ll2.width, ll2.height, before, after, halo); + vec![ll3, hl3, lh3, hh3, hl2, lh2, hh2, hl1, lh1, hh1] +} + +/// Subband shapes the decoder derives (crx.rs `wavelet_plane` / `crx_wavelet::sizes`). +fn band_shapes(plane_width: usize, plane_height: usize, before: bool, after: bool) -> [(usize, usize); 10] { + let mut levels = [(0usize, 0usize, 0usize, 0usize); 3]; // (low_w, high_w, low_h, high_h), finest first + let (mut w, mut h) = (plane_width, plane_height); + for level in &mut levels { + let low_w = w.div_ceil(2) + usize::from(after && w % 2 == 0); + let high_w = w / 2 + usize::from(before) + usize::from(after); + *level = (low_w, high_w, h.div_ceil(2), h / 2); + w = low_w; + h = level.2; + } + let (lw1, hw1, lh1, hh1) = levels[0]; + let (lw2, hw2, lh2, hh2) = levels[1]; + let (lw3, hw3, lh3, hh3) = levels[2]; + [(lw3, lh3), (hw3, lh3), (lw3, hh3), (hw3, hh3), (hw2, lh2), (lw2, hh2), (hw2, hh2), (hw1, lh1), (lw1, hh1), (hw1, hh1)] +} + +fn encode_band(band: &Band, lowpass: bool) -> Vec<u8> { + let mut bits = BitWriter::default(); + if lowpass { + encode_lossless_plane(&band.data, band.width, band.height, &mut bits); + } else { + encode_high_frequency_band(&band.data, band.width, band.height, &mut bits); + } + bits.into_bytes() +} + +/// The measured uniform quantization sequence (crx.rs `quant_step`). +fn quant_step(value: u8) -> i32 { + let scale = [40i32, 45, 51, 57, 64, 72][usize::from(value % 6)]; + (scale << (value / 6)) / 64 +} + +// --- synthetic CR3 container and CRX sample assembly --- + +fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> { + let mut out = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out +} + +fn track_box(entry: &[u8], offset: u64, size: u32) -> Vec<u8> { + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); + stsd.extend_from_slice(entry); + let mut stsz = vec![0u8; 4]; + stsz.extend(size.to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); + co64.extend(offset.to_be_bytes()); + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))) +} + +/// Wraps a CRAW sample entry and its sample bytes in a minimal CR3 file (no metadata boxes: geometry +/// and levels come from the container and CMP1 alone). +fn cr3_file(entry: &[u8], sample: &[u8]) -> Vec<u8> { + let ftyp = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + // The track length does not depend on the offset value (always a 64-bit field), so it can be + // measured with a placeholder before the real offset is known. + let track_len = track_box(entry, 0, sample.len() as u32).len(); + let offset = (ftyp.len() + 8 + track_len + 7) & !7; + let mut file = ftyp; + file.extend(box_bytes(b"moov", &track_box(entry, offset as u64, sample.len() as u32))); + file.resize(offset, 0); + file.extend_from_slice(sample); + file +} + +fn cmp1(version: u16, width: u32, height: u32, tile_width: u32, tile_height: u32, levels: u8, header_size: u32) -> Vec<u8> { + let mut coding = vec![0u8; 52]; + coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + coding[2..4].copy_from_slice(&48u16.to_be_bytes()); + coding[4..6].copy_from_slice(&version.to_be_bytes()); + coding[8..12].copy_from_slice(&width.to_be_bytes()); + coding[12..16].copy_from_slice(&height.to_be_bytes()); + coding[16..20].copy_from_slice(&tile_width.to_be_bytes()); + coding[20..24].copy_from_slice(&tile_height.to_be_bytes()); + coding[24] = 14; + coding[25] = 0x40; // four planes, CFA pattern 0 (RGGB) + coding[26] = levels; // encoding 0 + coding[28..32].copy_from_slice(&header_size.to_be_bytes()); + coding +} + +fn iad1_bytes(width: u16, height: u16, full: u16, crop: [u16; 4], masked_left: [u16; 4], masked_top: [u16; 4], active: [u16; 4]) -> Vec<u8> { + let mut area = vec![0u8; 48]; + let mut put = |at: usize, value: u16| area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + put(4, width); + put(6, height); + put(10, full); + for (i, value) in crop.iter().enumerate() { + put(16 + 2 * i, *value); + } + for (i, value) in masked_left.iter().enumerate() { + put(24 + 2 * i, *value); + } + if full == 2 { + for (i, value) in masked_top.iter().enumerate() { + put(32 + 2 * i, *value); + } + for (i, value) in active.iter().enumerate() { + put(40 + 2 * i, *value); + } + } + area +} + +fn craw_entry(coding: &[u8], width: u16, height: u16, iad1: Option<&[u8]>) -> Vec<u8> { + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&width.to_be_bytes()); + craw[26..28].copy_from_slice(&height.to_be_bytes()); + craw.extend(box_bytes(b"CMP1", coding)); + if let Some(area) = iad1 { + craw.extend(box_bytes(b"CDI1", &[vec![0u8; 4], box_bytes(b"IAD1", area)].concat())); + } + box_bytes(b"CRAW", &craw) +} + +fn put_marker(out: &mut Vec<u8>, code: u16, a: u32, b: u32) { + out.extend(code.to_be_bytes()); + out.extend(8u16.to_be_bytes()); + out.extend(a.to_be_bytes()); + out.extend(b.to_be_bytes()); +} + +fn put_marker16(out: &mut Vec<u8>, code: u16, words: [u32; 4]) { + out.extend(code.to_be_bytes()); + out.extend(16u16.to_be_bytes()); + for word in words { + out.extend(word.to_be_bytes()); + } +} + +/// Assembles a version 0x100 sample: one ff01 tile header and per plane an ff02 header plus `bands` +/// ff03 headers, then the band bytes. `bands` is 1 (lossless) or 10 (three-level C-RAW). +fn v100_sample(tiles: &[[Vec<Vec<u8>>; 4]], quant: u8) -> (usize, Vec<u8>) { + let mut header = Vec::new(); + let mut data = Vec::new(); + for (index, planes) in tiles.iter().enumerate() { + let tile_size: usize = planes.iter().flatten().map(Vec::len).sum(); + put_marker(&mut header, 0xff01, tile_size as u32, u32::from(index as u16) << 16); + for (p, bands) in planes.iter().enumerate() { + let plane_size: usize = bands.iter().map(Vec::len).sum(); + put_marker(&mut header, 0xff02, plane_size as u32, (p as u32) << 28 | 0x0800_0000); + for (b, bytes) in bands.iter().enumerate() { + put_marker(&mut header, 0xff03, bytes.len() as u32, (b as u32) << 28 | u32::from(quant) << 19); + } + } + for bands in planes { + for bytes in bands { + data.extend_from_slice(bytes); + } + } + } + let header_size = header.len(); + header.extend(data); + (header_size, header) +} + +/// Assembles a version 0x200 single-tile sample: an ff11 header with the adaptive QP stream, ff12/ff13 +/// headers with per-band base and gain, then QP data, its padding and the band bytes. +fn v200_sample(tile: &[Vec<Vec<u8>>; 4], gains: [u32; 10], bases: [u32; 10], qp: &[u8], qp_padding: usize) -> (usize, Vec<u8>) { + let band_bytes: usize = tile.iter().map(|bands| bands.iter().map(Vec::len).sum::<usize>()).sum(); + let tile_size = qp.len() + qp_padding + band_bytes; + let mut header = Vec::new(); + put_marker16(&mut header, 0xff11, [tile_size as u32, 0x4000, qp.len() as u32, u32::from(qp_padding as u16) << 16]); + for (p, bands) in tile.iter().enumerate() { + let plane_size: usize = bands.iter().map(Vec::len).sum(); + put_marker(&mut header, 0xff12, plane_size as u32, (p as u32) << 28 | 0x0800_0000); + for (b, bytes) in bands.iter().enumerate() { + put_marker16(&mut header, 0xff13, [bytes.len() as u32, (b as u32) << 28 | gains[b], bases[b], 0]); + } + } + let header_size = header.len(); + let mut data = header; + data.extend_from_slice(qp); + data.resize(data.len() + qp_padding, 0); + for bands in tile { + for bytes in bands { + data.extend_from_slice(bytes); + } + } + (header_size, data) +} + +// --- fixture builders --- + +/// A synthetic CRX stream plus its container, with the expected sensor samples when exact decoding is asserted. +struct Fixture { + file: Vec<u8>, + expected: Vec<u16>, +} + +fn wrap(coding: &[u8], width: u32, height: u32, sample: &[u8], iad1: Option<&[u8]>) -> Vec<u8> { + cr3_file(&craw_entry(coding, width as u16, height as u16, iad1), sample) +} + +/// Deterministic pseudo-random plane content for fixed regression-style files. +fn lcg_values(seed: u64, count: usize) -> Vec<i32> { + let mut state = seed; + (0..count) + .map(|_| { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((state >> 33) % 16_384) as i32 - 8192 + }) + .collect() +} + +/// Tile geometries with three non-empty wavelet levels: (tile width, tile height, tile count). +/// Plane sizes are half of each; odd plane sizes come from tile sizes ≡ 2 (mod 4). +const GEOMETRIES: [(usize, usize, usize); 8] = + [(16, 16, 1), (18, 16, 1), (16, 18, 1), (18, 18, 2), (24, 16, 2), (32, 18, 2), (24, 24, 1), (48, 16, 3)]; +const MAX_PLANE_VALUES: usize = 3 * 4 * 24 * 12; + +const LOSSLESS_GEOMETRIES: [(usize, usize); 5] = [(4, 4), (8, 4), (6, 8), (12, 6), (8, 8)]; + +/// Version 0x200 sensor sizes; the QP map has one cell per 16×4 pixels, so heights 12 and 20 give odd map heights (3 and 5). +const V200_GEOMETRIES: [(usize, usize); 6] = [(16, 16), (32, 16), (16, 24), (16, 32), (16, 12), (32, 20)]; + +/// Encodes the four planes of one tile into CRX band byte streams. +fn encode_tile_planes( + planes_values: [Vec<i32>; 4], + plane_w: usize, + plane_h: usize, + before: bool, + after: bool, + halo: i32, + levels: u8, +) -> [Vec<Vec<u8>>; 4] { + let mut encoded: [Vec<Vec<u8>>; 4] = Default::default(); + for (p, plane_bytes) in encoded.iter_mut().enumerate() { + let values = &planes_values[p]; + if levels == 0 { + let band = Band { width: plane_w, height: plane_h, data: values.clone() }; + *plane_bytes = vec![encode_band(&band, true)]; + } else { + let bands = forward_bands(values, plane_w, plane_h, before, after, halo); + *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + } + } + encoded +} + +/// Builds a C-RAW file whose planes hold the given random values exactly (uniform quant 4/8/9 has step 1). +fn craw_roundtrip_file((tile_w, tile_h, tile_count): (usize, usize, usize), quant: u8, halo: i32, values: &[i32]) -> Fixture { + let (plane_w, plane_h) = (tile_w / 2, tile_h / 2); + let (width, height) = (tile_w * tile_count, tile_h); + let mut cursor = 0usize; + let mut tiles: Vec<[Vec<Vec<u8>>; 4]> = Vec::new(); + let mut expected = vec![0u16; width * height]; + for tile in 0..tile_count { + let mut planes_values = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + for p in 0..4 { + planes_values[p] = values[cursor..cursor + plane_w * plane_h].to_vec(); + cursor += plane_w * plane_h; + for (i, &value) in planes_values[p].iter().enumerate() { + let x = tile * tile_w + 2 * (i % plane_w) + p % 2; + let y = 2 * (i / plane_w) + p / 2; + expected[y * width + x] = (value + 8192) as u16; // in sensor range: no clamping + } + } + tiles.push(encode_tile_planes(planes_values, plane_w, plane_h, tile > 0, tile + 1 < tile_count, halo, 3)); + } + let (header_size, sample) = v100_sample(&tiles, quant); + let coding = cmp1(0x100, width as u32, height as u32, tile_w as u32, tile_h as u32, 3, header_size as u32); + Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected } +} + +/// Builds a lossless file (one band per plane) whose planes hold exactly the given values. +fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture { + let (plane_w, plane_h) = (width / 2, height / 2); + let mut planes_values = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + let mut expected = vec![0u16; width * height]; + for p in 0..4 { + planes_values[p] = values[p * plane_w * plane_h..(p + 1) * plane_w * plane_h].to_vec(); + for (i, &value) in planes_values[p].iter().enumerate() { + let x = 2 * (i % plane_w) + p % 2; + let y = 2 * (i / plane_w) + p / 2; + expected[y * width + x] = (value + 8192) as u16; + } + } + let tiles = [encode_tile_planes(planes_values, plane_w, plane_h, false, false, 0, 0)]; + let (header_size, sample) = v100_sample(&tiles, 4); + let coding = cmp1(0x100, width as u32, height as u32, width as u32, height as u32, 0, header_size as u32); + Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected } +} + +/// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`, +/// everything else zero. Encoded at the given level count with the given quant/gain/base parameters. +fn constant_band_planes( + plane_w: usize, + plane_h: usize, + levels: u8, + ll3: i32, + mid_band: Option<(usize, i32)>, + quant: u8, + gains: [u32; 10], + bases: [u32; 10], + qp: Option<&[i32]>, + qp_padding: usize, + before: bool, + after: bool, +) -> (usize, Vec<u8>) { + let planes: [Vec<Vec<u8>>; 4] = if levels == 0 { + let mut encoded: [Vec<Vec<u8>>; 4] = Default::default(); + for bands in encoded.iter_mut() { + let band = Band { width: plane_w, height: plane_h, data: vec![ll3; plane_w * plane_h] }; + *bands = vec![encode_band(&band, true)]; + } + encoded + } else { + let shapes = band_shapes(plane_w, plane_h, before, after); + let mut encoded: [Vec<Vec<u8>>; 4] = Default::default(); + for bands in encoded.iter_mut() { + let bands_data: Vec<Band> = shapes + .iter() + .enumerate() + .map(|(i, &(w, h))| { + let value = if i == 0 { ll3 } else { mid_band.filter(|&(band, _)| band == i).map_or(0, |(_, value)| value) }; + Band { width: w, height: h, data: vec![value; w * h] } + }) + .collect(); + *bands = bands_data.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + } + encoded + }; + if levels == 0 { + v100_sample(&[planes], quant) + } else if let Some(map) = qp { + let (map_w, map_h) = (plane_w / 8, plane_h / 2); // one QP cell per 16×4 sensor pixels + v200_sample(&planes, gains, bases, &encode_qp_map(map, map_w, map_h), qp_padding) + } else { + v100_sample(&[planes], quant) + } +} + +/// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value. +fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture { + let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32); + Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() } +} + +/// A version 0x200 file with the given adaptive QP map (one cell per 16×4 sensor pixels) and per-band gains/bases. +fn v200_mapped_file( + width: usize, + height: usize, + map: &[i32], + ll3: i32, + mid_band: Option<(usize, i32)>, + gains: [u32; 10], + bases: [u32; 10], +) -> Fixture { + assert_eq!(map.len(), (width / 16) * (height / 4)); + let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false); + let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); + Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() } +} + +/// A version 0x200 file with a uniform adaptive QP map and the given per-band gains/bases. +fn v200_constant_bands_file( + width: usize, + height: usize, + qp_value: i32, + ll3: i32, + mid_band: Option<(usize, i32)>, + gains: [u32; 10], + bases: [u32; 10], +) -> Fixture { + v200_mapped_file(width, height, &vec![qp_value; (width / 16) * (height / 4)], ll3, mid_band, gains, bases) +} + +fn sensor_hash(image: &RawImage) -> u64 { + match &image.data { + RawData::U16(samples) => samples + .iter() + .flat_map(|value| value.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)), + RawData::F32(_) => panic!("u16 sensor data expected"), + } +} + +fn quad() -> impl proptest::strategy::Strategy<Value = [u16; 4]> { + (any::<u16>(), any::<u16>(), any::<u16>(), any::<u16>()).prop_map(|(a, b, c, d)| [a, b, c, d]) +} + +proptest! { + /// Property 1: synthesis after the exact-inverse analysis reproduces random planes exactly, across + /// odd plane parities and multi-tile halo configurations, with arbitrary halo values. + #[test] + fn craw_wavelet_roundtrip_is_exact( + geometry in 0usize..GEOMETRIES.len(), + values in proptest::collection::vec(-8192i32..=8191, MAX_PLANE_VALUES), + halo in -4096i32..=4096, + quant in proptest::prop_oneof![Just(4u8), Just(8u8), Just(9u8)], + ) { + let (tile_w, tile_h, tile_count) = GEOMETRIES[geometry]; + let fixture = craw_roundtrip_file(GEOMETRIES[geometry], quant, halo, &values); + assert_eq!(probe(&fixture.file), Some(RawFormat::Cr3)); + let image = decode(&fixture.file).unwrap(); + assert_eq!((image.width, image.height, image.bits), (tile_w * tile_count, tile_h, 14)); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert_eq!(samples, &fixture.expected, "synthesis after analysis must be exact"); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); + } + + /// Property 2 (lossless side): in-range planes decode exactly; a sample outside the CMP1 bit depth + /// is corrupt data, never clamped. + #[test] + fn lossless_planes_are_exact_and_out_of_range_values_are_rejected( + geometry in 0usize..LOSSLESS_GEOMETRIES.len(), + values in proptest::collection::vec(-8192i32..=8191, 4 * 6 * 4), + out_of_range in 0u8..=2, + ) { + let (width, height) = LOSSLESS_GEOMETRIES[geometry]; + let mut values = values[..4 * (width / 2) * (height / 2)].to_vec(); + if out_of_range > 0 { + values[0] = if out_of_range == 1 { 9000 } else { -9000 }; + } + let fixture = lossless_file((width, height), &values); + if out_of_range == 0 { + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert_eq!(samples, &fixture.expected); + } else { + assert!(matches!(decode(&fixture.file), Err(RawError::Corrupt(_)))); + } + } + + /// Property 3: pathological Rice/Golomb prefixes inside well-formed frames are errors or clean + /// decodes — never a panic, never a hang, always deterministic. + #[test] + fn adversarial_entropy_never_panics_and_is_deterministic( + kind in 0usize..4, + payload in proptest::collection::vec(any::<u8>(), 0..=600), + slot in any::<usize>(), + ) { + let file = adversarial_frame(kind, &payload, slot); + let first = decode(&file); + assert_eq!(first, decode(&file), "repeated decodes of the same bytes must agree"); + if let Ok(image) = &first { + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&value| value <= 16383), "C-RAW clamps to the CMP1 bit depth"); + } + let _ = probe_info(&file); + } + + /// Property 2 (adaptive side): random QP maps in the supported 131–168 band, including odd map heights, + /// and random per-band base/gain parameters always decode, with every sample inside the 14-bit range. + #[test] + fn adaptive_qp_maps_stay_within_the_bit_depth( + geometry in 0usize..V200_GEOMETRIES.len(), + qp_values in proptest::collection::vec(131i32..=168, 16), + ll3 in -2048i32..=2048, + mid in -16i32..=16, + gain in 0u32..=32, + base in 0u32..=8, + ) { + let (width, height) = V200_GEOMETRIES[geometry]; + let (map_w, map_h) = (width / 16, height / 4); + let mut gains = [0u32; 10]; + let mut bases = [0u32; 10]; + for (i, (g, b)) in gains.iter_mut().zip(bases.iter_mut()).enumerate() { + if i >= 4 { + *g = gain; + } + *b = if i < 4 { base + 1 } else { base }; + } + let (header_size, sample) = constant_band_planes( + width / 2, + height / 2, + 3, + ll3, + Some((4, mid)), + 4, + gains, + bases, + Some(&qp_values[..map_w * map_h]), + 3, + false, + false, + ); + let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); + let file = wrap(&coding, width as u32, height as u32, &sample, None); + let image = decode(&file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&value| value <= 16383), "adaptive dequantization must stay in the bit depth"); + assert_eq!(probe_info(&file).unwrap(), image.info()); + } + + /// Property 4: randomly generated IAD1 fields either decode — with `active_area ⊆ sensor dims` and + /// `crop ⊆ active_area` — or are rejected (no overflow or out-of-bounds geometry gets through). + #[test] + fn random_iad1_geometry_keeps_active_area_and_crop_invariants( + base in 0usize..2, + full in proptest::prop_oneof![Just(0u16), Just(2u16)], + crop in quad(), + masked_left in quad(), + masked_top in quad(), + active in quad(), + width_off in -3i32..=3, + height_off in -3i32..=3, + ) { + let (width, height, coding, sample) = match base { + 0 => (4usize, 4usize, cmp1(0x100, 4, 4, 4, 4, 0, 108), lossless_sample(100)), + _ => (16usize, 16usize, cmp1(0x100, 16, 16, 16, 16, 3, 540), constant_bands_sample(0, None, 4)), + }; + let clamp16 = |off: i32, base_dim: usize| u16::try_from((base_dim as i64 + off as i64).clamp(1, i64::from(u16::MAX))).unwrap(); + let area = iad1_bytes(clamp16(width_off, width), clamp16(height_off, height), full, crop, masked_left, masked_top, active); + let file = wrap(&coding, width as u32, height as u32, &sample, Some(&area)); + assert_eq!(probe(&file), Some(RawFormat::Cr3)); + if let Ok(image) = decode(&file) { + image.validate().unwrap(); + let a = image.active_area; + assert!(a.width > 0 && a.height > 0, "{a:?}"); + assert!(a.x + a.width <= image.width && a.y + a.height <= image.height, "active area outside sensor: {a:?}"); + assert!( + image.crop.x + image.crop.width <= a.width && image.crop.y + image.crop.height <= a.height, + "crop outside active area: {:?} in {a:?}", + image.crop + ); + assert_eq!(probe_info(&file).unwrap(), image.info()); + } + } +} + +/// Raw sample bytes of the 4×4 lossless fixture (a 108-byte marker header plus four flat planes). +fn lossless_sample(value: i32) -> Vec<u8> { + let band = Band { width: 2, height: 2, data: vec![value; 4] }; + let tiles = [std::array::from_fn(|_| vec![encode_band(&band, true)])]; + let (_, sample) = v100_sample(&tiles, 4); + sample +} + +/// Raw sample bytes of a constant-band 16×16 C-RAW fixture. +fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec<u8> { + let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + sample +} + +// --- adversarial entropy frames (property 3) --- + +/// A well-formed frame of the given kind (0: lossless 8×6, 1: C-RAW 16×16, 2: 0x200 with an adaptive +/// QP map, 3: the same 0x200 frame with the QP map itself replaced) in which one plane's entropy streams +/// (kinds 0 to 2) or the QP map stream (kind 3) are replaced by the adversarial payload. The marker +/// sizes follow the payload, so the layout stays parseable and the pathological bits reach the +/// Rice/Golomb readers; the untouched planes decode fine, reaching the mosaic stage on success. +fn adversarial_frame(kind: usize, payload: &[u8], slot: usize) -> Vec<u8> { + let shapes = if kind == 0 { vec![(4usize, 3usize)] } else { band_shapes(8, 8, false, false).to_vec() }; + let mut tiles: Vec<[Vec<Vec<u8>>; 4]> = vec![Default::default()]; + for (p, bands) in tiles[0].iter_mut().enumerate() { + *bands = if kind != 3 && p == slot % 4 { + shapes.iter().map(|_| payload.to_vec()).collect() + } else { + shapes + .iter() + .enumerate() + .map(|(i, &(w, h))| { + let band = Band { width: w, height: h, data: vec![0; w * h] }; + encode_band(&band, i == 0) + }) + .collect() + }; + } + let (header_size, sample) = if kind == 2 { + let map = vec![150i32; 16]; + v200_sample(&tiles[0], [0; 10], [1; 10], &encode_qp_map(&map, 1, 4), 3) + } else if kind == 3 { + v200_sample(&tiles[0], [0; 10], [1; 10], payload, 3) + } else { + v100_sample(&tiles, 4) + }; + let (width, height, levels) = match kind { + 0 => (8u32, 6u32, 0u8), + 1 => (16, 16, 3), + _ => (16, 16, 3), + }; + let version = if kind >= 2 { 0x200u16 } else { 0x100 }; + let coding = cmp1(version, width, height, width, height, levels, header_size as u32); + wrap(&coding, width, height, &sample, None) +} + +// --- exhaustive coverage of the verified quantization bands (property 2) --- + +#[test] +fn every_uniform_quant_value_dequantizes_within_the_bit_depth() { + for quant in 4u8..=44 { + let step = quant_step(quant); + // Overshoot: the dequantized DC leaves the 14-bit range and C-RAW clamps it. + let fixture = constant_bands_file(8000, None, quant); + let expected_value = (8192 + 8000 * step).clamp(0, 16383) as u16; + let image = decode(&fixture.file).unwrap(); + assert_eq!(image.data, RawData::U16(vec![expected_value; 256]), "quant {quant}"); + assert_eq!(probe_info(&fixture.file).unwrap().bits, 14); + // In range: the DC lands exactly on 8192 + level * step, so the step itself is observable + // (the overshoot case above saturates for every step above 1). + let level = 8000 / step; + let fixture = constant_bands_file(level, None, quant); + let image = decode(&fixture.file).unwrap(); + assert_eq!(image.data, RawData::U16(vec![(8192 + level * step) as u16; 256]), "quant {quant} in range"); + } + for quant in [3u8, 45] { + let fixture = constant_bands_file(8000, None, quant); + assert!(matches!(decode(&fixture.file), Err(RawError::Unsupported(_))), "quant {quant}"); + assert!(matches!(probe_info(&fixture.file), Err(RawError::Unsupported(_)))); + } +} + +#[test] +fn every_qp_value_in_the_supported_band_dequantizes_within_the_bit_depth() { + let gains = [0, 0, 0, 0, 8, 8, 8, 8, 8, 8]; + let bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; + let mut first_hash = 0u64; + let mut last_hash = 0u64; + for value in 131i32..=168 { + let fixture = v200_constant_bands_file(16, 16, value, 400, Some((4, 8)), gains, bases); + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&sample| sample <= 16383), "QP {value}"); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); + if value == 131 { + first_hash = sensor_hash(&image); + } + last_hash = sensor_hash(&image); + } + assert_ne!(first_hash, last_hash, "the QP value must reach the dequantization step"); + for value in [130i32, 169] { + let fixture = v200_constant_bands_file(16, 16, value, 400, None, gains, bases); + assert!(matches!(decode(&fixture.file), Err(RawError::Unsupported(_))), "QP {value}"); + assert!(matches!(probe_info(&fixture.file), Err(RawError::Unsupported(_)))); + } +} + +// --- determinism (property 5) --- + +#[test] +fn decoding_is_deterministic_across_repeated_and_parallel_invocations() { + let craw = craw_roundtrip_file((24, 16, 2), 4, 7, &lcg_values(7, MAX_PLANE_VALUES)); + let lossless = lossless_file((8, 6), &lcg_values(11, 4 * 4 * 3)); + let adaptive = v200_constant_bands_file(16, 16, 150, 400, Some((4, 8)), [0, 0, 0, 0, 8, 8, 8, 8, 8, 8], [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]); + for fixture in [&craw, &lossless, &adaptive] { + let first = decode(&fixture.file).unwrap(); + assert_eq!(first, decode(&fixture.file).unwrap(), "repeated decodes must be identical"); + let expected_hash = sensor_hash(&first); + let hashes: Vec<u64> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..4).map(|_| scope.spawn(|| sensor_hash(&decode(&fixture.file).unwrap()))).collect(); + handles.into_iter().map(|handle| handle.join().unwrap()).collect() + }); + assert!(hashes.iter().all(|&hash| hash == expected_hash), "parallel decodes must agree with the serial hash"); + } +} + +// --- QP map shapes: odd heights, large jumps, over-long prefixes (properties 3 and 6) --- + +/// An odd QP-map height (16×12 sensor, 1×3 map) decodes, and with step 1 (base 1, no gain) the flat LL3 +/// coefficient comes through exactly. +#[test] +fn odd_qp_map_height_0x200_decodes_within_range() { + let gains = [0; 10]; + let bases = [1; 10]; + let fixture = v200_constant_bands_file(16, 12, 150, 400, None, gains, bases); + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert_eq!(samples, &vec![8592u16; 16 * 12], "step 1 (base 1) keeps the LL3 coefficient exact"); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); +} + +/// Adaptive steps on an odd-height map: the coarser bands pair QP rows, averaging the QP values before the +/// step lookup, and the last row of an odd map has no partner and is used alone. Here HL2 has two rows: the +/// first pairs QP rows 0 and 1 (150 and 153, average 151), the second is QP row 2 alone (151). The adaptive +/// stream must therefore decode exactly like the uniform stream whose step is the one QP 151 gives, +/// and differ from the uniform streams one step either side. +#[test] +fn odd_height_qp_map_uses_its_last_row_alone() { + let map = [150, 153, 151]; + let step = |qp: i32| 2 + 8 * quant_step((qp - 124) as u8) as u32 / 8; + assert_eq!(step(151), 16); + let adaptive_gains = [0, 0, 0, 0, 8, 8, 8, 8, 8, 8]; + let adaptive_bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; + let uniform = |hl_step: u32| { + let fixture = v200_mapped_file(16, 12, &map, 400, Some((4, 8)), [0; 10], [8, 8, 8, 8, hl_step, hl_step, hl_step, hl_step, hl_step, hl_step]); + decode(&fixture.file).unwrap() + }; + let fixture = v200_mapped_file(16, 12, &map, 400, Some((4, 8)), adaptive_gains, adaptive_bases); + let adaptive = decode(&fixture.file).unwrap(); + assert_eq!(adaptive, uniform(step(151)), "QP rows 0+1 average to 151 and the unpaired row 2 is 151 alone"); + assert_ne!(adaptive, uniform(step(151) - 1)); + assert_ne!(adaptive, uniform(step(151) + 1)); + assert_eq!(probe_info(&fixture.file).unwrap(), adaptive.info()); +} + +/// A map whose neighbouring cells differ by up to 37 forces symbols past the plain Rice range, so the +/// 23-zero escape is exercised deterministically (a run of equal cells drops the Rice parameter to 0 first). +#[test] +fn qp_map_with_large_jumps_uses_the_escape_code() { + let map = [131, 131, 131, 131, 168, 131, 168, 131]; + let gains = [0, 0, 0, 0, 8, 8, 8, 8, 8, 8]; + let bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; + let fixture = v200_mapped_file(16, 32, &map, 400, Some((4, 8)), gains, bases); + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&sample| sample <= 16383)); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); +} + +/// A QP Rice prefix longer than the 23-zero escape is corrupt data, not an endless read. +#[test] +fn qp_rice_prefix_longer_than_the_escape_is_corrupt() { + // The 32-bit literal 300 (QP 150), then 24 zero bits with no terminating 1 within 23 zeros. + let payload = [0x00, 0x00, 0x01, 0x2c, 0x00, 0x00, 0x00, 0x80]; + let file = adversarial_frame(3, &payload, 0); + let error = decode(&file).unwrap_err(); + assert!(matches!(error, RawError::Corrupt(ref message) if message.contains("QP Rice escape prefix")), "{error:?}"); + assert!(probe_info(&file).is_err()); +} From 64ccc208537832fb0440466b66f957723ca20f30 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Tue, 13 Oct 2026 13:10:57 +0530 Subject: [PATCH 067/315] KEY-HIDEPINS: Tool Overlay modes (Auto / Always / Selected / Never) for spot and mask pins, Shift+H cycles them (fixes #224) The Remove tool's spot pins (Remove / Content-Aware / Heal / Clone) and the mask pins now follow a Tool Overlay mode, saved with the UI state: - Auto (default): shown while the pointer is over the photo (or a pin is dragged), hidden while a new spot or brush stroke is painted - Always: all of them - Selected: only the selected spot's or mask's - Never: none; the tool still works and a click paints a new spot A hidden spot can't be grabbed. Mask pins also need Show Pins (view.maskPins), which keeps its meaning and reply. Command view.toolOverlay {mode?: auto|always|selected|never} (no mode steps to the next), Shift+H (H stays Remove), View > Tool Overlay with the modes and the cycle item, and a Tool Overlay dropdown in the Remove and Masking panels. Headless tests cover each mode with the pointer over and away from the photo, grabbing, the key, the command, the menu, the dropdown, mask pins with Show Pins, and the ui.json round trip. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 13 ++ crates/ui-egui/src/menus.rs | 29 +++ crates/ui-egui/src/panels/detail.rs | 34 ++- crates/ui-egui/src/panels/masking.rs | 3 + crates/ui-egui/src/panels/right.rs | 24 +++ crates/ui-egui/src/state.rs | 68 ++++++ crates/ui-egui/src/tests_tool_overlay.rs | 259 +++++++++++++++++++++++ docs/parity-checklist.md | 17 +- docs/ui-parity.md | 5 +- 10 files changed, 440 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/tests_tool_overlay.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..13f437441 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -118,6 +118,8 @@ mod tests_temp_slider; #[cfg(test)] mod tests_titlebar; #[cfg(test)] +mod tests_tool_overlay; +#[cfg(test)] mod tests_unsaved; #[cfg(test)] mod tests_wb_selector; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..e1e96e197 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -161,6 +161,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.maskOverlayMode", "view.maskOverlayColor", "view.maskPins", + "@Tool Overlay", "view.cropOverlay", "---", "@Sort", @@ -507,6 +508,18 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { } v } + "Tool Overlay" => { + // when the spot and mask pins show; the last item steps to the next mode (Shift+H) + use crate::state::ToolOverlay; + let mut v: Vec<MenuNode> = ToolOverlay::ALL + .into_iter() + .map(|m| item("view.toolOverlay", json!({"mode": m.name()}), m.label(), None, true, Some(app.ui.tool_overlay == m))) + .collect(); + v.push(MenuNode::Separator); + let shortcut = crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.toolOverlay"); + v.push(item("view.toolOverlay", Value::Null, "Cycle Tool Overlay", shortcut, true, None)); + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..72e0d714c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -98,6 +98,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), ("view.maskPins", "Show Mask Pins", None, "View"), + // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next + // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool + // Overlay and the Remove panel list the modes. + ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), @@ -319,6 +323,18 @@ fn bool_param(command: &str, p: &Value, key: &str) -> Result<Option<bool>, Strin } } +/// What Shift+H says it switched to (the pins themselves may be off screen). +fn tool_overlay_toast(mode: crate::state::ToolOverlay) -> String { + use crate::state::ToolOverlay; + crate::i18n::tr(match mode { + ToolOverlay::Auto => "Tool Overlay: Auto", + ToolOverlay::Always => "Tool Overlay: Always", + ToolOverlay::Selected => "Tool Overlay: Selected", + ToolOverlay::Never => "Tool Overlay: Never", + }) + .to_string() +} + fn appearance_json(app: &LightcraftApp) -> Value { let s = &app.ui.settings; json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) @@ -894,6 +910,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); Ok(json!({"maskPins": app.ui.mask_pins})) } + "view.toolOverlay" => { + use crate::state::ToolOverlay; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.tool_overlay.next(), + Some(v) => match v.as_str().and_then(ToolOverlay::parse) { + Some(m) => m, + None => return Some(Err(format!("view.toolOverlay: unknown mode {v} (auto|always|selected|never)"))), + }, + }; + app.ui.tool_overlay = mode; + app.toast(&ctx, tool_overlay_toast(mode)); + Ok(json!({"mode": mode.name()})) + } "view.visualizeSpots" => { // like Lightroom's A: opens the Remove tool with the view on, or toggles it there if app.ui.right == RightPanel::Remove { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 66f9c7997..c92767c12 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -10,7 +10,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::render::Slot; -use crate::state::{BeforeAfter, RightPanel, Zoom}; +use crate::state::{BeforeAfter, RightPanel, ToolOverlay, Zoom}; use crate::theme::Tokens; use crate::widgets::register; @@ -1834,6 +1834,12 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let p = &ui.painter_at(clip); // (mask, component, handle, screen position) of everything that can be grabbed let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); + // pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + let mut pins = p.clone(); + if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) { + pins.set_invisible(); + } for m in &d.masks { let sel = Some(m.id) == active; for (ci, c) in m.components.iter().enumerate() { @@ -1886,10 +1892,11 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons _ => {} } if app.ui.mask_pins + && mode.shows(sel, live) && let Some(at) = component_pin(&c.shape) { let q = map.screen(at); - pin(p, q, sel); + pin(&pins, q, sel); register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); grips.push((m.id, ci, 0, q)); } @@ -2066,6 +2073,12 @@ fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { (l / x.max(1e-9), l / y.max(1e-9)) } +/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is +/// being dragged. +fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { + resp.hover_pos().is_some_and(|q| map.rect.contains(q)) || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) +} + fn pin(p: &egui::Painter, c: Pos2, sel: bool) { p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) }); p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE)); @@ -2075,15 +2088,28 @@ fn pin(p: &egui::Painter, c: Pos2, sel: bool) { /// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), /// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. +/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a +/// click there adds a new one. fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { let long = (map.rect.width().max(map.rect.height())) as f64; - let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); let active = app.session.active_spot.filter(|i| *i < d.spots.len()); + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + // Auto also hides them while a new spot is painted + let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. })); + let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); + let mut pins = p.clone(); + if painting { + pins.set_invisible(); + } // (spot, is source, screen centre, radius) of everything that can be grabbed let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); for (i, s) in d.spots.iter().enumerate() { let r = (s.size * long) as f32; let sel = Some(i) == active; + if !mode.shows(sel, live) { + continue; + } + let p = &pins; let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); for q in &s.points { p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); @@ -2100,7 +2126,7 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); grips.push((i, true, sq, r)); } - pin(&p, tq, sel); + pin(p, tq, sel); register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); grips.push((i, false, tq, r)); } diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 534a207ca..4095625f6 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -498,6 +498,9 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.checkbox(&mut pins, crate::i18n::tr("Show Pins")).changed() { let _ = app.run("view.maskPins", json!({"show": pins})); } + if app.ui.mask_pins { + super::right::tool_overlay_options(app, ui); + } } /// Controls for a range component: the selected range (two handles over a dark → light bar), diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..330b8d885 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -454,6 +454,8 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.spots_threshold = v as f32; } padded(ui, |ui| { + tool_overlay_options(app, ui); + ui.add_space(4.0); if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { for i in (0..d.spots.len()).rev() { let _ = app.run("spot.delete", json!({"index": i})); @@ -462,6 +464,28 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } +/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never; +/// Shift+H steps through them). Remove and Masking panels. +pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use crate::state::ToolOverlay; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim)); + let current = app.ui.tool_overlay; + let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label) + .on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)")); + egui::Popup::menu(&r).show(|ui| { + for m in ToolOverlay::ALL { + let item = ui.selectable_label(m == current, crate::i18n::tr(m.label())); + register(ui.ctx(), format!("toolOverlay:{}", m.name()), item.rect); + if item.clicked() { + let _ = app.run("view.toolOverlay", json!({"mode": m.name()})); + } + } + }); + }); +} + fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let d = app.session.develop_of(id).unwrap_or_default(); header(ui, "Red Eye"); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..84ac60780 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -83,6 +83,70 @@ pub enum CropOverlay { None, } +/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on +/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ToolOverlay { + /// While the pointer is over the photo (hidden while a new spot or brush stroke is painted). + #[default] + Auto, + Always, + /// Only the selected spot's or mask's. + Selected, + /// None; the tool still works (a click still adds a spot). + Never, +} + +impl ToolOverlay { + pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never]; + + /// The id used by `view.toolOverlay {mode}` and the saved settings. + pub fn name(self) -> &'static str { + match self { + ToolOverlay::Auto => "auto", + ToolOverlay::Always => "always", + ToolOverlay::Selected => "selected", + ToolOverlay::Never => "never", + } + } + + pub fn parse(s: &str) -> Option<ToolOverlay> { + ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim())) + } + + /// Menu label. + pub fn label(self) -> &'static str { + match self { + ToolOverlay::Auto => "Auto", + ToolOverlay::Always => "Always", + ToolOverlay::Selected => "Selected", + ToolOverlay::Never => "Never", + } + } + + /// The mode Shift+H steps to. + pub fn next(self) -> ToolOverlay { + match self { + ToolOverlay::Auto => ToolOverlay::Always, + ToolOverlay::Always => ToolOverlay::Selected, + ToolOverlay::Selected => ToolOverlay::Never, + ToolOverlay::Never => ToolOverlay::Auto, + } + } + + /// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer + /// is over the photo (or a pin is being dragged), which is what Auto waits for. + pub fn shows(self, selected: bool, live: bool) -> bool { + match self { + ToolOverlay::Auto => live, + ToolOverlay::Always => true, + ToolOverlay::Selected => selected, + ToolOverlay::Never => false, + } + } +} + /// Which view the app opens in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -544,6 +608,9 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, + /// When the Remove tool's spot pins and the mask pins show (Shift+H cycles). Mask pins also + /// need [`mask_pins`](Self::mask_pins). + pub tool_overlay: ToolOverlay, /// Masking: show every visible mask's region as one overlay at once (Ctrl+Shift+O / Cmd+Shift+O), /// instead of just the selected mask's ([`mask_overlay`]). pub mask_overlay_all: bool, @@ -1062,6 +1129,7 @@ impl Default for UiState { mask_overlay_color: [230, 30, 40], mask_overlay_opacity: 50.0, mask_pins: true, + tool_overlay: ToolOverlay::Auto, mask_overlay_all: false, crop_overlay: CropOverlay::Thirds, crop_default_aspect: None, diff --git a/crates/ui-egui/src/tests_tool_overlay.rs b/crates/ui-egui/src/tests_tool_overlay.rs new file mode 100644 index 000000000..35426ac44 --- /dev/null +++ b/crates/ui-egui/src/tests_tool_overlay.rs @@ -0,0 +1,259 @@ +//! Headless tests of the Tool Overlay (issue #224): when the Remove tool's spot pins and the mask +//! pins show (Auto / Always / Selected / Never), Shift+H and `view.toolOverlay` to change it. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::menubar::MenuNode; +use crate::state::{RightPanel, ToolOverlay}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn detail(panel: &str) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +/// The Remove tool with three spots (the last one selected). +fn three_spots() -> Headless { + let mut h = detail("panel.remove"); + assert_eq!(h.app.ui.right, RightPanel::Remove); + for (x, y) in [(0.25, 0.3), (0.5, 0.6), (0.75, 0.3)] { + exec(&mut h, "spot.add", json!({"mode": "heal", "points": [[x, y]], "size": 0.03})); + } + let id = h.app.session.active().expect("active photo"); + assert_eq!(h.app.session.develop_of(id).unwrap_or_default().spots.len(), 3); + assert_eq!(h.app.session.active_spot, Some(2)); + h +} + +/// Pointer over the photo (normalized image coordinates), or away from it (the top bar). +fn hover(h: &mut Headless, over: bool) { + let r = if over { + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.1, "y": 0.9}]}), T) + } else { + h.request("ui.move", json!({"x": 4.0, "y": 4.0}), T) + }; + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +/// The ids of the pins drawn this frame with `prefix` (`spotPin:`, `maskPin:`), sorted. +fn pins(h: &Headless, prefix: &str) -> Vec<String> { + let mut v: Vec<String> = h.app.widgets.iter().filter(|(id, _)| id.starts_with(prefix)).map(|(id, _)| id.clone()).collect(); + v.sort(); + v.dedup(); + v +} + +fn spot_pins(h: &Headless) -> Vec<String> { + pins(h, "spotPin:") +} + +fn all_three() -> Vec<String> { + vec!["spotPin:0".to_string(), "spotPin:1".to_string(), "spotPin:2".to_string()] +} + +#[test] +fn spot_pins_follow_each_tool_overlay_mode() { + let mut h = three_spots(); + // Auto (the default): only while the pointer is over the photo + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Auto); + hover(&mut h, false); + assert_eq!(spot_pins(&h), Vec::<String>::new(), "Auto: hidden with the pointer away"); + assert!(pins(&h, "spotSource:").is_empty()); + hover(&mut h, true); + assert_eq!(spot_pins(&h), all_three(), "Auto: shown with the pointer over the photo"); + assert_eq!(pins(&h, "spotSource:"), vec!["spotSource:2".to_string()], "the selected spot's source"); + // Always: whatever the pointer does + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + hover(&mut h, false); + assert_eq!(spot_pins(&h), all_three(), "Always: shown with the pointer away"); + // Selected: the selected spot only + exec(&mut h, "view.toolOverlay", json!({"mode": "selected"})); + hover(&mut h, true); + assert_eq!(spot_pins(&h), vec!["spotPin:2".to_string()]); + exec(&mut h, "spot.select", json!({"index": 0})); + h.step(); + assert_eq!(spot_pins(&h), vec!["spotPin:0".to_string()]); + // Never: none, even over the photo + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + hover(&mut h, true); + assert_eq!(spot_pins(&h), Vec::<String>::new()); + assert!(pins(&h, "spotSource:").is_empty()); +} + +/// Hidden pins can't be grabbed: with Never a click on a spot paints a new spot (the tool still +/// works); with Selected, clicking a hidden spot does too, while the selected one can be dragged. +#[test] +fn hidden_spot_pins_are_not_grabbed_and_the_tool_still_works() { + let mut h = three_spots(); + let spots = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap_or_default().spots.clone(); + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.25, "y": 0.3}, {"kind": "up", "x": 0.25, "y": 0.3}]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(spots(&h).len(), 4, "Never: a click adds a spot"); + assert_eq!(h.app.session.active_spot, Some(3)); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(spots(&h).len(), 3); + // Selected: the selected spot's pin still drags it + exec(&mut h, "view.toolOverlay", json!({"mode": "selected"})); + exec(&mut h, "spot.select", json!({"index": 1})); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "drag", "x": 0.52, "y": 0.6}, {"kind": "drag", "x": 0.55, "y": 0.6}, {"kind": "up", "x": 0.55, "y": 0.6}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + let moved = spots(&h)[1].points[0]; + assert!((moved.x - 0.55).abs() < 0.01 && (moved.y - 0.6).abs() < 0.01, "{moved:?}"); + assert_eq!(spots(&h).len(), 3); + // …and a click on a hidden spot adds one rather than selecting it + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.75, "y": 0.3}, {"kind": "up", "x": 0.75, "y": 0.3}]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(spots(&h).len(), 4); +} + +#[test] +fn shift_h_and_the_command_cycle_the_tool_overlay() { + let mut h = three_spots(); + // Shift+H steps Auto → Always → Selected → Never → Auto + for want in [ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never, ToolOverlay::Auto] { + let r = h.request("ui.key", json!({"key": "h", "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, want); + assert_eq!(h.app.ui.right, RightPanel::Remove, "Shift+H leaves the panel alone"); + } + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()); + assert_eq!(toast.as_deref(), Some("Tool Overlay: Auto")); + // the pins follow the key: Never hides them over the photo + for _ in 0..3 { + h.request("ui.key", json!({"key": "h", "shift": true}), T); + } + hover(&mut h, true); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Never); + assert!(spot_pins(&h).is_empty()); + // the command: no params steps, `mode` sets, a bad mode is refused and changes nothing + assert_eq!(exec(&mut h, "view.toolOverlay", json!({})), json!({"mode": "auto"})); + assert_eq!(exec(&mut h, "view.toolOverlay", json!({"mode": "Selected"})), json!({"mode": "selected"})); + for bad in [json!({"mode": "sometimes"}), json!({"mode": 3})] { + let r = h.request("engine.execute", json!({"command": "view.toolOverlay", "params": bad}), T); + assert_eq!(r["ok"], false, "{r}"); + } + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Selected); + // plain H still opens Remove (from Edit), and leaves the mode alone + exec(&mut h, "panel.edit", json!({})); + h.request("ui.key", json!({"key": "h"}), T); + assert_eq!(h.app.ui.right, RightPanel::Remove); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Selected); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.toolOverlay"), Some("Shift+H")); +} + +/// View ▸ Tool Overlay lists the modes (the current one checked) and the cycle item with its key; +/// the Remove panel's dropdown sets the mode. +#[test] +fn view_menu_and_remove_panel_choose_the_tool_overlay() { + let mut h = three_spots(); + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + let bar = crate::menubar::menu_bar(&h.app); + let view = &bar.iter().find(|(t, _)| t == "View").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = view.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Tool Overlay")) + else { + panic!("View ▸ Tool Overlay") + }; + let items: Vec<(String, serde_json::Value, Option<bool>, Option<String>)> = children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, checked, shortcut, .. } if id == "view.toolOverlay" => { + Some((id.clone(), params.clone(), *checked, shortcut.clone())) + } + _ => None, + }) + .collect(); + assert_eq!(items.len(), 5, "{items:?}"); + let checked: Vec<&serde_json::Value> = items.iter().filter(|i| i.2 == Some(true)).map(|i| &i.1).collect(); + assert_eq!(checked, vec![&json!({"mode": "never"})]); + assert_eq!(items[4].1, serde_json::Value::Null, "the last item cycles"); + assert_eq!(items[4].3.as_deref(), Some("Shift+H")); + // the menu items run the command like the shortcut does + let r = h.request("ui.menu.invoke", json!({"id": "view.toolOverlay", "params": {"mode": "always"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Always); + // the Remove panel's dropdown + let r = h.request("ui.clickWidget", json!({"id": "dropdown:toolOverlay"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "toolOverlay:selected"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Selected); +} + +/// Mask pins follow the same mode; Show Pins (`view.maskPins`) off still hides them all. +#[test] +fn mask_pins_follow_the_tool_overlay_and_show_pins() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]})); + assert_eq!(h.app.session.active_mask, Some(2)); + let both = vec!["maskPin:1:0".to_string(), "maskPin:2:0".to_string()]; + hover(&mut h, false); + assert!(pins(&h, "maskPin:").is_empty(), "Auto: hidden with the pointer away"); + hover(&mut h, true); + assert_eq!(pins(&h, "maskPin:"), both); + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + hover(&mut h, false); + assert_eq!(pins(&h, "maskPin:"), both); + exec(&mut h, "view.toolOverlay", json!({"mode": "selected"})); + h.step(); + assert_eq!(pins(&h, "maskPin:"), vec!["maskPin:2:0".to_string()]); + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + hover(&mut h, true); + assert!(pins(&h, "maskPin:").is_empty()); + // Show Pins off hides them in every mode; view.maskPins answers as before + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + assert_eq!(exec(&mut h, "view.maskPins", json!({"show": false})), json!({"maskPins": false})); + h.step(); + assert!(pins(&h, "maskPin:").is_empty()); + assert_eq!(exec(&mut h, "view.maskPins", json!({})), json!({"maskPins": true})); + h.step(); + assert_eq!(pins(&h, "maskPin:"), both); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Always, "Show Pins leaves the mode alone"); +} + +/// The mode is saved with the UI state (`ui.json`); settings from before it open in Auto. +#[test] +fn tool_overlay_is_saved_and_old_settings_open_in_auto() { + let ui = crate::UiState { tool_overlay: ToolOverlay::Selected, ..Default::default() }; + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["toolOverlay"], "selected"); + let back: crate::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(back.sanitized().tool_overlay, ToolOverlay::Selected); + let mut old = saved; + old.as_object_mut().unwrap().remove("toolOverlay"); + let back: crate::UiState = serde_json::from_value(old).unwrap(); + assert_eq!(back.tool_overlay, ToolOverlay::Auto); + // through the control channel too + let mut h = detail("panel.remove"); + let r = h.request("ui.set", json!({"toolOverlay": "never"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Never); + for m in ToolOverlay::ALL { + assert_eq!(ToolOverlay::parse(m.name()), Some(m)); + assert_eq!(serde_json::to_value(m).unwrap(), m.name()); + } + assert_eq!(ToolOverlay::parse("nope"), None); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..1d3c9a303 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEY-HIDEPINS, LR-MASK-PINS, LR-REM-SPOT-EDIT: Tool Overlay modes for spot and mask pins, ⇧H cycles (#224); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -51,11 +51,11 @@ cited here still exists. Formerly docs/parity.md. | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | +| Z. Keyboard shortcuts (desktop) | 77 | 3 | 2 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 410 | 52 | 71 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.8%** of 533 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.6% of 173. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -266,7 +266,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-REM-GEN | Generative remove | OOS | 🚫 | | | | LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | | LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) | -| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added | +| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete`, `cmd:view.toolOverlay` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added; Tool Overlay (Remove panel, View menu, ⇧H) shows the spots' pins Auto (pointer over the photo, hidden while painting) / Always / Selected / Never, and a hidden spot can't be grabbed (#224) | | LR-REM-VISUALIZE | Visualize spots | P1 | ✅ | `cmd:view.visualizeSpots` | | | LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | @@ -305,7 +305,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | -| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests_tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | @@ -675,7 +675,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`), `crates/ui-egui/src/tests_crop_tool.rs` | top-bar navigation arrowheads render both halves at 1× and 2× scale; in the crop tool Esc closes the tool first (the crop stays), the next Esc goes back to the grid; Esc that ends an edit in a text field does nothing else | | KEY-COMMIT | Commit tool — Return | P1 | ✅ | `cmd:tool.done` | Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) | | KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching | -| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel | +| KEY-HIDEPINS | Hide pins — H | P2 | ✅ | `cmd:view.toolOverlay`, `crates/ui-egui/src/tests_tool_overlay.rs` | ⇧H cycles the Tool Overlay (Auto → Always → Selected → Never) for spot and mask pins, since H opens Remove; also View ▸ Tool Overlay and the Remove / Masking panels (#224) | ## Lightroom Classic extras @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEY-HIDEPINS ⬜ → ✅: Tool Overlay (Auto / Always / Selected / Never, `view.toolOverlay`, ⇧H cycles; H stays Remove) for the Remove tool's spot pins and the mask pins (issue #224); LR-MASK-PINS loses its "no Auto mode" note, LR-REM-SPOT-EDIT notes the modes | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..84bea0df0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Tool Overlay: ⇧H cycles when spot and mask pins show, #224; accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -60,6 +60,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Expand/collapse edit sections | ⌘⌥1–5 | ⌘1–6 | ⌘0 = Zoom to Fit, ⌘1 = Zoom 100 %; Geometry lives in the Crop panel | | Histogram | ⌘⇧H | ⌘0 | ⌘0 = Zoom to Fit | | Square Grid | ⇧G | (none; ⇧G = Guided Upright) | Guided Upright is a button in the Crop panel | +| Hide / show pins (Tool Overlay) | ⇧H (cycles Auto, Always, Selected, Never) | H (Classic) | H opens Remove; View ▸ Tool Overlay and the Remove / Masking panels pick a mode | | Export dialog | ⌘⇧E (+ ⇧E) | ⇧E | ⌘⇧E is "Edit in Photoshop" there; no external-editor command yet | | Crop overlay cycle | ⇧O | O | O = mask overlay; ⇧O (mask colour / overlay orientation) unused otherwise | | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | @@ -70,7 +71,6 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). -- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -105,6 +105,7 @@ left panel, canvas, right panel, which is sensible but not tuned). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | From 115b19bdcb0a3c189e4f323d0795708274dbad94 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:40:45 +0300 Subject: [PATCH 068/315] LR-IMP-FORMATS: fuzz structurally valid CR3 containers in the robustness tests crates/raw/tests/robust.rs only mutated DNGs. Add two small valid CR3 fixtures (a 4x4 lossless CRX and a 16x16 three-level C-RAW CRX) and a property that flips bytes in them or truncates them, then runs the existing exercise() helper (probe, embedded_preview, decode, develop and the colour transform) plus probe_info, the header-only description used on import: errors or clean decodes, never a panic. Unlike random TIFF-like bytes, a structurally valid file gets past the container walk, so the mutations land in the CRX marker walker and entropy readers. A fixture test asserts that both files decode as built, so the mutations hit live decode paths. Tests only; the existing DNG property keeps its 400 cases and the new one uses proptest's default (256, PROPTEST_CASES overrides it). --- crates/raw/tests/robust.rs | 139 +++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 7bd5a5e54..f2e6426bf 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -102,3 +102,142 @@ fn hostile_dimensions_are_rejected_quickly() { assert!(lightcraft_raw::decode(&bytes).is_err()); assert!(t0.elapsed().as_secs_f64() < 2.0); } + +// --- CR3 containers (Canon CRX): structurally valid files whose every byte may be mutated --- + +/// A CR3 with a single CRAW track carrying a CMP1 descriptor (and optional CDI1/IAD1) plus raw samples. +mod cr3 { + pub(crate) fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> { + let mut out = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out + } + + fn track(entry: &[u8], offset: u64, size: u32) -> Vec<u8> { + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); + stsd.extend_from_slice(entry); + let mut stsz = vec![0u8; 4]; + stsz.extend(size.to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); + co64.extend(offset.to_be_bytes()); + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))) + } + + /// `CMP1` coding descriptor: 52 bytes, four planes, 14 bits, RGGB, `levels` wavelet levels. + pub(crate) fn cmp1(width: u32, height: u32, tile_width: u32, tile_height: u32, levels: u8, header_size: u32) -> Vec<u8> { + let mut coding = vec![0u8; 52]; + coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + coding[2..4].copy_from_slice(&48u16.to_be_bytes()); + coding[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + coding[8..12].copy_from_slice(&width.to_be_bytes()); + coding[12..16].copy_from_slice(&height.to_be_bytes()); + coding[16..20].copy_from_slice(&tile_width.to_be_bytes()); + coding[20..24].copy_from_slice(&tile_height.to_be_bytes()); + coding[24] = 14; + coding[25] = 0x40; + coding[26] = levels; + coding[28..32].copy_from_slice(&header_size.to_be_bytes()); + coding + } + + pub(crate) fn file(coding: &[u8], width: u16, height: u16, sample: &[u8]) -> Vec<u8> { + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&width.to_be_bytes()); + craw[26..28].copy_from_slice(&height.to_be_bytes()); + craw.extend(box_bytes(b"CMP1", coding)); + let entry = box_bytes(b"CRAW", &craw); + let ftyp = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + let track_len = track(&entry, 0, sample.len() as u32).len(); + let offset = (ftyp.len() + 8 + track_len + 7) & !7; + let mut out = ftyp; + out.extend(box_bytes(b"moov", &track(&entry, offset as u64, sample.len() as u32))); + out.resize(offset, 0); + out.extend_from_slice(sample); + out + } + + fn marker(out: &mut Vec<u8>, code: u16, size: u32, flags: u32) { + out.extend(code.to_be_bytes()); + out.extend(8u16.to_be_bytes()); + out.extend(size.to_be_bytes()); + out.extend(flags.to_be_bytes()); + } + + /// A 4×4 lossless CRX stream: four 2×2 planes, each sample value 1 (sensor level 8193). + pub(crate) fn lossless_sample() -> Vec<u8> { + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 4, 0); + for p in 0..4u32 { + marker(&mut sample, 0xff02, 1, (p << 28) | 0x0800_0000); + marker(&mut sample, 0xff03, 1, 4 << 19); + } + sample.extend([0x1e; 4]); + sample + } + + pub(crate) fn lossless_file() -> Vec<u8> { + file(&cmp1(4, 4, 4, 4, 0, 108), 4, 4, &lossless_sample()) + } + + /// A 16×16 three-level C-RAW CRX stream of flat zero subbands (sensor level 8192). + pub(crate) fn craw_sample() -> Vec<u8> { + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 52, 0); + for plane in 0..4u32 { + marker(&mut sample, 0xff02, 13, (plane << 28) | 0x0800_0000); + for band in 0..10u32 { + marker(&mut sample, 0xff03, if band >= 7 { 2 } else { 1 }, (band << 28) | (4 << 19)); + } + } + for _ in 0..4 { + sample.extend([0x80; 4]); + sample.extend([0xf0; 3]); + for _ in 0..3 { + sample.extend([0xff, 0xf8]); + } + } + sample + } + + pub(crate) fn craw_file() -> Vec<u8> { + file(&cmp1(16, 16, 16, 16, 3, 540), 16, 16, &craw_sample()) + } +} + +/// Both fixtures decode as built, so the mutations below hit live decode paths. +#[test] +fn cr3_fixtures_decode() { + let lossless = lightcraft_raw::decode(&cr3::lossless_file()).unwrap(); + assert_eq!((lossless.width, lossless.height, lossless.bits), (4, 4, 14)); + assert_eq!(lossless.data.get(0), 8193.0); + let craw = lightcraft_raw::decode(&cr3::craw_file()).unwrap(); + assert_eq!((craw.width, craw.height, craw.bits), (16, 16, 14)); + assert_eq!(craw.data.get(0), 8192.0); + assert_eq!(lightcraft_raw::probe_info(&cr3::craw_file()).unwrap(), craw.info()); +} + +proptest! { + /// Mutating or truncating structurally valid CR3 containers exercises the CRX marker walker and + /// entropy readers far deeper than random TIFF bytes: errors or clean decodes, never a panic. + #[test] + fn mutated_cr3_containers_never_panic( + kind in 0usize..2, + flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..24), + cut in any::<usize>(), + ) { + let mut data = if kind == 0 { cr3::lossless_file() } else { cr3::craw_file() }; + for (i, v) in flips { + let n = data.len(); + data[i % n] = v; + } + let keep = if cut % 3 == 0 { cut % data.len() } else { data.len() }; + exercise(&data[..keep]); + // The header-only description path (used when importing) must hold up against the same mutations. + let _ = lightcraft_raw::probe_info(&data[..keep]); + } +} From cb8b797cd44040fe889b146e1e05b329902c9690 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Tue, 13 Oct 2026 13:16:08 +0530 Subject: [PATCH 069/315] KEY-SLIDER-RESET: a slider's name and thumb say a double-click resets it (fixes #1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double-click on a slider's name or thumb already reset it, but nothing said so: only the value had a tooltip. Resting the pointer on the name or on the thumb now shows "Exposure — double-click to reset it", the wording (and translations) the curve channels already use. The rest of the track, which is clicked and dragged, gets no tooltip, and the value keeps "Click to type a value". The first click of a double-click on the thumb moved the value to where the pointer landed, a few points off its centre: one history step, then the reset another. A click on the thumb now leaves the value alone, so the reset is one undo step, as from the name. A disabled slider's name no longer resets it. No per-slider reset button: Lightroom has none, and the double-click it documents is now shown where it works. Tests: tests_slider_reset.rs (name and thumb reset Exposure and Contrast in one undo step, the hints, a click on the thumb keeps the value, typing a value still works) and widgets/tests_slider_reset.rs (a disabled slider's name). --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_slider_reset.rs | 151 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 29 +++- .../ui-egui/src/widgets/tests_slider_reset.rs | 36 +++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 6 + 6 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/tests_slider_reset.rs create mode 100644 crates/ui-egui/src/widgets/tests_slider_reset.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..4dd6effda 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,8 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_slider_reset; +#[cfg(test)] mod tests_switch_library; #[cfg(test)] mod tests_sync; diff --git a/crates/ui-egui/src/tests_slider_reset.rs b/crates/ui-egui/src/tests_slider_reset.rs new file mode 100644 index 000000000..6d0357fcb --- /dev/null +++ b/crates/ui-egui/src/tests_slider_reset.rs @@ -0,0 +1,151 @@ +//! Headless tests of resetting a slider (issue #1436): double-clicking its name or its thumb puts it +//! back to its default in one undo step, as in Lightroom, and resting the pointer on either says so. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); +/// The sliders the issue names, each set away from its default. +const EDITED: [(&str, f64); 2] = [("light.exposure", 1.25), ("light.contrast", 40.0)]; + +/// The demo library in Detail with the Edit panel's Light section open. +fn edit_panel() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0].0; + assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true); + assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string()]; + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn value(h: &Headless, control: &str) -> f64 { + let id = h.app.session.selection.active.unwrap(); + lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap() +} + +fn default(control: &str) -> f64 { + lightcraft_develop::controls::find(control).unwrap().default +} + +fn ask(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params.clone(), T); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); +} + +fn set(h: &mut Headless, control: &str, value: f64) { + ask(h, "engine.execute", json!({"command": "develop.set", "params": {"control": control, "value": value}})); +} + +/// The thumb's place along the track (0..1), plus `dx` points: a hand lands near its centre, not on it. +fn thumb_fx(h: &Headless, control: &str, dx: f32) -> f32 { + let spec = lightcraft_develop::controls::find(control).unwrap(); + let track = widget(h, &format!("slider:{control}")); + spec.position(value(h, control)) as f32 + dx / track.width() +} + +/// A double-click on a widget: the control channel plays each press and release in a frame of its +/// own, so the slider sees the first click before the second, as with a hand on a mouse. +fn double_click(h: &mut Headless, id: &str, fx: f32) { + ask(h, "ui.clickWidget", json!({"id": id, "count": 2, "fx": fx})); +} + +#[test] +fn double_clicking_a_sliders_name_resets_it_in_one_step() { + let mut h = edit_panel(); + for (control, edited) in EDITED { + set(&mut h, control, edited); + let undo = h.app.session.undo.len(); + double_click(&mut h, &format!("sliderLabel:{control}"), 0.1); + assert_eq!(value(&h, control), default(control), "{control} is back at its default"); + assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo"})); + assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back"); + } +} + +/// The first click of the double-click lands a few points off the thumb's centre: it leaves the value +/// alone (it used to move it there, a history step of its own before the reset). +#[test] +fn double_clicking_a_sliders_thumb_resets_it_in_one_step() { + let mut h = edit_panel(); + for (control, edited) in EDITED { + set(&mut h, control, edited); + let undo = h.app.session.undo.len(); + let fx = thumb_fx(&h, control, 4.0); + double_click(&mut h, &format!("slider:{control}"), fx); + assert_eq!(value(&h, control), default(control), "{control} is back at its default"); + assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo"})); + assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back"); + } +} + +/// A click on the thumb leaves the value alone; a click elsewhere on the track still moves it there. +#[test] +fn a_click_on_the_thumb_keeps_the_value() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + let fx = thumb_fx(&h, "light.exposure", -4.0); + ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": fx})); + assert_eq!(value(&h, "light.exposure"), 1.25); + ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": 0.25})); + assert!((value(&h, "light.exposure") - -2.5).abs() < 0.02, "{}", value(&h, "light.exposure")); +} + +#[test] +fn a_sliders_name_and_thumb_say_a_double_click_resets_it() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + let hint = "Exposure — double-click to reset it"; + let shown = |h: &Headless| h.painted_text().iter().any(|t| t == hint); + let thumb = thumb_fx(&h, "light.exposure", 0.0); + for (what, id, fx) in [("name", "sliderLabel:light.exposure", 0.1), ("thumb", "slider:light.exposure", thumb)] { + // somewhere else first, so the hint of the step before goes + ask(&mut h, "ui.hoverWidget", json!({"id": "sliderLabel:light.contrast", "fx": 0.9})); + assert!(!shown(&h), "{what}: no hint left over"); + ask(&mut h, "ui.hoverWidget", json!({"id": id, "fx": fx})); + // tooltips wait for the pointer to rest + assert!( + h.step_until(Duration::from_secs(5), shown), + "{what}: {:?}", + h.painted_text().iter().filter(|t| t.contains("reset")).collect::<Vec<_>>() + ); + } + // the rest of the track is for dragging and clicking: no hint there + ask(&mut h, "ui.hoverWidget", json!({"id": "slider:light.exposure", "fx": 0.1})); + for _ in 0..90 { + h.step(); + } + assert!(!shown(&h), "no hint away from the thumb"); + // the value keeps its own hint + ask(&mut h, "ui.hoverWidget", json!({"id": "sliderValue:light.exposure"})); + assert!(h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t == "Click to type a value"))); +} + +/// Typing a value (KEY-SLIDER-TYPE, #1362) still works next to the reset. +#[test] +fn a_typed_value_still_applies_after_a_reset() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + double_click(&mut h, "sliderLabel:light.exposure", 0.1); + assert_eq!(value(&h, "light.exposure"), 0.0); + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + for (method, params) in [("ui.text", json!({"text": "0.5"})), ("ui.key", json!({"key": "Enter"}))] { + ask(&mut h, method, params); + } + assert_eq!(value(&h, "light.exposure"), 0.5); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..9a9f86c36 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -176,9 +176,12 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { if shown == "+0" || shown == "-0" { "0".to_string() } else { shown } } +/// The radius of a slider's hollow ring thumb. +const THUMB_RING: f32 = 7.0; + /// A Lightroom slider row (label + value above a track with a hollow ring thumb). -/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to -/// type one: Return or clicking away applies it, Esc keeps the old one. +/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine +/// adjustment. Click the value to type one: Return or clicking away applies it, Esc keeps the old one. pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -214,7 +217,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ node.add_action(egui::accesskit::Action::Decrement); } }); - let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); + // the name: double-click it to reset, as the thumb (a disabled slider's name is only text) + let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() }); + // say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do + let reset_hint = crate::i18n::tr_format!("{name} — double-click to reset it", name = label_text); + let label_resp = if enabled { label_resp.on_hover_text(reset_hint.as_str()) } else { label_resp }; + register(ui.ctx(), format!("sliderLabel:{}", spec.id), label_rect); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one (over the label, so it takes the click) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); @@ -241,6 +249,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); + // the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest + // of the track, which is clicked and dragged, gets no tooltip) + let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0; + if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) { + resp.clone().on_hover_text(reset_hint.as_str()); + } let mut v = value; if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. @@ -313,6 +327,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } else if resp.clicked() && let Some(p) = resp.interact_pointer_pos() + // a click on the thumb leaves it there, so the first click of a double-click on it isn't a + // history step of its own before the reset + && !on_thumb(p) { let step = spec.step.max(1e-9); let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max); @@ -391,7 +408,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } } - let ring = 7.0; + let ring = THUMB_RING; let tx = to_x(v); paint_track(ui, track_rect, &spec.track, &t, tx, ring); let p = ui.painter(); @@ -848,6 +865,10 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; +#[cfg(test)] +#[path = "widgets/tests_slider_reset.rs"] +mod slider_reset_tests; + fn focused_slider_key() -> egui::Id { egui::Id::new("lightcraft-focused-slider") } diff --git a/crates/ui-egui/src/widgets/tests_slider_reset.rs b/crates/ui-egui/src/widgets/tests_slider_reset.rs new file mode 100644 index 000000000..091768ec7 --- /dev/null +++ b/crates/ui-egui/src/widgets/tests_slider_reset.rs @@ -0,0 +1,36 @@ +//! Real egui pointer frames on a disabled slider: its name doesn't reset it (issue #1436). +use super::{SliderOut, slider, take_registry}; +use crate::headless::HeadlessView; +use egui::{Event, Modifiers, PointerButton, Rect, vec2}; +use lightcraft_develop::controls; + +fn frame(view: &mut HeadlessView, time: f64, enabled: bool, events: Vec<Event>) -> (SliderOut, Rect) { + let spec = controls::find("light.exposure").unwrap(); + let mut out = SliderOut::default(); + view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, time, events), |ui| { + out = slider(ui, spec, 1.25, enabled, None); + }); + let name = take_registry(&view.ctx).into_iter().find(|(id, _)| id == "sliderLabel:light.exposure").unwrap().1; + (out, name) +} + +#[test] +fn a_disabled_sliders_name_ignores_a_double_click() { + for enabled in [true, false] { + let mut view = HeadlessView::new(); + let mut time = 0.0; + let (_, name) = frame(&mut view, time, enabled, vec![]); + let pos = name.left_center() + vec2(8.0, 0.0); + let mut reset = false; + for pressed in [None, Some(true), Some(false), Some(true), Some(false), None] { + time += 0.02; + let events = match pressed { + None => vec![Event::PointerMoved(pos)], + Some(pressed) => vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }], + }; + let (out, _) = frame(&mut view, time, enabled, events); + reset |= out.reset || out.value.is_some(); + } + assert_eq!(reset, enabled, "enabled: {enabled}"); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..ba1babb61 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEY-SLIDER-RESET: a slider's name and thumb say a double-click resets it, one undo step (#1436); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -666,7 +666,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | `cmd:view.cropOverlayOrientation` | while cropping: O cycles the overlay, ⇧O its orientation | | KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active | -| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | +| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/tests_slider_reset.rs` | double-click a slider's name or thumb: back to its default as one undo step (a click on the thumb no longer moves it first, which made it two); resting the pointer on either says so (“Exposure — double-click to reset it”, as the curve channels), the value keeps “Click to type a value” (issue #1436); a disabled slider's name doesn't reset it. No per-slider reset button (Lightroom has none); no ⌥ panel headings turning into Reset buttons yet (`cmd:develop.resetSection` is command-only) | | KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..86e626fe0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-13 +### Resetting a slider +- Resting the pointer on a slider's name or its thumb now says that a double-click resets it ("Exposure — double-click + to reset it"), in every language (issue #1436). +- A double-click on the thumb is a single step in History and Undo. Its first click no longer nudges the value to + where the pointer landed, and a single click on the thumb leaves the value alone. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From be592cf2b1a1a31ff066889823e826e6f6f38684 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:54:26 +0300 Subject: [PATCH 070/315] LR-IMP-FORMATS: CI-enforce the unsupported CRX variants, listed in docs/cr3.md docs/cr3.md gains a variant-status table: every supported and rejected CRX coding variant, the function that gates it and the exact Unsupported text. crates/raw/tests/cr3_variants.rs enforces the rejections (19 tests). Each rejected variant is rebuilt as a small synthetic CR3 (container, CMP1 descriptor, CRX stream) and the test asserts the exact string, and that probe_info (the import path) and decode return the same error. Control fixtures decode through the same container, including both edges of the accepted uniform quantization range (4..=44) and adaptive QP range (131..=168), so a rejection can only come from the documented gate. The table itself is prose and is not checked against the tests. The doc said only QP values 131-167 were accepted; the decoder also accepts 168 (quantization value 44, step 102), so that sentence now says so. Tests and docs only; no decoder change. --- crates/raw/tests/cr3_variants.rs | 426 +++++++++++++++++++++++++++++++ docs/cr3.md | 47 +++- 2 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 crates/raw/tests/cr3_variants.rs diff --git a/crates/raw/tests/cr3_variants.rs b/crates/raw/tests/cr3_variants.rs new file mode 100644 index 000000000..b338228d9 --- /dev/null +++ b/crates/raw/tests/cr3_variants.rs @@ -0,0 +1,426 @@ +//! The unsupported CRX variant list of `docs/cr3.md`, enforced in CI with synthetic fixtures. +//! +//! Each test wraps a hand-built CRX stream in a minimal but structurally valid CR3 container +//! (`ftyp` + `moov` → `trak` → `stbl` with a `CRAW` entry carrying a `CMP1` descriptor), like the unit +//! fixtures in `vendor/crx.rs` but driven through the public container API. For every unsupported +//! coding variant it asserts the exact `RawError::Unsupported` string, and that header probing +//! (`probe_info`, the import path) and full decoding fail with the *identical* error: when the engine +//! falls back to the embedded preview it shows the reason from either call (`engine/files.rs` +//! `preview_reason`), so the two layers must agree on it. Control fixtures decode successfully, +//! including both edges of each accepted range, so a rejection below can only come from the +//! documented gate. +//! +//! The accepted ranges are the ones in `vendor/crx.rs`: uniform quantization values 4..=44 and +//! adaptive QP values 131..=168. Odd QP-map heights are not a rejected variant any more (the last +//! QP row is used on its own); the unit tests in `vendor/crx.rs` cover them. The deeper +//! `"Canon CRX adaptive low-frequency quantization"` dequantization gate is unreachable through +//! this API: plane validation rejects any gain on the low-frequency bands first. It is defence in +//! depth, not a contract, and has no test here. + +use lightcraft_raw::{RawData, RawError, decode, probe_info}; + +// --- minimal CR3 container ------------------------------------------------- + +fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> { + let mut out = (u32::try_from(body.len() + 8).unwrap()).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out +} + +/// A 52-byte `CMP1` descriptor: 4×4 single-tile 14-bit four-plane RGGB lossless, 108 CRX header bytes. +fn cmp1() -> Vec<u8> { + let mut c = vec![0u8; 52]; + c[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + c[2..4].copy_from_slice(&48u16.to_be_bytes()); // base length 48 + the 4-byte header + c[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + for (at, value) in [(8, 4u32), (12, 4), (16, 4), (20, 4)] { + c[at..at + 4].copy_from_slice(&value.to_be_bytes()); // width, height, tile width, tile height + } + c[24] = 14; // bit depth + c[25] = 0x40; // four planes, CFA 0 (RGGB) + c[28..32].copy_from_slice(&108u32.to_be_bytes()); + c +} + +fn set_words(coding: &mut [u8], words: [(usize, u32); 5]) { + for (at, value) in words { + coding[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } +} + +/// 16×16 single-tile three-level 0x100 C-RAW, 540 CRX header bytes. +fn craw_coding() -> Vec<u8> { + let mut c = cmp1(); + set_words(&mut c, [(8, 16), (12, 16), (16, 16), (20, 16), (28, 540)]); + c[26] = 3; // encoding 0, three wavelet levels + c +} + +/// The same image coded as adaptive-quantized 0x200 C-RAW, 872 CRX header bytes. +fn adaptive_coding() -> Vec<u8> { + let mut c = craw_coding(); + c[4..6].copy_from_slice(&0x200u16.to_be_bytes()); + c[28..32].copy_from_slice(&872u32.to_be_bytes()); + c +} + +/// A `CMP1` with the optional median-precision extension (`type 3` burst coding), which `supports()` rejects. +fn cmp1_with_median_precision() -> Vec<u8> { + let mut c = cmp1(); + c[32] = 0x80; // extension present + let mut extension = vec![0u8; 36]; + extension[0..4].copy_from_slice(&36u32.to_be_bytes()); + extension[4] = 0x40; // median precision present + extension[32] = 10; // the precision itself + c.extend(extension); + c +} + +/// Wrap a `CMP1` descriptor and its CRX sample in a one-track CR3 file. The `CRAW` entry's 16-bit +/// dimensions mirror the descriptor; decoding is driven by `CMP1` alone. +fn cr3_file(coding: &[u8], sample: &[u8]) -> Vec<u8> { + let dimension = |at: usize| u32::from_be_bytes(coding[at..at + 4].try_into().unwrap()) as u16; + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&dimension(8).to_be_bytes()); + craw[26..28].copy_from_slice(&dimension(12).to_be_bytes()); + craw.extend(box_bytes(b"CMP1", coding)); + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); // entry count + stsd.extend(box_bytes(b"CRAW", &craw)); + let mut stsz = vec![0u8; 4]; + stsz.extend(u32::try_from(sample.len()).unwrap().to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); // one sample + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); // entry count + co64.extend([0u8; 8]); // sample offset, patched once the layout is known + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + let trak = box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))); + let mut file = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(box_bytes(b"moov", &trak)); + let offset = file.len(); + file.extend_from_slice(sample); + let at = file.len() - sample.len() - 8; // the co64 offset field ends the moov + file[at..at + 8].copy_from_slice(&u64::try_from(offset).unwrap().to_be_bytes()); + file +} + +// --- CRX stream construction (the crx.rs fixtures, rebuilt here) ------------ + +fn marker_body(size: u32, flags: u32) -> Vec<u8> { + [size.to_be_bytes(), flags.to_be_bytes()].concat() +} + +fn marker(out: &mut Vec<u8>, code: u16, body: &[u8]) { + out.extend(code.to_be_bytes()); + out.extend(u16::try_from(body.len()).unwrap().to_be_bytes()); + out.extend_from_slice(body); +} + +/// Four 2×2 lossless planes at coefficient 1 (decodes to 8193 everywhere), with overridable +/// marker codes and flag bits so each variant gate can be aimed at precisely one field. +fn lossless_stream(tile: u16, plane: u16, band: u16, plane_flags: u32, band_flags: u32) -> Vec<u8> { + let mut out = Vec::new(); + marker(&mut out, tile, &marker_body(4, 0)); + for p in 0..4u32 { + marker(&mut out, plane, &marker_body(1, (p << 28) | 0x0800_0000 | plane_flags)); + marker(&mut out, band, &marker_body(1, 0x0020_0000 | band_flags)); + } + out.extend([0x1e; 4]); // per plane, one byte: row 0 = run flag 0, Rice 001 (+1), Rice 1 (0); row 1 = full-row run; zero alignment + out +} + +/// Single-tile 16×16 three-level C-RAW with uniform quantization `quant` in every band +/// (quant 4 decodes to 8192 everywhere). +fn craw_stream(quant: u8) -> Vec<u8> { + let mut out = Vec::new(); + marker(&mut out, 0xff01, &marker_body(52, 0)); // four 13-byte planes + for plane in 0..4u32 { + marker(&mut out, 0xff02, &marker_body(13, (plane << 28) | 0x0800_0000)); + for band in 0..10u32 { + marker(&mut out, 0xff03, &marker_body(u32::from(band >= 7) + 1, (band << 28) | (u32::from(quant) << 19))); + } + } + for _ in 0..4 { + out.extend([0x80; 4]); // LL3 and the three 1×1 HF3 bands + out.extend([0xf0; 3]); // three 2×2 all-zero HF2 bands + for _ in 0..3 { + out.extend([0xff, 0xf8]); // three 4×4 all-zero HF1 bands + } + } + out +} + +/// Accepted adaptive-quantization gains: none on the low-frequency bands. +const ADAPTIVE_GAINS: [u32; 10] = [0, 0, 0, 0, 4, 4, 8, 8, 8, 16]; +/// A five-byte QP map for the 1×4 map of a 16×16 image: the 32-bit literal 300 (QP 150 from a +/// prediction of zero), then three more rows at 150. +const ADAPTIVE_QP: [u8; 5] = [0, 0, 1, 0x2c, 0x94]; + +/// `ADAPTIVE_QP` with another first symbol. It is a 32-bit literal, mapped like every Rice-coded +/// symbol: even literals give `literal / 2`, odd ones `-(literal + 1) / 2`. The three zero residuals +/// that follow stay valid for any literal of 6 or more, which leaves the Rice parameter at 2. +fn qp_with_literal(literal: u32) -> [u8; 5] { + let mut qp = ADAPTIVE_QP; + qp[0..4].copy_from_slice(&literal.to_be_bytes()); + qp +} + +/// Single-tile 16×16 adaptive-quantized 0x200 C-RAW (decodes to 8192 everywhere), with the +/// per-band gains, the two reserved header fields and the QP map bytes under test control. +fn adaptive_stream(gains: &[u32; 10], tile_reserved: u16, band_reserved: u16, qp: [u8; 5]) -> Vec<u8> { + let mut out = Vec::new(); + let mut tile = Vec::new(); + tile.extend(60u32.to_be_bytes()); // tile data size + tile.extend(0x4000u32.to_be_bytes()); // index 0, adaptive-tile magic + tile.extend(5u32.to_be_bytes()); // QP map length + tile.extend(3u16.to_be_bytes()); // QP padding + tile.extend(tile_reserved.to_be_bytes()); + marker(&mut out, 0xff11, &tile); + for plane in 0..4u32 { + marker(&mut out, 0xff12, &marker_body(13, (plane << 28) | 0x0800_0000)); + for (band, &gain) in gains.iter().enumerate() { + let mut body = Vec::new(); + body.extend(u32::from(band >= 7).wrapping_add(1).to_be_bytes()); // band size + body.extend(((band as u32) << 28 | gain).to_be_bytes()); + body.extend(u32::from(band < 4).to_be_bytes()); // LL3 base quantization + body.extend([0u8; 2]); // band padding + body.extend(band_reserved.to_be_bytes()); + marker(&mut out, 0xff13, &body); + } + } + out.extend([0u8; 4]); // zero padding between the marker tree and the data area + out.extend(qp); + out.extend([0u8; 3]); // QP padding + for _ in 0..4 { + out.extend([0x80; 4]); + out.extend([0xf0; 3]); + for _ in 0..3 { + out.extend([0xff, 0xf8]); + } + } + out +} + +// --- assertions ------------------------------------------------------------- + +/// `probe_info` (Mode::Header, the import path) and `decode` (Mode::Full) must both reject the +/// file with exactly this `Unsupported` message: same error class, same text. +fn assert_rejected(name: &str, file: &[u8], expected: &str) { + let probed = match probe_info(file) { + Ok(_) => panic!("{name}: probe_info unexpectedly succeeded"), + Err(error) => error, + }; + let full = match decode(file) { + Ok(_) => panic!("{name}: decode unexpectedly succeeded"), + Err(error) => error, + }; + assert_eq!(probed, full, "{name}: probe_info and decode must report the same error"); + assert!(matches!(&probed, RawError::Unsupported(why) if why == expected), "{name}: expected Unsupported(\"{expected}\"), got {probed:?}"); +} + +// --- controls --------------------------------------------------------------- + +#[test] +fn control_lossless_fixture_decodes_through_the_container() { + let file = cr3_file(&cmp1(), &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)); + let raw = decode(&file).unwrap(); + assert_eq!((raw.width, raw.height, raw.bits, raw.cpp), (4, 4, 14, 1)); + assert_eq!(raw.data, RawData::U16(vec![8193; 16])); + assert_eq!(probe_info(&file).unwrap(), raw.info()); +} + +#[test] +fn control_craw_and_adaptive_fixtures_decode_through_the_container() { + let craw = cr3_file(&craw_coding(), &craw_stream(4)); + let raw = decode(&craw).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256])); + assert_eq!(probe_info(&craw).unwrap(), raw.info()); + let adaptive = cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP)); + let raw = decode(&adaptive).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256])); + assert_eq!(probe_info(&adaptive).unwrap(), raw.info()); +} + +// --- supports() gates ------------------------------------------------------- + +#[test] +fn unknown_version_and_wavelet_level_counts_are_unsupported() { + let mut coding = cmp1(); + coding[4..6].copy_from_slice(&0x300u16.to_be_bytes()); + assert_rejected( + "version 0x300", + &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), + "Canon CRX version 0x300, encoding 0, 4 planes, 0 wavelet levels", + ); + let mut coding = craw_coding(); + coding[26] = 2; // two wavelet levels: neither lossless nor three-level C-RAW + assert_rejected("two wavelet levels", &cr3_file(&coding, &craw_stream(4)), "Canon CRX version 0x100, encoding 0, 4 planes, 2 wavelet levels"); +} + +#[test] +fn bit_depth_outside_8_to_16_is_unsupported() { + // CMP1 parsing only accepts depths 1..=16, so the reachable side of the gate is 1..=7. + let mut coding = cmp1(); + coding[24] = 7; + assert_rejected("7-bit", &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), "Canon CRX 7-bit samples"); +} + +#[test] +fn vertical_craw_tiling_is_unsupported() { + let mut coding = craw_coding(); + coding[20..24].copy_from_slice(&8u32.to_be_bytes()); // two tile rows + assert_rejected("vertical tiles", &cr3_file(&coding, &craw_stream(4)), "Canon CRX C-RAW with vertical tiles"); +} + +#[test] +fn v200_requires_a_single_tile_14_bit_image() { + let mut coding = adaptive_coding(); + coding[24] = 12; + assert_rejected( + "0x200 12-bit", + &cr3_file(&coding, &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP)), + "Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image", + ); + let mut coding = adaptive_coding(); + coding[16..20].copy_from_slice(&8u32.to_be_bytes()); // two tile columns + assert_rejected( + "0x200 two tiles", + &cr3_file(&coding, &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP)), + "Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image", + ); +} + +#[test] +fn extended_median_bit_depth_header_is_unsupported() { + assert_rejected( + "median extension", + &cr3_file(&cmp1_with_median_precision(), &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), + "Canon CRX extended median-bit-depth header", + ); +} + +// --- validate_planes gates -------------------------------------------------- + +#[test] +fn rounded_plane_coding_is_unsupported() { + assert_rejected("rounded plane", &cr3_file(&cmp1(), &lossless_stream(0xff01, 0xff02, 0xff03, 1 << 25, 0)), "Canon CRX plane coding flags"); +} + +#[test] +fn partial_subband_coding_is_unsupported() { + assert_rejected( + "partial subband", + &cr3_file(&cmp1(), &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0x0800_0000)), + "Canon CRX partial subband coding", + ); +} + +#[test] +fn adaptive_gains_on_low_frequency_bands_are_unsupported() { + // Plane validation rejects any gain on bands 0-3 before decoding. The deeper dequantization + // gate behind it ("Canon CRX adaptive low-frequency quantization") is therefore unreachable + // through the public API; this asserts the reachable gate's exact string. + let mut gains = ADAPTIVE_GAINS; + gains[0] = 4; + assert_rejected( + "LL band gain", + &cr3_file(&adaptive_coding(), &adaptive_stream(&gains, 0, 0, ADAPTIVE_QP)), + "Canon CRX adaptive quantization subband parameters", + ); +} + +#[test] +fn uniform_quantization_outside_4_to_44_is_unsupported() { + for quant in [3u8, 45] { + assert_rejected(&format!("quant {quant}"), &cr3_file(&craw_coding(), &craw_stream(quant)), &format!("Canon CRX quantization value {quant}")); + } +} + +#[test] +fn uniform_quantization_edges_of_the_accepted_range_decode() { + // Zero coefficients decode to the mid level whatever the step, so only the gate decides. + for quant in [4u8, 44] { + let file = cr3_file(&craw_coding(), &craw_stream(quant)); + let raw = decode(&file).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256]), "quant {quant}"); + assert_eq!(probe_info(&file).unwrap(), raw.info(), "quant {quant}"); + } +} + +// --- qp_map gate ------------------------------------------------------------ + +#[test] +fn adaptive_qp_outside_131_to_168_is_unsupported() { + // Literal 260 → 130 and 338 → 169 sit just outside the accepted range; literal 5 → −3 is far outside. + for (literal, value) in [(260u32, 130i32), (338, 169), (5, -3)] { + assert_rejected( + &format!("QP {value}"), + &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, qp_with_literal(literal))), + &format!("Canon CRX adaptive QP value {value}"), + ); + } +} + +#[test] +fn adaptive_qp_edges_of_the_accepted_range_decode() { + // Literal 262 → QP 131 and 336 → QP 168; zero coefficients decode to the mid level whatever the step. + for (literal, value) in [(262u32, 131i32), (336, 168)] { + let file = cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, qp_with_literal(literal))); + let raw = decode(&file).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256]), "QP {value}"); + assert_eq!(probe_info(&file).unwrap(), raw.info(), "QP {value}"); + } +} + +// --- layout() marker gates -------------------------------------------------- + +#[test] +fn ff1x_marker_family_inside_a_0x100_stream_is_unsupported() { + for (name, tile, plane, band) in + [("ff11 tile", 0xff11, 0xff02, 0xff03), ("ff12 plane", 0xff01, 0xff12, 0xff03), ("ff13 band", 0xff01, 0xff02, 0xff13)] + { + assert_rejected(name, &cr3_file(&cmp1(), &lossless_stream(tile, plane, band, 0, 0)), "Canon CRX ff11/ff12/ff13 marker family"); + } +} + +#[test] +fn marker_header_length_mismatch_is_unsupported() { + let mut stream = lossless_stream(0xff01, 0xff02, 0xff03, 0, 0); + stream[2..4].copy_from_slice(&12u16.to_be_bytes()); // tile header claims 12 body bytes, 8 expected + assert_rejected("0x100 tile length", &cr3_file(&cmp1(), &stream), "Canon CRX marker 0xff01 header length 12"); + let mut stream = adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP); + stream[2..4].copy_from_slice(&8u16.to_be_bytes()); // tile header claims 8 body bytes, 16 expected + assert_rejected("0x200 tile length", &cr3_file(&adaptive_coding(), &stream), "Canon CRX marker 0xff11 header length 8"); +} + +#[test] +fn nonzero_reserved_fields_in_0x200_headers_are_unsupported() { + assert_rejected( + "tile reserved", + &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 1, 0, ADAPTIVE_QP)), + "Canon CRX adaptive quantization tile header", + ); + assert_rejected( + "band reserved", + &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 1, ADAPTIVE_QP)), + "Canon CRX adaptive subband reserved field", + ); +} + +// --- vendor/cr3.rs adapter gate --------------------------------------------- + +#[test] +fn non_four_plane_codings_are_rejected_at_the_adapter() { + // CMP1 parsing allows one plane (with any CFA), which the adapter rejects before the CRX layer. + let mut coding = cmp1(); + coding[25] = 0x10; // one plane + assert_rejected("one plane", &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), "Canon CR3 encoding 0 with 1 planes"); +} + +#[test] +fn non_crx_encodings_are_rejected_at_the_adapter() { + let mut coding = cmp1(); + coding[26] = 0x10; // encoding 1, still zero wavelet levels + assert_rejected("encoding 1", &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), "Canon CR3 encoding 1 with 4 planes"); +} diff --git a/docs/cr3.md b/docs/cr3.md index 138b5d463..68dc072c2 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -36,6 +36,44 @@ and only four bodies are verified, so a CR3 never fails to import or load where a preview exists. HEVC-only preview tracks are recognized but are not decoded. +## Variant status + +The supported and rejected CRX coding variants. Every rejection is an explicit `RawError::Unsupported` whose +text is part of the contract: `crates/raw/tests/cr3_variants.rs` rebuilds each variant as a synthetic CR3 +fixture and asserts the exact string, and that header probing (`probe_info`, the import path) and full +decoding fail with the same error, because the engine shows that reason when it falls back to the embedded +preview. The same tests decode a control fixture at both edges of each accepted range, so a rejection can +only come from the gate named here. + +| Variant | Status | Gate | Exact rejection | +|---|---|---|---| +| Version `0x100`, four Bayer planes, encoding 0, wavelet levels 0 (lossless) or 3 (C-RAW, horizontal tiles) | Supported | `supports()` in `vendor/crx.rs` | none | +| Version `0x200`: single-tile 14-bit three-level C-RAW with adaptive quantization, QP map of any height (an odd height leaves the last QP row without a partner and it is used on its own), QP values 131–168 | Supported | `supports()`, `qp_map()` | none | +| Version other than `0x100`/`0x200`, or wavelet levels other than 0 and 3 | Rejected | `supports()` | `Canon CRX version {version:#x}, encoding {encoding}, {planes} planes, {levels} wavelet levels` | +| Bit depth outside 8–16 (the `CMP1` parser only accepts 1–16, so 1–7 is what can arrive) | Rejected | `supports()` | `Canon CRX {bits}-bit samples` | +| C-RAW with vertical tiles (tile height below the image height, three levels) | Rejected | `supports()` | `Canon CRX C-RAW with vertical tiles` | +| Version `0x200` that is not a single-tile 14-bit C-RAW | Rejected | `supports()` | `Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image` | +| Extended `CMP1` header with a median bit depth (used by type-3 burst coding) | Rejected | `supports()` | `Canon CRX extended median-bit-depth header` | +| Rounded-plane coding or unexpected plane flags | Rejected | `validate_planes()` | `Canon CRX plane coding flags` | +| Partial-subband coding | Rejected | `validate_planes()` | `Canon CRX partial subband coding` | +| Adaptive quantization gain on a low-frequency band (bands 0–3) | Rejected | `validate_planes()` | `Canon CRX adaptive quantization subband parameters` | +| Uniform quantization value outside 4–44 | Rejected | `quant_step()`, called from `validate_planes()` | `Canon CRX quantization value {value}` | +| Adaptive QP value outside 131–168 | Rejected | `qp_map()` | `Canon CRX adaptive QP value {value}` | +| `ff11`/`ff12`/`ff13` markers in a `0x100` stream | Rejected | `layout()` | `Canon CRX ff11/ff12/ff13 marker family` | +| Marker header of the wrong length (8 bytes expected for `ff01`, `ff02`, `ff03` and `ff12`, 16 for `ff11` and `ff13`) | Rejected | `layout()` | `Canon CRX marker {marker:#x} header length {size}` | +| `0x200` tile header with a wrong magic, a nonzero reserved field, an empty QP map or QP padding above 7 | Rejected | `layout()` | `Canon CRX adaptive quantization tile header` | +| `0x200` band header with a nonzero reserved field | Rejected | `layout()` | `Canon CRX adaptive subband reserved field` | +| Container adapter: a track that is not four planes with encoding 0 | Rejected | `decode()` in `vendor/cr3.rs` | `Canon CR3 encoding {encoding} with {planes} planes` | + +`supports()` also rejects encoding other than 0, a plane count other than 4 and a CFA pattern above 3, but +through a CR3 container the adapter rejects the first two before the CRX layer is reached, and the `CMP1` +parser rejects the third for four-plane tracks. A descriptor the parser cannot accept at all (a zero +dimension, a tile larger than the image, a bit depth outside 1–16, a plane count outside 1–4 or a zero +header size) is reported as `Corrupt` (`CR3 raw track has an invalid CMP1 descriptor`), not as unsupported. +A gain on a low-frequency band cannot reach `adaptive_step()`, which has its own rejection +(`Canon CRX adaptive low-frequency quantization`): `validate_planes()` refuses it first. That gate is defence +in depth and is not tested separately. + ## Verified sensor decoding CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers @@ -46,7 +84,7 @@ its adaptive quantization map. C-RAW is quantized, so next to clipped highlights its reconstruction can overshoot the sensor range by a few quantization steps (up to 36 codes above 16383 in 17 CC0 files); those samples are clamped to the CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data. -Other unverified coding variants return explicit unsupported errors. Header probing validates the same coding variants and +Other unverified coding variants return explicit unsupported errors (the table above lists them all). Header probing validates the same coding variants and tile/plane layout as full decoding. `crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a @@ -84,8 +122,9 @@ files are reported as skipped by the tests. The version `0x200` QP predictor and entropy framing were checked independently. Controlled input mutations establish floor averaging of paired QP rows before the nonlinear integer table, then multiplication, division by eight, addition -of the subband base and a minimum step of one. QP values 131–167 are verified; -unmeasured values are rejected. +of the subband base and a minimum step of one. QP values 131–167 are verified; 168 (quantization +value 44, step 102, measured at about 100 on EOS R50 C-RAW files) continues the sequence and is +accepted. Other values are rejected. The QP map is coded as follows (established on the EOS R5 Mark II). Its first symbol is a 32-bit big-endian literal, mapped like every other symbol and added to a prediction of zero. Every later @@ -124,7 +163,7 @@ and 6400), whose developed images match visually and whose sensor means agree within 0.1% at ISO 6400. Files with even-height maps never reach this rule, so the six verified fixtures are unchanged. Partial-subband and rounded-plane coding, vertical C-RAW tiling and adaptive low-frequency gains -remain unsupported. The decoder bounds image dimensions, sample count, +remain unsupported (see the variant table above; `cr3_variants.rs` pins each rejection). The decoder bounds image dimensions, sample count, tile count, marker headers and temporary allocations. The full sensor buffer is allocated only after the first plane decodes, and plane buffers grow row by row, so a small crafted file fails before reserving memory for the image it claims. From fe9e6e3433f6e56f860ccfd64bfc5622f807fd49 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Tue, 13 Oct 2026 02:44:22 -0400 Subject: [PATCH 071/315] LR-RAW-NEF: size the NRW word packing by the row, not by the padded strip (P7000 stripes) --- crates/raw/src/vendor/nef.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 695d55712..ad792b2e4 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -232,7 +232,8 @@ fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&ma let packing = if bytes_per_row >= row_samples * 2 { Packing::Word16 } else if bits == 12 && bytes_per_row * 8 >= row_samples * 12 && bytes_per_row * 8 < row_samples * 13 { - if nrw_data(mn).is_some() && bytes_per_row.is_multiple_of(4) { Packing::Words32Msb } else { Packing::Msb } + // the row's own size (a strip may be padded past the last row, which skews `bytes_per_row`) + if nrw_data(mn).is_some() && (row_samples * 12).div_ceil(8).is_multiple_of(4) { Packing::Words32Msb } else { Packing::Msb } } else if info.compression == 1 && bits != 8 && bits != 16 { return Err(RawError::Unsupported(format!("NEF uncompressed packing ({bytes_per_row} bytes per {row_samples}-sample row)"))); } else { @@ -575,6 +576,17 @@ mod tests { assert_eq!(decode(Some(both)).black.values, vec![100.0, 101.0, 102.0, 103.0]); } + #[test] + fn nrw_padded_strip_keeps_32_bit_word_packing() { + // 16 x 6 12-bit samples are 24 bytes a row; a strip padded past the last row must not change the packing + let (w, h) = (16usize, 6usize); + let px: Vec<u16> = (0..w * h).map(|i| (200 + i * 131 % 3800) as u16).collect(); + let mut packed = words32(&px, w, ByteOrder::Little); + packed.extend_from_slice(&[0u8; 6]); + let r = crate::decode(&nef_in(ByteOrder::Little, 1, 12, vec![packed], w as u32, h as u32, h as u32, Some(nrw_note(0)))).unwrap(); + assert_eq!(r.data, RawData::U16(px)); + } + #[test] fn compressed_without_table_is_unsupported() { let bytes = nef(34713, 14, vec![vec![0; 64]], 8, 8, 8); From 967e57fa50661139fd76fedacff9ed8982b004d1 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Tue, 13 Oct 2026 02:44:22 -0400 Subject: [PATCH 072/315] LR-RAW-NEF: read the as-shot white balance from the NRW data block when the maker note has no WB tag (P7000, P7100) --- crates/raw/src/vendor/nef.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index ad792b2e4..b9d6c9cc4 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -45,6 +45,17 @@ fn nrw_black(mn: Option<&makernote::MakerNote>, bits: u32) -> Option<f32> { (u64::from(v) < (1u64 << bits.min(16)) / 4).then_some(v as f32) } +/// As-shot white balance from the NRW data block, for bodies whose maker note has no `0x000c`: the first entry of the +/// block's colour-temperature table (byte `0x34`) is five `u32`s, kelvin, R, G, G, B, in the same 1/256 units as +/// `0x000c` (the same R and B values in the P950, B700, P1000 and P1100 files, which carry both). +fn nrw_wb(mn: Option<&makernote::MakerNote>) -> Option<[f32; 3]> { + let (b, order) = nrw_data(mn)?; + let at = |i: usize| -> Option<u32> { Some(order.u32(b.get(0x34 + 4 * i..0x38 + 4 * i)?.try_into().ok()?)) }; + let (kelvin, r, g1, g2, bl) = (at(0)?, at(1)?, at(2)?, at(3)?, at(4)?); + let sane = |v: u32| (64..=2560).contains(&v); + ((2000..=12000).contains(&kelvin) && g1 == 512 && g2.abs_diff(512) <= 8 && sane(r) && sane(bl)).then(|| [r as f32 / 256.0, 1.0, bl as f32 / 256.0]) +} + /// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation). /// Keep the active area/CFA origin unchanged: the default crop is applied after demosaicing. fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { @@ -172,7 +183,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { .as_ref() .and_then(|m| m.ifd.f64s(WB_RB_LEVELS)) .filter(|v| v.len() >= 2 && v[0] > 0.1 && v[1] > 0.1 && v[0] < 10.0 && v[1] < 10.0) - .map(|v| [v[0] as f32, 1.0, v[1] as f32]); + .map(|v| [v[0] as f32, 1.0, v[1] as f32]) + .or_else(|| nrw_wb(mn.as_ref())); let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, _ => Cfa::bayer_static("RGGB"), @@ -587,6 +599,26 @@ mod tests { assert_eq!(r.data, RawData::U16(px)); } + #[test] + fn nrw_block_supplies_the_as_shot_white_balance_when_the_maker_note_has_none() { + let note = |kelvin: u32, r: u32, g1: u32, g2: u32, b: u32| { + let mut block = b"NRW 0101".to_vec(); + block.resize(0x34, 0); + for v in [kelvin, r, g1, g2, b] { + block.extend_from_slice(&v.to_le_bytes()); + } + block.resize(0x80, 0); + let mut mn = IfdBuilder::new(); + mn.set(NRW_DATA, Value::Undefined(block)); + mn + }; + let wb = |mn| crate::decode(&nef_in(ByteOrder::Little, 1, 12, vec![vec![0; 144]], 16, 6, 6, Some(mn))).unwrap().wb_multipliers; + assert_eq!(wb(note(5236, 430, 512, 510, 567)), Some([430.0 / 256.0, 1.0, 567.0 / 256.0])); + // an implausible entry is not a white balance + assert_eq!(wb(note(0, 430, 512, 510, 567)), None); + assert_eq!(wb(note(5236, 430, 300, 510, 567)), None); + } + #[test] fn compressed_without_table_is_unsupported() { let bytes = nef(34713, 14, vec![vec![0; 64]], 8, 8, 8); From 5f2f013c8eb32e5a02a75d3071b82a52027efb24 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Mon, 12 Oct 2026 18:34:52 +0200 Subject: [PATCH 073/315] Perf: a full-size raw decode no longer copies its 64 MP image to "fit" it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load_bytes_now` ran every development through `resample::fit`, which returns a clone when the image already fits. At full size (1:1 zoom, export) that cloned the whole picture: 797 MB and ~0.3 s for a 9984 × 6656 Sony ILCE-7RM6 raw. It now fits only when the image is larger, as the other preview paths in files.rs already do. Output unchanged (same pixels, by construction). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/files.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..feefb21a0 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -450,7 +450,8 @@ fn load_bytes_now( stages.push(("highlights", t0.elapsed())); colour.apply(&mut img); stages.push(("colour", t0.elapsed())); - let img = fit(&img, max_edge, max_edge, Filter::Box); + // (`fit` copies an image that already fits: 797 MB and 0.3 s for a full-size 64 MP decode) + let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Box) } else { img }; stages.push(("fit", t0.elapsed())); let img = img.into_oriented(raw.orientation); stages.push(("orient", t0.elapsed())); From f4078135690352e8591e08a6a7fd2f0b1fa896f4 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Mon, 12 Oct 2026 18:35:00 +0200 Subject: [PATCH 074/315] =?UTF-8?q?Perf:=20AHD=20demosaic=201.7=C3=97=20fa?= =?UTF-8?q?ster=20and=200.8=20GB=20lighter=20on=20a=2064=20MP=20raw,=20sam?= =?UTF-8?q?e=20pixels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, each leaving the output bit for bit as it was: - Each step runs only where the tile's output depends on it (choice ±0, homogeneity ±1, Lab ±2, greens ±3, mosaic ±5 inside the 6 px margin), with direct neighbour indices instead of an index clamped on every read; the clamped values at the buffer edge never reached the output. - A candidate pixel is built in registers before its Lab conversion: writing its channels through an index and reading the pixel back stalled on store forwarding (the hottest instructions of the profile). - Tiles are written into the output one band at a time, instead of all 64 MP of tiles being collected and then copied (797 MB held twice). Tiles inside the image copy their mosaic rows instead of reflecting and taking a modulo per sample. Sony ILCE-7RM6 9984 × 6656 Compressed HQ raw, demosaic only, best of 5 on 4 cores: 1.85 s → 1.05 s wall, 6.3 s → 4.0 s CPU. Verified identical (f32 bits) on 7 real raws (ILCE-7RM6 FF/APS-C, ARW6 HQ/C and lossless, ILCE-7RM4A) and, against the previous code, on random mosaics of all four Bayer layouts at 1 × 1 … 300 × 7, 257 × 130 (tile edges, reflection). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raw/src/demosaic/ahd.rs | 193 ++++++++++++++++----------------- 1 file changed, 94 insertions(+), 99 deletions(-) diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 3f17413ff..a75c395aa 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -8,7 +8,10 @@ //! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity. //! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties). //! -//! Work is done in overlapping tiles (bounded memory, rayon-parallel). +//! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written +//! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the +//! homogeneity 1 pixel around the tile, which reads Lab 2 pixels around, the candidates read the greens 3 +//! pixels around, and those read the mosaic 5 pixels around, inside the `MARGIN`. use super::Mosaic; use crate::Rgb32f; @@ -19,20 +22,22 @@ const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect(); - let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles - .par_iter() - .map(|&(x0, y0)| { - let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0)); - (x0, y0, tw, th, tile(m, x0, y0, tw, th)) - }) - .collect(); let mut out = Rgb32f::new(w, h); - for (x0, y0, tw, th, px) in results { - for y in 0..th { - out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); + out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w.max(1)); + let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE)) + .into_par_iter() + .map(|tx| { + let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE)); + (x0, tw, tile(m, x0, y0, tw, th)) + }) + .collect(); + for (x0, tw, px) in tiles { + for (row, src) in band.chunks_exact_mut(w).zip(px.chunks_exact(tw)) { + row[x0..x0 + tw].copy_from_slice(src); + } } - } + }); out } @@ -56,110 +61,100 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> let (ox, oy) = (x0 as isize - MARGIN as isize, y0 as isize - MARGIN as isize); let mut mos = vec![0f32; n]; let mut col = vec![0u8; n]; - for y in 0..bh { - for x in 0..bw { - mos[y * bw + x] = m.get(ox + x as isize, oy + y as isize); - col[y * bw + x] = m.color(ox + x as isize, oy + y as isize); + for (y, (mrow, crow)) in mos.chunks_exact_mut(bw).zip(col.chunks_exact_mut(bw)).enumerate() { + let ry = super::reflect(oy + y as isize, m.h); + if ox >= 0 && ox as usize + bw <= m.w { + // inside the image: the row as it is, and its colours repeating with the pattern + let x0 = ry * m.w + ox as usize; + mrow.copy_from_slice(&m.data[x0..x0 + bw]); + let period: Vec<u8> = (0..m.cfa.width).map(|k| m.cfa.color_at(ox as usize + k, ry)).collect(); + for (c, &p) in crow.iter_mut().zip(period.iter().cycle()) { + *c = p; + } + } else { + for x in 0..bw { + mrow[x] = m.get(ox + x as isize, oy + y as isize); + crow[x] = m.color(ox + x as isize, oy + y as isize); + } } } - let cl = |x: isize, y: isize| -> usize { y.clamp(0, bh as isize - 1) as usize * bw + x.clamp(0, bw as isize - 1) as usize }; + // flat indices of the pixels `d` or more from the buffer's edge, row by row + let inner = |d: usize| (d..bh - d).flat_map(move |y| y * bw + d..y * bw + bw - d); // 1. directional greens let mut gh = vec![0f32; n]; let mut gv = vec![0f32; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let c = mos[i]; - if col[i] == 1 { - gh[i] = c; - gv[i] = c; - continue; - } - let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]); - let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25; - gh[i] = super::clamp_between(est, l, r); - let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]); - let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25; - gv[i] = super::clamp_between(est, u, d); + for i in inner(MARGIN - 3) { + let c = mos[i]; + if col[i] == 1 { + gh[i] = c; + gv[i] = c; + continue; } + let (l, r) = (mos[i - 1], mos[i + 1]); + let est = (l + r) * 0.5 + (2.0 * c - mos[i - 2] - mos[i + 2]) * 0.25; + gh[i] = super::clamp_between(est, l, r); + let (u, d) = (mos[i - bw], mos[i + bw]); + let est = (u + d) * 0.5 + (2.0 * c - mos[i - 2 * bw] - mos[i + 2 * bw]) * 0.25; + gv[i] = super::clamp_between(est, u, d); } - // 2. full candidates - let complete = |g: &[f32]| -> Vec<[f32; 3]> { + // 2. full candidates, and 3. their Lab + // (`p` with channel `c` set to `v`, in registers: writing channels through an index and reading the pixel + // back for its Lab stalled on store forwarding) + let set = |p: [f32; 3], c: u8, v: f32| [if c == 0 { v } else { p[0] }, if c == 1 { v } else { p[1] }, if c >= 2 { v } else { p[2] }]; + let complete = |g: &[f32]| -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { let mut rgb = vec![[0f32; 3]; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let own = col[i] as usize; - let gvv = g[i]; - let d = |dx: isize, dy: isize| { - let j = cl(x + dx, y + dy); - mos[j] - g[j] - }; - let p = &mut rgb[i]; - p[1] = gvv; - if own == 1 { - let ch = col[cl(x + 1, y)] as usize; - let cv = col[cl(x, y + 1)] as usize; - p[ch] = gvv + (d(-1, 0) + d(1, 0)) * 0.5; - p[cv] = gvv + (d(0, -1) + d(0, 1)) * 0.5; - } else { - p[own] = mos[i]; - p[2 - own] = gvv + (d(-1, -1) + d(1, -1) + d(-1, 1) + d(1, 1)) * 0.25; - } - } + let mut lab_of = vec![[0f32; 3]; n]; + for i in inner(MARGIN - 2) { + let own = col[i]; + let gvv = g[i]; + let d = |j: usize| mos[j] - g[j]; + let p = if own == 1 { + let p = set([0.0, gvv, 0.0], col[i + 1], gvv + (d(i - 1) + d(i + 1)) * 0.5); + set(p, col[i + bw], gvv + (d(i - bw) + d(i + bw)) * 0.5) + } else { + let p = set([0.0, gvv, 0.0], own, mos[i]); + set(p, 2 - own, gvv + (d(i - bw - 1) + d(i - bw + 1) + d(i + bw - 1) + d(i + bw + 1)) * 0.25) + }; + rgb[i] = p; + lab_of[i] = lab(p); } - rgb + (rgb, lab_of) }; - let rh = complete(&gh); - let rv = complete(&gv); - // 3. Lab + homogeneity - let lh: Vec<[f32; 3]> = rh.iter().map(|&p| lab(p)).collect(); - let lv: Vec<[f32; 3]> = rv.iter().map(|&p| lab(p)).collect(); + let (rh, lh) = complete(&gh); + let (rv, lv) = complete(&gv); + // homogeneity let dl = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).abs(); let dc = |a: &[f32; 3], b: &[f32; 3]| (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); let mut hh = vec![0u8; n]; let mut hv = vec![0u8; n]; - for y in 1..bh as isize - 1 { - for x in 1..bw as isize - 1 { - let i = cl(x, y); - let (l, r, u, d) = (cl(x - 1, y), cl(x + 1, y), cl(x, y - 1), cl(x, y + 1)); - let eps_l = dl(&lh[i], &lh[l]).max(dl(&lh[i], &lh[r])).min(dl(&lv[i], &lv[u]).max(dl(&lv[i], &lv[d]))); - let eps_c = dc(&lh[i], &lh[l]).max(dc(&lh[i], &lh[r])).min(dc(&lv[i], &lv[u]).max(dc(&lv[i], &lv[d]))); - let mut ch = 0; - let mut cv = 0; - for j in [l, r, u, d] { - if dl(&lh[i], &lh[j]) <= eps_l && dc(&lh[i], &lh[j]) <= eps_c { - ch += 1; - } - if dl(&lv[i], &lv[j]) <= eps_l && dc(&lv[i], &lv[j]) <= eps_c { - cv += 1; - } - } - hh[i] = ch; - hv[i] = cv; - } + for i in inner(MARGIN - 1) { + // left, right, up, down + let (l, r, u, d) = (i - 1, i + 1, i - bw, i + bw); + let (a, b) = (lh[i], lv[i]); + let dlh = [dl(&a, &lh[l]), dl(&a, &lh[r]), dl(&a, &lh[u]), dl(&a, &lh[d])]; + let dch = [dc(&a, &lh[l]), dc(&a, &lh[r]), dc(&a, &lh[u]), dc(&a, &lh[d])]; + let dlv = [dl(&b, &lv[l]), dl(&b, &lv[r]), dl(&b, &lv[u]), dl(&b, &lv[d])]; + let dcv = [dc(&b, &lv[l]), dc(&b, &lv[r]), dc(&b, &lv[u]), dc(&b, &lv[d])]; + let eps_l = dlh[0].max(dlh[1]).min(dlv[2].max(dlv[3])); + let eps_c = dch[0].max(dch[1]).min(dcv[2].max(dcv[3])); + hh[i] = (0..4).map(|k| u8::from(dlh[k] <= eps_l && dch[k] <= eps_c)).sum(); + hv[i] = (0..4).map(|k| u8::from(dlv[k] <= eps_l && dcv[k] <= eps_c)).sum(); } // 4. choose let mut out = Vec::with_capacity(tw * th); - for y in MARGIN..MARGIN + th { - for x in MARGIN..MARGIN + tw { - let (mut sh, mut sv) = (0u32, 0u32); - for dy in -1isize..=1 { - for dx in -1isize..=1 { - let j = cl(x as isize + dx, y as isize + dy); - sh += hh[j] as u32; - sv += hv[j] as u32; - } - } - let i = y * bw + x; - out.push(if sh > sv { - rh[i] - } else if sv > sh { - rv[i] - } else { - [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] - }); + for i in inner(MARGIN) { + let (mut sh, mut sv) = (0u32, 0u32); + for j in [i - bw - 1, i - bw, i - bw + 1, i - 1, i, i + 1, i + bw - 1, i + bw, i + bw + 1] { + sh += hh[j] as u32; + sv += hv[j] as u32; } + out.push(if sh > sv { + rh[i] + } else if sv > sh { + rv[i] + } else { + [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] + }); } out } From 79ab54e167fe4cdd12425cfb0f1ea46faeb2c009 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Mon, 12 Oct 2026 18:59:47 +0200 Subject: [PATCH 075/315] Perf: ARW6 decode 20-33 % faster, same samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The vertical inverse 5/3 lifts whole rows at once (`inverse_lift` with samples `w` values wide) instead of gathering each column through a strided iterator and scattering it back: the same integer arithmetic, sequential memory. The horizontal pass is the same function with w = 1. - `0^n 1` codes are counted with one `leading_zeros` over the next MAX_DPT + 1 bits instead of bit by bit (same bits consumed, same errors); each line reserves its values up front. - Colour reconstruction reads plane rows as slices and runs over rows in parallel (it was one thread per tile: 4 threads on a full-frame file whatever the machine). Best of 5 on 4 CPUs, previous and new decoder alternating: ILCE-7RM6 FF HQ 0.56 → 0.46 s, FF Compressed 0.54 → 0.38 s, APS-C HQ 0.34 → 0.23 s, APS-C Compressed 0.35 → 0.24 s (docs/arw6-compression.md → Timings). The decoded mosaic is identical (hash of every sample) on the 8 ARW6 corpus files and 2 private full-frame photos (ISO 100, ISO 12800); the pinned corpus sums still match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raw/src/llvc.rs | 105 +++++++++++++++++++--------------- crates/raw/src/vendor/arw6.rs | 46 +++++++-------- docs/arw6-compression.md | 11 +++- 3 files changed, 93 insertions(+), 69 deletions(-) diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs index 0e2cc39cc..ad386157c 100644 --- a/crates/raw/src/llvc.rs +++ b/crates/raw/src/llvc.rs @@ -14,6 +14,7 @@ fn corrupt(why: &str) -> RawError { } /// One bit, failing when the reader has run past the `source_bits` of its half. +#[inline(always)] fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> { let b = bits.get(1); if bits.consumed_bits() > source_bits { @@ -24,12 +25,14 @@ fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> { /// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`. fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> { - let mut n = 0; - while bit(bits, source_bits)? == 0 { - n += 1; - if n > MAX_DPT { - return Err(corrupt("DPT code exceeds MAX_DPT")); - } + // the next MAX_DPT + 1 bits at once (more zeros than that is corrupt either way) + let n = bits.peek(MAX_DPT + 1).leading_zeros() - (32 - (MAX_DPT + 1)); + bits.skip((n + 1).min(MAX_DPT + 1)); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + if n > MAX_DPT { + return Err(corrupt("DPT code exceeds MAX_DPT")); } Ok(n) } @@ -42,7 +45,7 @@ pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: us let sets = width.div_ceil(4); let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8); let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64); - let mut out: Vec<i32> = Vec::new(); + let mut out: Vec<i32> = Vec::with_capacity(sets * 4); loop { // DPT value decoding (Figure 6.6). if cnt == 0 { @@ -180,43 +183,56 @@ pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) { (low, n - low) } -/// `v[i]` with `i` clamped into range (the whole-sample symmetric extension of the band); 0 for an empty band. -fn at(v: &[i32], i: isize) -> i32 { - let last = v.len() as isize - 1; - if last < 0 { - return 0; - } - v.get(i.clamp(0, last) as usize).copied().unwrap_or(0) -} - /// Saturating narrowing: hostile coefficients clamp instead of overflowing. fn sat(v: i64) -> i32 { v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 } -/// 1-D inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes). -fn inverse_1d(l: &[i32], h: &[i32], phase: u8) -> Result<Vec<i32>> { - let (n, p) = (l.len() + h.len(), isize::from(phase)); - if band_rows(n, phase) != (l.len(), h.len()) || phase > 1 { - return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); +/// Sample `i` (`w` values wide) of a band of `n` samples, clamped into range (the whole-sample symmetric extension); +/// `zeros` for an empty band. +#[inline] +fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) -> &'a [i32] { + if n == 0 { + return zeros; } - let even: Vec<i32> = l - .iter() - .enumerate() - .map(|(j, &lv)| sat(i64::from(lv) - ((i64::from(at(h, j as isize - 1 + p)) + i64::from(at(h, j as isize + p)) + 2) >> 2))) - .collect(); - let mut x = vec![0; n]; - let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); - for (xv, &e) in x.iter_mut().skip(lo).step_by(2).zip(&even) { - *xv = e; - } - for (k, (xv, &hv)) in x.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { - *xv = sat(i64::from(hv) + ((i64::from(at(&even, k as isize - p)) + i64::from(at(&even, k as isize + 1 - p))) >> 1)); - } - Ok(x) + let i = i.clamp(0, n as isize - 1) as usize; + v.get(i * w..(i + 1) * w).unwrap_or(zeros) } -/// Gathers the lines of the `low`/`high` pair along one axis (columns when `vertical`), transforms them, and scatters back. +/// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a +/// signal whose samples are `w` values wide: `w = 1` for one line, a band's width for all its columns at once (row +/// by row, as each column alone would give). +fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result<Vec<i32>> { + let w = w.max(1); + let (nl, nh) = (l.len() / w, h.len() / w); + let (n, p) = (nl + nh, isize::from(phase)); + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let zeros = vec![0; w]; + let mut even = vec![0; nl * w]; + for (j, (e, lv)) in even.chunks_exact_mut(w).zip(l.chunks_exact(w)).enumerate() { + let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); + for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { + *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); + } + } + let mut out = vec![0; n * w]; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, e) in out.chunks_exact_mut(w).skip(lo).step_by(2).zip(even.chunks_exact(w)) { + o.copy_from_slice(e); + } + for (k, (o, hv)) in out.chunks_exact_mut(w).skip(hi).step_by(2).zip(h.chunks_exact(w)).enumerate() { + let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); + for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { + *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); + } + } + Ok(out) +} + +/// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by +/// row), else along each row. fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> { if !low.ok() || !high.ok() { return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into())); @@ -228,17 +244,16 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) }; let n = nl + nh; let (w, h) = if vertical { (across_l, n) } else { (n, across_l) }; + if across_l == 0 { + return Ok(Plane::zeros(w, h)); + } + if vertical { + return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); + } let mut out = Plane::zeros(w, h); - let get = |p: &Plane, i: usize| -> Vec<i32> { - if vertical { p.data.iter().skip(i).step_by(p.width.max(1)).copied().collect() } else { p.row(i).map(<[i32]>::to_vec).unwrap_or_default() } - }; for i in 0..across_l { - let x = inverse_1d(&get(low, i), &get(high, i), phase)?; - if vertical { - for (o, v) in out.data.iter_mut().skip(i).step_by(w.max(1)).zip(x) { - *o = v; - } - } else if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { + let x = inverse_lift(low.row(i).unwrap_or_default(), high.row(i).unwrap_or_default(), 1, phase)?; + if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { r.copy_from_slice(&x); } } diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index f6f535bd6..cb903a6f3 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -468,36 +468,36 @@ pub(crate) fn colour(p: &TilePlanes) -> Result<Vec<u16>> { } let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?; let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?; - let at = |q: &Plane, j: usize, x: usize| -> i64 { q.data.get(j * w2 + x).copied().unwrap_or(0) as i64 }; + // (rows as slices: `at` is 0 past a row's end, as never happens once the sizes above match) + let at = |r: &[i32], x: usize| i64::from(r.get(x).copied().unwrap_or(0)); let mut g1 = vec![0i64; len]; - for j in 0..h2 { - let jp = j.saturating_sub(1); - for x in 0..w2 { + g1.par_chunks_mut(w2.max(1)).enumerate().for_each(|(j, row)| { + let (Some(m), Some(rp), Some(rj)) = (p.m.row(j), p.res.row(j.saturating_sub(1)), p.res.row(j)) else { return }; + for (x, g) in row.iter_mut().enumerate() { let xn = (x + 1).min(w2 - 1); - let r = at(&p.res, jp, x) + at(&p.res, jp, xn) + at(&p.res, j, x) + at(&p.res, j, xn); - if let Some(g) = g1.get_mut(j * w2 + x) { - *g = at(&p.m, j, x) - ((r + 4) >> 3); - } + let r = at(rp, x) + at(rp, xn) + at(rj, x) + at(rj, xn); + *g = at(m, x) - ((r + 4) >> 3); } - } - let g = |j: usize, x: usize| g1.get(j * w2 + x).copied().unwrap_or(0); + }); + let g1_row = |j: usize| g1.get(j * w2..(j + 1) * w2).unwrap_or(&[]); + let g = |r: &[i64], x: usize| r.get(x).copied().unwrap_or(0); let mut out = vec![0u16; total]; - for j in 0..h2 { - let jn = (j + 1).min(h2 - 1); - for x in 0..w2 { + // each plane row gives two mosaic rows + out.par_chunks_mut((2 * mw).max(1)).enumerate().for_each(|(j, rows)| { + let (gj, gn) = (g1_row(j), g1_row((j + 1).min(h2 - 1))); + let (Some(res), Some(c1), Some(c2), Some((top, bottom))) = (p.res.row(j), p.c1.row(j), p.c2.row(j), rows.split_at_mut_checked(mw)) else { + return; + }; + for (x, (t, b)) in top.as_chunks_mut::<2>().0.iter_mut().zip(bottom.as_chunks_mut::<2>().0.iter_mut()).enumerate() { let xp = x.saturating_sub(1); - let g2 = ((g(j, xp) + g(j, x) + g(jn, xp) + g(jn, x)) >> 2) + at(&p.res, j, x); - let mean = (g(j, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; - let r = 2 * at(&p.c1, j, x) + mean; - let b = 2 * at(&p.c2, j, x) + mean; - let cells = [(0, r), (1, g(j, x)), (mw, g2), (mw + 1, b)]; - for (off, v) in cells { - if let Some(o) = out.get_mut(2 * j * mw + 2 * x + off) { - *o = curve(v.clamp(0, 4095) as i32); - } + let g2 = ((g(gj, xp) + g(gj, x) + g(gn, xp) + g(gn, x)) >> 2) + at(res, x); + let mean = (g(gj, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; + let cells = [2 * at(c1, x) + mean, g(gj, x), g2, 2 * at(c2, x) + mean]; + for (o, v) in t.iter_mut().chain(b.iter_mut()).zip(cells) { + *o = curve(v.clamp(0, 4095) as i32); } } - } + }); Ok(out) } diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md index 3b65c1d37..a45f8365f 100644 --- a/docs/arw6-compression.md +++ b/docs/arw6-compression.md @@ -171,4 +171,13 @@ Raw decode only, 4 CPUs, release build, best of 3 wall-clock (2026-10-10). Metho | ILCE-7RM6 APS-C, Compressed (HQ) | 29.7 MB | 6592x4372 | 0.35 s | | ILCE-7RM6 APS-C, Compressed | 30.9 MB | 6592x4372 | 0.34 s | -The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components. +The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components, and the colour reconstruction over rows. + +2026-10-12 performance pass (output unchanged, bit for bit): the vertical wavelet pass lifts whole rows instead of gathering each column, `0^n 1` codes are counted with one `leading_zeros`, and the colour reconstruction reads rows as slices, in parallel. Same method, best of 5, the previous and the new decoder run alternately on the same machine (4 CPUs, which was busier than on 2026-10-10), best of two rounds: + +| File | Before | After | +|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.56 s | 0.46 s | +| ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | +| ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | From bc1f138ee3e8beade33bd60e039a1f8f418d453b Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Tue, 13 Oct 2026 01:22:34 +0200 Subject: [PATCH 076/315] Perf: ARW6 decode another 20-34 % faster, same samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The two halves of each stream are independent bit streams: they now decode in parallel (`decode_stream`) instead of one after the other inside each of a tile's 13 streams. - The vertical inverse 5/3 runs its rows in parallel. The horizontal pass lifts each row in place (`lift_row`, scratch space reused per thread), rows in parallel, instead of allocating two vectors per row and copying the result back; a test checks it against the vertical pass, row by row. Best of 3 on 4 CPUs, previous and new decoder alternating: ILCE-7RM6 FF HQ 0.60 → 0.48 s (process CPU 1.14 → 0.87 s), FF Compressed 0.62 → 0.48 s, APS-C HQ 0.34 → 0.23 s, APS-C Compressed 0.31 → 0.24 s (docs/arw6-compression.md → Timings). The decoded mosaic is identical (hash of every sample) on the 8 ARW6 files of the RawDB ILCE-7RM6 set and 3 private A7R VI photos. Co-Authored-By: Claude <noreply@anthropic.com> --- crates/raw/src/llvc.rs | 82 ++++++++++++++++++++++++++++------- crates/raw/src/vendor/arw6.rs | 5 ++- docs/arw6-compression.md | 9 ++++ 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs index ad386157c..17408c760 100644 --- a/crates/raw/src/llvc.rs +++ b/crates/raw/src/llvc.rs @@ -200,9 +200,10 @@ fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) } /// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a -/// signal whose samples are `w` values wide: `w = 1` for one line, a band's width for all its columns at once (row -/// by row, as each column alone would give). +/// signal whose samples are `w` values wide: a band's width for all its columns at once (row by row, as each column +/// alone would give; rows in parallel). One line (`w = 1`) goes through [`lift_row`]. fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result<Vec<i32>> { + use rayon::prelude::*; let w = w.max(1); let (nl, nh) = (l.len() / w, h.len() / w); let (n, p) = (nl + nh, isize::from(phase)); @@ -211,26 +212,49 @@ fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result<Vec<i32>> { } let zeros = vec![0; w]; let mut even = vec![0; nl * w]; - for (j, (e, lv)) in even.chunks_exact_mut(w).zip(l.chunks_exact(w)).enumerate() { + even.par_chunks_exact_mut(w).zip(l.par_chunks_exact(w)).enumerate().for_each(|(j, (e, lv))| { let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); } - } + }); let mut out = vec![0; n * w]; - let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); - for (o, e) in out.chunks_exact_mut(w).skip(lo).step_by(2).zip(even.chunks_exact(w)) { - o.copy_from_slice(e); - } - for (k, (o, hv)) in out.chunks_exact_mut(w).skip(hi).step_by(2).zip(h.chunks_exact(w)).enumerate() { + let lo = usize::from(phase); + out.par_chunks_exact_mut(w).enumerate().for_each(|(t, o)| { + if t % 2 == lo { + if let Some(e) = even.get((t - lo) / 2 * w..((t - lo) / 2 + 1) * w) { + o.copy_from_slice(e); + } + return; + } + let k = (t - (1 - lo)) / 2; + let Some(hv) = h.get(k * w..(k + 1) * w) else { return }; let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); } - } + }); Ok(out) } +/// [`inverse_lift`] of one line (`w = 1`) into `out` (`l.len() + h.len()` values), without allocating: `even` is +/// scratch space. The caller has checked the band sizes. +fn lift_row(l: &[i32], h: &[i32], phase: u8, even: &mut Vec<i32>, out: &mut [i32]) { + let (nl, nh, p) = (l.len(), h.len(), isize::from(phase)); + let hs = |i: isize| if nh == 0 { 0 } else { h.get(i.clamp(0, nh as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + even.clear(); + even.extend(l.iter().enumerate().map(|(j, &lv)| sat(i64::from(lv) - ((hs(j as isize - 1 + p) + hs(j as isize + p) + 2) >> 2)))); + let even = &*even; + let es = |i: isize| if nl == 0 { 0 } else { even.get(i.clamp(0, nl as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, &e) in out.iter_mut().skip(lo).step_by(2).zip(even.iter()) { + *o = e; + } + for (k, (o, &hv)) in out.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { + *o = sat(i64::from(hv) + ((es(k as isize - p) + es(k as isize + 1 - p)) >> 1)); + } +} + /// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by /// row), else along each row. fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> { @@ -250,13 +274,16 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> if vertical { return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); } - let mut out = Plane::zeros(w, h); - for i in 0..across_l { - let x = inverse_lift(low.row(i).unwrap_or_default(), high.row(i).unwrap_or_default(), 1, phase)?; - if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { - r.copy_from_slice(&x); - } + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); } + use rayon::prelude::*; + let mut out = Plane::zeros(w, h); + out.data.par_chunks_exact_mut(w.max(1)).enumerate().for_each_init(Vec::new, |even, (i, r)| { + if let (Some(l), Some(hi)) = (low.row(i), high.row(i)) { + lift_row(l, hi, phase, even, r); + } + }); Ok(out) } @@ -463,6 +490,29 @@ mod tests { assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]); } #[test] + fn horizontal_pass_gives_the_vertical_passs_samples() { + // two implementations of one lifting: `lift_row` along rows, `inverse_lift` down columns + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s % 8191) as i32 - 4095 + }; + for n in 1..12usize { + for phase in 0..2u8 { + let ((nl, nh), rows) = (band_rows(n, phase), 3); + let low = Plane { width: nl, height: rows, data: (0..nl * rows).map(|_| next()).collect() }; + let high = Plane { width: nh, height: rows, data: (0..nh * rows).map(|_| next()).collect() }; + let across = inverse_53_1d_cols(&low, &high, phase).unwrap(); + for r in 0..rows { + let down = inverse_53_1d_rows(&col(low.row(r).unwrap()), &col(high.row(r).unwrap()), phase).unwrap(); + assert_eq!(across.row(r).unwrap(), &down.data[..], "n {n}, phase {phase}, row {r}"); + } + } + } + } + #[test] fn inverse_53_rejects_mismatched_bands() { assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err()); assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err()); diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index cb903a6f3..70f438190 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -329,9 +329,10 @@ type HalfLines = (Half, Vec<Vec<i32>>); fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result<Vec<HalfLines>> { let halves = stream_halves(tile, st, ntu)?; let width = line_width(st.group, w2); + // halves are independent bit streams: decode them in parallel halves - .into_iter() - .zip(layouts) + .into_par_iter() + .zip(layouts.par_iter()) .map(|(half, rows)| { let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?; let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?; diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md index a45f8365f..334b651f1 100644 --- a/docs/arw6-compression.md +++ b/docs/arw6-compression.md @@ -181,3 +181,12 @@ The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the ti | ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | | ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | | ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | + +2026-10-13 (output unchanged, bit for bit): the two halves of each stream, which are independent bit streams, decode in parallel; the vertical wavelet pass runs its rows in parallel; the horizontal pass lifts each row in place (scratch space reused per thread), rows in parallel, instead of allocating two vectors per row. Same method, best of 3, the previous and the new decoder run alternately, on another machine (Ryzen 7 9800X3D, a 4-CPU cgroup quota shared with other builds); CPU is the process CPU time of the decode (all threads): + +| File | Before | After | CPU before | CPU after | +|---|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.60 s | 0.48 s | 1.14 s | 0.87 s | +| ILCE-7RM6 FF, Compressed | 0.62 s | 0.48 s | 1.14 s | 0.89 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | 0.50 s | 0.40 s | +| ILCE-7RM6 APS-C, Compressed | 0.31 s | 0.24 s | 0.51 s | 0.42 s | From 4f51f051e7b3f8c1303b28470d33e7b0d4b93963 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Tue, 13 Oct 2026 04:03:59 -0400 Subject: [PATCH 077/315] LR-RAW-NEF: only take the NRW block's white balance where the as-shot kelvin is mirrored (P7000); P7100 gets none --- crates/raw/src/vendor/nef.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index b9d6c9cc4..39dfe6fd5 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -47,13 +47,16 @@ fn nrw_black(mn: Option<&makernote::MakerNote>, bits: u32) -> Option<f32> { /// As-shot white balance from the NRW data block, for bodies whose maker note has no `0x000c`: the first entry of the /// block's colour-temperature table (byte `0x34`) is five `u32`s, kelvin, R, G, G, B, in the same 1/256 units as -/// `0x000c` (the same R and B values in the P950, B700, P1000 and P1100 files, which carry both). +/// `0x000c` (the same R and B values in the P950, B700, P1000 and P1100 files, which carry both). Only used when the +/// table's fourth entry, a kelvin-only slot, repeats the first entry's kelvin (the P7000); the P7100 keeps its +/// as-shot kelvin there (3195) while entry 0 is the 3000 K preset, so that body gets no white balance from here. fn nrw_wb(mn: Option<&makernote::MakerNote>) -> Option<[f32; 3]> { let (b, order) = nrw_data(mn)?; let at = |i: usize| -> Option<u32> { Some(order.u32(b.get(0x34 + 4 * i..0x38 + 4 * i)?.try_into().ok()?)) }; let (kelvin, r, g1, g2, bl) = (at(0)?, at(1)?, at(2)?, at(3)?, at(4)?); let sane = |v: u32| (64..=2560).contains(&v); - ((2000..=12000).contains(&kelvin) && g1 == 512 && g2.abs_diff(512) <= 8 && sane(r) && sane(bl)).then(|| [r as f32 / 256.0, 1.0, bl as f32 / 256.0]) + let mirrored = at(15)? == kelvin; + (mirrored && (2000..=12000).contains(&kelvin) && g1 == 512 && g2.abs_diff(512) <= 8 && sane(r) && sane(bl)).then(|| [r as f32 / 256.0, 1.0, bl as f32 / 256.0]) } /// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation). @@ -601,22 +604,26 @@ mod tests { #[test] fn nrw_block_supplies_the_as_shot_white_balance_when_the_maker_note_has_none() { - let note = |kelvin: u32, r: u32, g1: u32, g2: u32, b: u32| { + let note = |kelvin: u32, r: u32, g1: u32, g2: u32, b: u32, mirror: Option<u32>| { let mut block = b"NRW 0101".to_vec(); block.resize(0x34, 0); for v in [kelvin, r, g1, g2, b] { block.extend_from_slice(&v.to_le_bytes()); } + block.resize(0x34 + 60, 0); + block.extend_from_slice(&mirror.unwrap_or(kelvin).to_le_bytes()); block.resize(0x80, 0); let mut mn = IfdBuilder::new(); mn.set(NRW_DATA, Value::Undefined(block)); mn }; let wb = |mn| crate::decode(&nef_in(ByteOrder::Little, 1, 12, vec![vec![0; 144]], 16, 6, 6, Some(mn))).unwrap().wb_multipliers; - assert_eq!(wb(note(5236, 430, 512, 510, 567)), Some([430.0 / 256.0, 1.0, 567.0 / 256.0])); + assert_eq!(wb(note(5236, 430, 512, 510, 567, None)), Some([430.0 / 256.0, 1.0, 567.0 / 256.0])); // an implausible entry is not a white balance - assert_eq!(wb(note(0, 430, 512, 510, 567)), None); - assert_eq!(wb(note(5236, 430, 300, 510, 567)), None); + assert_eq!(wb(note(0, 430, 512, 510, 567, None)), None); + assert_eq!(wb(note(5236, 430, 300, 510, 567, None)), None); + // the P7100 layout: entry 0 is a preset, the as-shot kelvin sits in the fourth entry + assert_eq!(wb(note(3000, 278, 512, 510, 868, Some(3195))), None); } #[test] From b36b0d14b71b648a2fc5acdf2a26abff906c3fba Mon Sep 17 00:00:00 2001 From: PiMinusMax <maxwalz92@gmx.de> Date: Tue, 13 Oct 2026 10:10:24 +0200 Subject: [PATCH 078/315] KEY-SLIDER-NUDGE: scrolling sideways over a slider adjusts it A trackpad swipe, a tilting wheel or Shift+wheel over a Develop slider nudges it by the wheel/arrow-key steps, without selecting it first (the right panel only scrolls vertically). Only a mostly sideways movement counts, so scrolling the panel leaves the sliders alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/tests_masking.rs | 30 +++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 27 ++++++++++++++++++++++++-- docs/parity-checklist.md | 3 ++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bf808b532..11d831db7 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1500,6 +1500,36 @@ fn a_selected_develop_slider_accepts_wheel_nudges() { assert!(after > before, "selected exposure did not increase: {before} -> {after}"); } +/// Scrolling sideways over a slider adjusts it without selecting it first: a trackpad swipe +/// right raises the value, left lowers it, and Shift+wheel (sideways scrolling with a mouse) does +/// the same. A mostly vertical swipe still scrolls the panel and leaves the slider alone. +#[test] +fn sideways_scrolling_adjusts_the_slider_under_the_pointer() { + let mut h = detail("panel.edit"); + let r = h.request("ui.set", json!({"right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let exposure = |h: &Headless| lightcraft_develop::controls::get(&develop(h), "light.exposure").expect("exposure"); + let scroll = |h: &mut Headless, params: serde_json::Value| { + // the pointer rests on the track, the slider is not clicked + let r = h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), T); + assert_eq!(r["ok"], true, "{r}"); + let before = exposure(h); + let r = h.request("ui.scroll", params, T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..10 { + h.step(); + } + exposure(h) - before + }; + assert!(scroll(&mut h, json!({"dx": 120.0})) > 0.0, "a swipe right raises the value"); + assert!(scroll(&mut h, json!({"dx": -120.0})) < 0.0, "a swipe left lowers it"); + assert!(scroll(&mut h, json!({"dy": 120.0, "shift": true})) > 0.0, "Shift+wheel scrolls sideways"); + let undo = h.app.session.undo.len(); + assert_eq!(scroll(&mut h, json!({"dx": 30.0, "dy": -120.0})), 0.0, "a mostly vertical swipe leaves the slider alone"); + assert_eq!(h.app.session.undo.len(), undo); +} + /// Issue #517: a fast brush stroke (pointer samples several brush widths apart) shows on the photo /// while the button is held as the continuous, feathered band it will be once committed, not as /// separate dabs at the samples with gaps between them. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..456c42670 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -177,8 +177,9 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { } /// A Lightroom slider row (label + value above a track with a hollow ring thumb). -/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to -/// type one: Return or clicking away applies it, Esc keeps the old one. +/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Scroll sideways over +/// it to nudge it. Click the value to type one: Return or clicking away applies it, Esc keeps the +/// old one. pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -351,6 +352,28 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } } + // Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under + // the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. + // Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel + // leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. + let side_id = id.with("side-rem"); + let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); + if !enabled || typing.is_some() || out.value.is_some() || !ui.rect_contains_pointer(row) || dx.abs() <= dy.abs() { + ui.data_mut(|m| m.remove::<f32>(side_id)); + } else { + // the vertical part would scroll the panel and move the slider out from under the pointer + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let mut remainder = ui.data(|m| m.get_temp::<f32>(side_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, dx); + ui.data_mut(|m| m.insert_temp(side_id, remainder)); + let nv = nudged(spec, value, steps); + if steps != 0.0 && (nv - value).abs() > 1e-12 { + out.value = Some(nv); + out.drag_started = true; + out.drag_stopped = true; + v = nv; + } + } // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. // Consume only the vertical component so a trackpad's horizontal scroll still works. let wheel_id = id.with("wheel-rem"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..931086e21 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -667,7 +667,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active | | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | -| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | +| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each. Scrolling sideways over any slider (trackpad swipe, tilting wheel, ⇧+wheel) nudges it the same way, one wheel notch a step, without selecting it first; only a mostly sideways movement counts, so scrolling the panel leaves sliders alone (`crates/ui-egui/src/tests_masking.rs`, `sideways_scrolling_adjusts_the_slider_under_the_pointer`) | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | @@ -801,6 +801,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LRC-LIB-MISSING: folder and disk context menus locate a moved tree recursively without relinking missing photos outside it (issue #1194) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Canon PowerShot G9 as-shot white balance from WBInfo (issue #1273); status unchanged (🟡) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) | +| 2026-10-13 | minor | KEY-SLIDER-NUDGE: scrolling sideways over a slider adjusts it | | 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | | 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | From 488edec6c88a3db001085fca8630f780330995fb Mon Sep 17 00:00:00 2001 From: witkowski01 <konradwitkowski01@gmail.com> Date: Tue, 13 Oct 2026 10:27:09 +0200 Subject: [PATCH 079/315] i18n(pl): plural forms, the messages main gained, and paintable menu arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes for the Polish catalogs of #1100, on top of a merge of main: - English plural suffixes ("s"/"") and the "it"/"them" words in 51 format messages are now consumed with `{:.0}`, as docs/localization.md asks; Polish showed "Zaimportowano 5 zdjęćs" and "utracisz they are". The counts read "Zaimportowane zdjęcia: 5", which is right for every number (Polish has three plural forms; ru and uk use the same pattern). - The 19 format messages main gained since the translation was written; without them the build fails. - 118 plain messages the other catalogs already translate. - "▸" and "▾" in translations become "→" and "⌄", as the other catalogs do (translated_catalogs_do_not_use_missing_menu_triangles). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/pl-formats.json | 123 ++++++++++++--------- crates/ui-egui/locales/pl.json | 146 ++++++++++++++++++++++--- 2 files changed, 203 insertions(+), 66 deletions(-) diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 8bdf25420..88622823b 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -4,31 +4,31 @@ " · {:.0}% clipped": " · {:.0}% prześwietlonych obszarów", " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", - " · {} duplicate{} skipped": " · pominięto {} duplikatów{}", + " · {} duplicate{} skipped": " · pominięte duplikaty: {}{:.0}", " · {} failed: {}": " · {} nieudanych: {}", " · {} moved": " · {} przeniesionych", " · {} not readable": " · {} nie można odczytać", - " · {} original{} left at the source": " · {} oryginalnych{} pozostawiono w źródle", + " · {} original{} left at the source": " · oryginały pozostawione w źródle: {}{:.0}", " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", " — not carried over: {}{}": " — nie przeniesiono: {}{}", "+{} more": "+{} więcej", - ", {failed} file{} not readable": ", nie można odczytać {failed} plików{}", + ", {failed} file{} not readable": ", nieczytelne pliki: {failed}{:.0}", "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", "; {outside} outside its time range": "; {outside} poza zakresem dat", "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", - "Add {} Photo{}": "Dodaj {} zdjęć{}", - "Add {} Photo{}, restore {}": "Dodaj {} zdjęć{}, przywróć {}", + "Add {} Photo{}": "Dodaj zdjęcia: {}{:.0}", + "Add {} Photo{}, restore {}": "Dodaj zdjęcia: {}{:.0}, przywróć: {}", "Add “{}” to Favorites": "Dodaj „{}” do ulubionych", - "Added {n} photo{} to My Photos": "Dodano {n} zdjęć{} do Moich zdjęć", - "Added {n} photo{} to “{}”": "Dodano {n} zdjęć{} do „{}”", + "Added {n} photo{} to My Photos": "Dodano do Moich zdjęć: {n}{:.0}", + "Added {n} photo{} to “{}”": "Dodano do „{1}”: {n}{0:.0}", "Added {what} to {name}": "Dodano {what} do {name}", "Adding photos… {} of {}": "Dodawanie zdjęć… {} z {}", "All {n} selected photos move by the same amount.": "Wszystkie {n} zaznaczone zdjęcia zostaną przesunięte o tę samą wartość.", - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Już w bibliotece, w Ostatnio usuniętych: zaznaczono {} zdjęć{} · kliknij prawym przyciskiem ▸ Przywróć albo Usuń trwale, aby zaimportować ponownie", - "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece: zaznaczono {} zdjęć{} we Wszystkich zdjęciach", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Już w bibliotece, w Ostatnio usuniętych — zaznaczone zdjęcia: {}{:.0} · kliknij prawym przyciskiem → Przywróć albo Usuń trwale, aby zaimportować ponownie", + "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece — zaznaczone zdjęcia we Wszystkich zdjęciach: {}{:.0}", "Auto ({})": "Automatycznie ({})", - "Auto Import: added {} photo{}": "Automatyczny import: dodano {} zdjęć{}", + "Auto Import: added {} photo{}": "Automatyczny import — dodane zdjęcia: {}{:.0}", "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", "Automatic ({} MB)": "Automatycznie ({} MB)", "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", @@ -45,26 +45,26 @@ "Delete {n} Photos": "Usuń {n} zdjęć", "Delete “{current}”": "Usuń „{current}”", "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", - "Done ({what}): {done} photo{plural}": "Gotowe ({what}): {done} zdjęć{plural}", + "Done ({what}): {done} photo{plural}": "Gotowe ({what}) — zdjęcia: {done}{plural:.0}", "Duplicate \"{label}\"": "Duplikuj „{label}”", "Example: {example}": "Przykład: {example}", - "Exported {n} photo{}": "Wyeksportowano {n} zdjęć{}", - "Exported {ok} of {total} photo{}": "Wyeksportowano {ok} z {total} zdjęć{}", - "Exported {} point curve preset{}": "Wyeksportowano {} ustawień krzywej punktowej{}", - "Exported {} preset{}": "Wyeksportowano {} ustawień wstępnych{}", + "Exported {n} photo{}": "Wyeksportowane zdjęcia: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Wyeksportowane zdjęcia: {ok} z {total}{:.0}", + "Exported {} point curve preset{}": "Wyeksportowane ustawienia krzywej punktowej: {}{:.0}", + "Exported {} preset{}": "Wyeksportowane ustawienia wstępne: {}{:.0}", "Exporting {} of {total}": "Eksportowanie {} z {total}", - "Filter bar — {active} active filter{}": "Pasek filtrów — {active} aktywnych filtrów{}", + "Filter bar — {active} active filter{}": "Pasek filtrów — aktywne filtry: {active}{:.0}", "Focus {:.0}": "Ostrość {:.0}", "Folder “{}” (and its subfolders)": "Folder „{}” (wraz z podfolderami)", - "Found {n} missing photo{}; {left} still missing{unsure}": "Znaleziono {n} brakujących zdjęć{}; nadal brakuje {left}{unsure}", - "Healed {n} dust spot{}": "Usunięto {n} plamek kurzu{}", - "Import cancelled · {} photo{} added": "Import anulowany · dodano {} zdjęć{}", + "Found {n} missing photo{}; {left} still missing{unsure}": "Odnalezione brakujące zdjęcia: {n}{:.0}; nadal brakuje: {left}{unsure}", + "Healed {n} dust spot{}": "Usunięte plamki kurzu: {n}{:.0}", + "Import cancelled · {} photo{} added": "Import anulowany · dodane zdjęcia: {}{:.0}", "Import failed: {e}": "Import nieudany: {e}", - "Imported {n} point curve preset{}": "Zaimportowano {n} ustawień krzywej punktowej{}", - "Imported {n} preset{}": "Zaimportowano {n} ustawień wstępnych{}", - "Imported {n} preset{}, {f} file{} not readable": "Zaimportowano {n} ustawień wstępnych{}, nie można odczytać {f} plików{}", - "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Zaimportowano {profiles} profili{} (Przeglądarka profili ▸ odpowiednie grupy)", - "Imported {} photo{}": "Zaimportowano {} zdjęć{}", + "Imported {n} point curve preset{}": "Zaimportowane ustawienia krzywej punktowej: {n}{:.0}", + "Imported {n} preset{}": "Zaimportowane ustawienia wstępne: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Zaimportowane ustawienia wstępne: {n}{:.0}, nieczytelne pliki: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Zaimportowane profile: {profiles}{:.0} (Przeglądarka profili → odpowiednie grupy)", + "Imported {} photo{}": "Zaimportowane zdjęcia: {}{:.0}", "Insert at the cursor (also written {})": "Wstaw w miejscu kursora (zostanie też zapisane {})", "Label Set: {name}": "Zestaw etykiet: {name}", "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", @@ -81,11 +81,11 @@ "Move {what} to Recently Deleted?": "Przenieść {what} do Ostatnio usuniętych?", "Named ({named_n})": "Nazwane ({named_n})", "Painting “{k}”: click photos to add or remove it · Esc stops": "Przypisywanie „{k}”: klikaj zdjęcia, aby dodać lub usunąć słowo kluczowe · Esc kończy", - "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do {n} zdjęć{} — wklejone zostaną tylko skopiowane ustawienia", + "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do zdjęć ({n}){:.0} — wklejone zostaną tylko skopiowane ustawienia", "Preset: {name}": "Ustawienie wstępne: {name}", "Preview only — editing the camera's embedded JPEG ({})": "Tylko podgląd — edycja pliku JPEG osadzonego przez aparat ({})", "Preview only — {}": "Tylko podgląd — {}", - "Previews ready for {done} photo{plural}": "Podglądy gotowe dla {done} zdjęć{plural}", + "Previews ready for {done} photo{plural}": "Podglądy gotowe — zdjęcia: {done}{plural:.0}", "Profile: {}": "Profil: {}", "Proof Copy ({})": "Kopia próbna ({})", "Proof Preview · {}": "Podgląd próbny · {}", @@ -96,28 +96,28 @@ "Reading photos… {done} of {total}": "Odczytywanie zdjęć… {done} z {total}", "Reading photos… {} of {}": "Odczytywanie zdjęć… {} z {}", "Redo {}": "Ponów {}", - "Remove “{name}” and its {count} photo{} from the library?": "Usunąć „{name}” i jego {count} zdjęć{} z biblioteki?", + "Remove “{name}” and its {count} photo{} from the library?": "Usunąć „{name}” i jego zdjęcia ({count}){:.0} z biblioteki?", "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", "Removed from {}": "Usunięto z {}", "Removed {what} from {name}": "Usunięto {what} z {name}", "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", "Rename “{name}”": "Zmień nazwę „{name}”", - "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na {n} zdjęciach{} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na zdjęciach ({n}){:.0} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", "Replace {} with:": "Zamień {} na:", "Reset {name}": "Wyzeruj {name}", "Reset {} Channel": "Wyzeruj kanał {}", "Restore {}": "Przywróć {}", - "Restore {} Photo{}": "Przywróć {} zdjęć{}", + "Restore {} Photo{}": "Przywróć zdjęcia: {}{:.0}", "Saved export preset “{}”": "Zapisano ustawienie eksportu „{}”", "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Ocenia ostrość i ekspozycję zdjęć z zakresu {scope} oraz znajduje podobne ujęcia wykonane w odstępie kilku sekund (serie).", - "Show {hidden} hidden location{}": "Pokaż {hidden} ukrytych lokalizacji{}", + "Show {hidden} hidden location{}": "Pokaż ukryte lokalizacje: {hidden}{:.0}", "Showing {n} of {}": "Wyświetlono {n} z {}", "Smart album: {}": "Album inteligentny: {}", - "Tagged {n} photo{} from the tracklog": "Dodano geotagi do {n} zdjęć{} na podstawie śladu GPS", + "Tagged {n} photo{} from the tracklog": "Geotagi ze śladu GPS dodane do zdjęć: {n}{:.0}", "The SAM 3 download failed: {e}": "Nie udało się pobrać SAM 3: {e}", "Undo {}": "Cofnij {}", - "Unknown tag{s} {} stay{} as typed (see Tags)": "Nieznane znaczniki: {s} {} pozostaną{} w wpisanej postaci (zobacz Znaczniki)", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Nieznane tagi{s:.0} pozostaną bez zmian: {}{:.0} (zobacz Tagi)", "Updated “{name}”": "Zaktualizowano „{name}”", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", "Version: {}": "Wersja: {}", @@ -127,7 +127,7 @@ "the {n} selected photos": "{n} zaznaczonych zdjęć", "the {} photos in view": "{} zdjęć w bieżącym widoku", "these {count} photos": "te {count} zdjęcia", - "{count} smart preview{} · {:.1} MB": "{count} podglądów inteligentnych{} · {:.1} MB", + "{count} smart preview{} · {:.1} MB": "Inteligentne podglądy: {count}{:.0} · {:.1} MB", "{f} folders (and their subfolders)": "{f} folderów (wraz z podfolderami)", "{label} Grading": "Gradacja: {label}", "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", @@ -144,19 +144,19 @@ "{name} On": "{name}: włączone", "{name} opened for editing; it is stacked with the original": "{name} otwarto do edycji; kopia znajduje się w stosie z oryginałem", "{name} — double-click to reset it": "{name} — kliknij dwukrotnie, aby wyzerować", - "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nie można zapisać {n} zmian{} na dysku: {e}\nJeśli teraz zamkniesz program, utracisz {}.", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu oznacza utratę {}.", - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} zmian{} zapisano w pamięci, ale nie na dysku: {e} — LightCraft ponowi próbę", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nie udało się zapisać zmian na dysku ({n}){0:.0}: {e}\nJeśli teraz zamkniesz program, zostaną utracone.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){0:.0}: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu teraz oznacza ich utratę.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){:.0}: {e} — LightCraft ponowi próbę", "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", "{n} photos": "{n} zdjęć", "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", - "{n} photo{}": "{n} zdjęć{}", - "{n} photo{} match · updates automatically as photos change": "{n} pasujących zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", - "{n} photo{} now · updates automatically as photos change": "Obecnie {n} zdjęć{} · aktualizacja automatyczna po zmianach zdjęć", + "{n} photo{}": "Zdjęcia: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "Pasujące zdjęcia: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", + "{n} photo{} now · updates automatically as photos change": "Zdjęcia teraz: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", "{pid} (missing)": "{pid} (brak)", "{sel_n} selected · {counted}": "Zaznaczono {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", - "{verb} {n} Photo{}": "{verb} {n} zdjęć{}", + "{verb} {n} Photo{}": "{verb} (zdjęcia: {n}){:.0}", "{weekday}, {day}": "{weekday}, {day}", "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", "{what} merge added": "Dodano po scaleniu: {what}", @@ -167,26 +167,26 @@ "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", "{} failed": "{} nieudanych", "{} files": "{} plików", - "{} files and {f} folder{}": "{} plików i {f} folderów{}", - "{} item{}": "{} elementów{}", + "{} files and {f} folder{}": "Pliki: {}, foldery: {f}{:.0}", + "{} item{}": "Elementy: {}{:.0}", "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", "{} photos": "{} zdjęć", "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", - "{} photo{} not readable": "Nie można odczytać {} zdjęć{}", - "{} sample{}": "{} próbek{}", + "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}", + "{} sample{}": "Próbki: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "{} plamek na tym zdjęciu", "{} — show their photos": "{} — pokaż ich zdjęcia", "· {dups} already in the library": "· {dups} już w bibliotece", - "· {n} photo{}": "· {n} zdjęć{}", + "· {n} photo{}": "· zdjęcia: {n}{:.0}", "… and {more} more": "… i jeszcze {more}", "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", "Filtered: {label}": "Filtrowanie: {label}", - "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} podobnych zdjęć{} · Widok ▸ Wyczyść filtry, aby zobaczyć wszystkie", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Podobne zdjęcia: {n}{:.0} · Widok → Wyczyść filtry, aby zobaczyć wszystkie", "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", - ", {} star{}": ", {} gwiazdek{}", + ", {} star{}": ", gwiazdki: {}{:.0}", ", {} label": ", {} etykieta", "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Pobieranie z {host}. Po pobraniu i weryfikacji LightCraft zainstaluje plik, {after}.", "{:.1} MB": "{:.1} MB", @@ -209,17 +209,17 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Umieść wewnątrz „{parent}”", "Delete the keyword “{name}”?": "Usunąć słowo kluczowe „{name}”?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Zostanie usunięte z {count} zdjęć{}, razem z podrzędnymi słowami kluczowymi. Możesz to cofnąć.", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Zostanie usunięte ze zdjęć ({count}){:.0} razem z podrzędnymi słowami kluczowymi. Możesz to cofnąć.", "Create Keyword Tag Inside “{name}”…": "Utwórz podrzędne słowo kluczowe w „{name}”…", "“{parent}” has a “{name}” already. Merge the two?": "„{parent}” zawiera już „{name}”. Scalić oba?", "There is a “{name}” at the top level already. Merge the two?": "Słowo kluczowe „{name}” istnieje już na najwyższym poziomie. Scalić oba?", - "Added “{keyword}” to {n} photo{}": "Dodano „{keyword}” do {n} zdjęć{}", + "Added “{keyword}” to {n} photo{}": "Dodano „{keyword}” do zdjęć: {n}{:.0}", "On {have} of {of} selected photos": "Na {have} z {of} zaznaczonych zdjęć", "Remove “{name}”": "Usuń „{name}”", "There is a keyword set “{name}” already": "Istnieje już zestaw słów kluczowych „{name}”", - "Added {n} keyword{}": "Dodano {n} słów kluczowych{}", + "Added {n} keyword{}": "Dodane słowa kluczowe: {n}{:.0}", "; {n} gained synonyms": "; {n} otrzymało synonimy", - "Exported {n} keyword{}": "Wyeksportowano {n} słów kluczowych{}", + "Exported {n} keyword{}": "Wyeksportowane słowa kluczowe: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One nie zaimportuje listy zawierającej ; , < lub > w słowie kluczowym: {names}", " — left out, as a keyword list can't hold them: {names}": " — pominięto, ponieważ lista słów kluczowych ich nie obsługuje: {names}", "{done} of {total}": "{done} z {total}", @@ -228,5 +228,24 @@ "+{n} more": "+{n} więcej", "1 task is still running: {names}. Quitting stops it.": "Wciąż trwa 1 zadanie: {names}. Zamknięcie programu je zatrzyma.", "{n} tasks are still running: {names}. Quitting stops them.": "Wciąż trwają {n} zadania: {names}. Zamknięcie programu je zatrzyma.", - "{what} merge cancelled": "Scalanie anulowane: {what}" + "{what} merge cancelled": "Scalanie anulowane: {what}", + "Import warnings ({n}):\n{warnings}": "Ostrzeżenia importu ({n}):\n{warnings}", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Geometria z Lightrooma dla {path} nie została zachowana; zapisane przekształcenie Guided Upright nie jest obsługiwane.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Geometria z Lightrooma dla {path} nie została zachowana; zapisane przekształcenie Upright nie jest obsługiwane.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Geometria z Lightrooma dla {path} nie została zachowana; zapisane przekształcenie jest pomijane, ponieważ Upright jest wyłączony.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometria z Lightrooma dla {path} nie została zachowana; tryb {mode} zostanie obliczony ponownie.", + "Moved {} photo(s)": "Przeniesione zdjęcia: {}", + "New Folder in “{name}”": "Nowy folder w „{name}”", + "Restored {} photo{} from Recently Deleted": "Przywrócone z Ostatnio usuniętych zdjęcia: {}{:.0}", + "XMP adjustments were not imported for {path}: {fields}.": "Korekty XMP dla {path} nie zostały zaimportowane: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP „{profile}” nie został zastosowany do {path}; kolory mogą się różnić od Lightrooma.", + "not added to Apple Photos: {e}": "nie dodano do aplikacji Zdjęcia: {e}", + "{name} opened in the other editor": "{name} otwarto w innym edytorze", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Pominięte zdjęcia lokalne: {n}{0:.0} — albumy zawierają tylko zdjęcia z biblioteki (najpierw dodaj je do Moich zdjęć)", + "{n} added to Apple Photos": "Dodane do aplikacji Zdjęcia: {n}", + "{n} added to Apple Photos album “{album}”": "Dodane do albumu „{album}” w aplikacji Zdjęcia: {n}", + "{n} originals opened in the other editor": "Oryginały otwarte w innym edytorze: {n}", + "{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem", + "· {in_source} identical copies": "· identyczne kopie: {in_source}", + "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}" } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index f69e22643..b03c2fcd3 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -1024,7 +1024,7 @@ "Presets & Profiles": "Ustawienia wstępne i profile", "Presets (Shift+P)": "Ustawienia wstępne (Shift+P)", "Presets and profiles": "Ustawienia wstępne i profile", - "Presets ▾": "Ustawienia wstępne ▾", + "Presets ▾": "Ustawienia wstępne ⌄", "Press keys…": "Naciśnij klawisze…", "Preview": "Podgląd", "Preview Build Progress": "Postęp tworzenia podglądów", @@ -1150,7 +1150,7 @@ "Remove from survey": "Usuń z widoku przeglądu", "Remove location info": "Usuń informacje o lokalizacji", "Remove shortcut": "Usuń skrót klawiszowy", - "Remove this face box (undo with Edit ▸ Undo)": "Usuń tę ramkę twarzy (cofnij przez Edycja ▸ Cofnij)", + "Remove this face box (undo with Edit ▸ Undo)": "Usuń tę ramkę twarzy (cofnij przez Edycja → Cofnij)", "Remove this filter": "Usuń ten filtr", "Remove this group": "Usuń tę grupę", "Remove this rule": "Usuń tę regułę", @@ -1203,7 +1203,7 @@ "Revert": "Cofnij zmiany", "Review": "Przegląd", "Review Import": "Sprawdź import", - "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem ▸ Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem → Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)", "Right-click a preset to delete it": "Kliknij ustawienie wstępne prawym przyciskiem, aby je usunąć", "Rights Usage Terms": "Warunki korzystania z praw", "Rotate": "Obróć", @@ -1887,7 +1887,7 @@ "Looking for changes": "Wyszukiwanie zmian", "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„zapisano w pamięci, ale nie na dysku”, na górnym pasku pojawia się ostrzeżenie, a LightCraft ponawia próby, dopóki", "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): plik DNG jest porównywany z danymi RAW, zapisywany do pliku tymczasowego, synchronizowany i odczytywany ponownie, zanim", - "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Pomoc ▸ Informacje o systemie i Ustawienia ▸ Wydajność wyjaśniają, dlaczego procesor graficzny nie jest", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Pomoc → Informacje o systemie i Ustawienia → Wydajność wyjaśniają, dlaczego procesor graficzny nie jest", "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(np. D750, D780, D850, D7500, Z 50) nie wyświetlają się już niemal czarne ani z utraconymi szczegółami w cieniach: ich poziom czerni był", "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(zgłoszenie #94): żądanie HTTP ze strony internetowej nie może już przekazać polecenia w treści. Długość wierszy ograniczono do", "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 na studio-mac”) zamiast pozwalać obu procesom zapisywać i po cichu gubić wzajemne zmiany. Awaria nigdy nie pozostawia", @@ -1901,7 +1901,7 @@ "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Zapis przerwany w połowie (pełny dysk, zerwane połączenie z udziałem sieciowym) nie sprawia już wrażenia uszkodzonego katalogu", "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Uśpiony NAS, niedostępny udział sieciowy albo uruchamiający się dysk USB nie blokują już okna (zgłoszenie #104):", "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Maski AI wykorzystujące SAM 3 (Obiekt i Opis w panelu Maskowanie): kliknij obiekt, aby go zaznaczyć (kliknięcie z ⌥ pozostawia", - "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Wersja przeglądarkowa (eksperymentalna), ochrona biblioteki (zgłoszenie #107): Plik ▸ Utwórz kopię zapasową biblioteki… pobiera katalog", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Wersja przeglądarkowa (eksperymentalna), ochrona biblioteki (zgłoszenie #107): Plik → Utwórz kopię zapasową biblioteki… pobiera katalog", "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Zdjęcia Canon CR2 z aparatów EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II i 1D Mark IV (oraz innych", "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Pliki Canon CR3 pokazują pełnowymiarowy podgląd JPEG osadzony w pliku (np. 6960 × 4640 w EOS R6 Mark III), zamiast", "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R zachowuje zdjęcie, ale usuwa jego edycje).", @@ -1913,8 +1913,8 @@ "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Eksporty nie są już czarne z powodu GPU (zgłoszenie #78): renderowanie GPU, któremu zabraknie pamięci urządzenia", "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Szybszy eksport i import z kart, przy zachowaniu ochrony danych nieodtwarzalnych (zgłoszenie #134): eksporty,", "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importuj ustawienia predefiniowane z innych edytorów: ustawienia XMP, klasyczne pliki `.lrtemplate`, „ustawienia DNG” z aplikacji mobilnych i archiwa `.zip`", - "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import ▸ Kopiuj weryfikuje każdą kopię, podobnie jak Przenieś (zgłoszenie #96): każdy plik jest zapisywany jako nowy, synchronizowany z dyskiem i", - "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import ▸ Przenieś: zdjęcia trafiają do folderu docelowego (z takim samym układem folderów i zmianą nazw jak przy kopiowaniu, np.", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import → Kopiuj weryfikuje każdą kopię, podobnie jak Przenieś (zgłoszenie #96): każdy plik jest zapisywany jako nowy, synchronizowany z dyskiem i", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import → Przenieś: zdjęcia trafiają do folderu docelowego (z takim samym układem folderów i zmianą nazw jak przy kopiowaniu, np.", "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: kopiowanie do dowolnego folderu, podział według dni lub miesięcy albo do jednego folderu, zmiana nazw przy imporcie, zestaw metadanych, kopiowanie jako DNG.", "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "Plik JPEG o tej samej nazwie (`IMG_0001.CR3` i `IMG_0001.JPG`) nie współdzieli już jednego pliku XMP: RAW zachowuje `IMG_0001.xmp`,", "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft nie zawiesza się już podczas uruchamiania na komputerach Windows z uszkodzonym sterownikiem Vulkan (zgłoszenie #136, np. niektóre Intel UHD", @@ -1926,8 +1926,8 @@ "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Maski Obiekt i Opis korzystają z modelu segmentacji SAM 3 firmy Meta. Nie jest on częścią LightCraft; wszystkie pozostałe funkcje działają bez niego.", "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Pliki RAW Panasonic i Leica (RW2, RWL i starsze RAW, od DMC-LX1 do DC-S1R II) są wywoływane z danych RAW w", "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Zdjęcie. Pasek zdjęć ma także menu kontekstowe. Ponowne dodanie pliku znajdującego się w Ostatnio usuniętych nie", - "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Zdjęcie ▸ Edytuj w edytorze zewnętrznym (⇧⌘E) tworzy 16-bitową kopię TIFF, grupuje ją z oryginałem i otwiera w wybranym programie (nazwa aplikacji w macOS, ścieżka programu w pozostałych systemach).", - "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Zdjęcia w Ostatnio usuniętych można przywrócić w aplikacji: kliknij prawym przyciskiem ▸ Przywróć (lub Usuń trwale), również w", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Zdjęcie → Edytuj w edytorze zewnętrznym (⇧⌘E) tworzy 16-bitową kopię TIFF, grupuje ją z oryginałem i otwiera w wybranym programie (nazwa aplikacji w macOS, ścieżka programu w pozostałych systemach).", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Zdjęcia w Ostatnio usuniętych można przywrócić w aplikacji: kliknij prawym przyciskiem → Przywróć (lub Usuń trwale), również w", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Zdjęcia widoczne w widoku Lokalnie, lecz nigdy niedodane ani niezmienione, są usuwane z katalogu, jeśli ich folder nie był przeglądany przez określony czas (sprawdzane przy otwieraniu biblioteki). Pliki pozostają na dysku; ponowne przejrzenie folderu znów je pokazuje.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Zdjęcia pojawiające się w tym folderze (z aparatu podłączonego do komputera, skanera lub aplikacji synchronizującej) są dodawane od razu po ukończeniu zapisu.", "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Zdjęcia); importowanie (także przeciąganiem), Znajdź brakujące zdjęcia, Zbuduj / Odrzuć podglądy inteligentne, podgląd zmiany nazw,", @@ -1955,7 +1955,7 @@ "against the library's originals no longer scans the whole library when nothing is at that path.": "z oryginałami biblioteki nie skanuje już całej biblioteki, jeśli pod wskazaną ścieżką nie ma żadnych plików.", "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ma już folder `thumbs/` z innymi zdjęciami — nie tracą one danych przy przycinaniu lub czyszczeniu pamięci podręcznej.", "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "i sprawdza plik przed użyciem. Maski zachowują swoje zaznaczenie, więc renderowanie i eksport działają także bez modelu.", - "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "i każde importowane zdjęcie w jednym archiwum ZIP, a Plik ▸ Przywróć bibliotekę z kopii zapasowej… przywraca je (bieżąca biblioteka", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "i każde importowane zdjęcie w jednym archiwum ZIP, a Plik → Przywróć bibliotekę z kopii zapasowej… przywraca je (bieżąca biblioteka", "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "oraz nie blokuje okna. Funkcja jest opcjonalna i nie należy do LightCraft (licencja SAM firmy Meta, około 3,4 GB):", "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "jako `<name>.corrupt-<time>` i informuje o tym; plik, którego nie można odczytać (np. zablokowany przez inny program), pozostaje bez zmian", "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "gdy o to poprosisz. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (lub `WGPU_BACKEND` biblioteki wgpu, którą renderowanie GPU", @@ -1967,7 +1967,7 @@ "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "weryfikowane względem zawartości odczytanej z karty. Nieudaną lub różniącą się kopię usuwa się i zgłasza jako nieudany", "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "kolory i tonację dopasowane do JPEG z aparatu, podobnie jak w plikach Nikon i Sony RAW, z kadrem zgodnym z proporcjami ustawionymi w", "copy is verified and in the library. Duplicates and files that fail stay where they were.": "kopia została zweryfikowana i dodana do biblioteki. Duplikaty i pliki z błędami pozostają na swoim miejscu.", - "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dane nie są jeszcze dekodowane, więc pozostają dostępne tylko jako podgląd. Dla plików CR3 zaimportowanych wcześniej funkcja Zdjęcie ▸ Wczytaj ponownie z dysku (teraz w", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dane nie są jeszcze dekodowane, więc pozostają dostępne tylko jako podgląd. Dla plików CR3 zaimportowanych wcześniej funkcja Zdjęcie → Wczytaj ponownie z dysku (teraz w", "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "usuwane są krawędzie, a „12-bitowe nieskompresowane” pliki nie są już przycinane tonalnie. Seria RX100 wyświetla się znacznie bliżej", "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "każdy format zapisywany przez aparaty. Większość modeli wcześniej otwierała tylko podgląd: GH1–GH5, serie G, GX, GF, GM, FZ, LX i", "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "eksport. Katalog, pliki XMP, ustawienia, DNG, połączone zdjęcia, podglądy inteligentne i kopie utworzone podczas importu nadal są", @@ -1987,7 +1987,7 @@ "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "niedokończone renderowanie jest powtarzane na CPU — plik wynikowy zawiera ten sam obraz w obu przypadkach. Praca jest wysyłana do GPU", "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "jest zachowywany). Nieudany zapis (pełny nośnik) wyświetla teraz ostrzeżenie o niezapisanych zmianach i jest ponawiany; zdjęcie, którego nie można zapisać", "is refused and nothing is overwritten.": "jest odrzucana i nic nie zostaje nadpisane.", - "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "działa, i tylko jeśli w Ustawienia ▸ Wydajność włączono używanie GPU do renderowania; jeśli LightCraft kiedykolwiek zakończy się awaryjnie podczas", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "działa, i tylko jeśli w Ustawienia → Wydajność włączono używanie GPU do renderowania; jeśli LightCraft kiedykolwiek zakończy się awaryjnie podczas", "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "nie zostaje dodana, druga karta pokazuje komunikat zamiast nadpisywać pierwszą, `?reset` najpierw pyta, a strona informuje", "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "z dala od drugiego pliku (zgłoszenie #92). Znajdź brakujące zdjęcia wyszukuje również pliki o zmienionych nazwach na podstawie zawartości, preferuje zgodność zawartości", "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ego zdjęcia i pliki XMP, a zdjęcia ponownie na niego wskazują. Jeśli stare miejsce jest już zajęte, cofnięcie", @@ -2019,7 +2019,7 @@ "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "podczas uruchamiania GPU następny start odbywa się z wyłączonym renderowaniem GPU i informacją, jak je ponownie włączyć.", "starts with": "zaczyna się od", "still saves your queued changes in the closing snapshot.": "nadal zapisuje oczekujące zmiany w migawce zamykającej.", - "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronizowane. Import ▸ Kopiuj sprawdza teraz każdą kopię względem skrótu zawartości obliczonego podczas skanowania karty, zamiast", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronizowane. Import → Kopiuj sprawdza teraz każdą kopię względem skrótu zawartości obliczonego podczas skanowania karty, zamiast", "the JPEG uses `IMG_0001.JPG.xmp`.": "a JPEG korzysta z `IMG_0001.JPG.xmp`.", "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "drzewo folderów Lokalnie i Automatyczny import również odczytują dysk w wątkach roboczych. Okno postępu importu ma przycisk Anuluj.", "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "menu Zdjęcie i menu kontekstowym zdjęcia) pobiera pełnowymiarowy podgląd i uzupełnia metadane aparatu, których", @@ -2039,5 +2039,123 @@ "日本語": "日本語", "繁體中文(台灣)": "繁體中文(台灣)", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Ta wersja programu nie może jeszcze pobrać modelu. Aby zainstalować go ręcznie, umieść pliki model.safetensors, vocab.json i merges.txt od Meta (facebook/sam3) w powyższym folderze: funkcje Obiekt i Opis zadziałają, gdy pliki się tam znajdą.", - "denoiseModel.accept": "denoiseModel.accept" + "denoiseModel.accept": "denoiseModel.accept", + "Add Face Model…": "Dodaj model twarzy…", + "Add to Apple Photos": "Dodaj do aplikacji Zdjęcia", + "Add to My Photos First": "Najpierw dodaj do Moich zdjęć", + "Add to favorites": "Dodaj do ulubionych", + "Adding photos…": "Dodawanie zdjęć…", + "Adding to Apple Photos": "Dodawanie do aplikacji Zdjęcia", + "Adding to Apple Photos…": "Dodawanie do aplikacji Zdjęcia…", + "Albums hold library photos: Add to My Photos first": "Albumy zawierają zdjęcia z biblioteki: najpierw dodaj je do Moich zdjęć", + "Appearance": "Wygląd", + "Appearance Mode": "Tryb wyglądu", + "Appearance Settings…": "Ustawienia wyglądu…", + "Cancel Face Model Download": "Anuluj pobieranie modelu twarzy", + "Cancel Pending Photo Action": "Anuluj oczekującą operację na zdjęciu", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Pliki C-RAW z aparatu Canon EOS R5 Mark II, które otwierały się jako „tylko podgląd” (w zależności od", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "sceny ponad połowa z nich), są teraz wywoływane z danych matrycy wszystkimi narzędziami, jak inne pliki RAW. Zdjęcia", + "imported earlier switch over with Photo ▸ Reload from Disk.": "zaimportowane wcześniej przełączysz poleceniem Zdjęcie → Wczytaj ponownie z dysku.", + "Charcoal": "Grafit", + "Choose Face Recognition Model": "Wybierz model rozpoznawania twarzy", + "Choose a model below (Use) to start.": "Aby zacząć, wybierz model poniżej (Użyj).", + "Clear Recent Libraries": "Wyczyść listę ostatnich bibliotek", + "Content-Aware": "Z uwzględnieniem zawartości", + "Dark Mode": "Tryb ciemny", + "Dark Theme": "Ciemny motyw", + "Detect Faces": "Wykryj twarze", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Potrzebuje go polecenie Wykryj twarze (menu Zdjęcie): to niewielkie, jednorazowe pobranie.", + "Download Face Model": "Pobierz model twarzy", + "Download Place Names (GeoNames)": "Pobierz nazwy miejsc (GeoNames)", + "Downloading place names": "Pobieranie nazw miejsc", + "Drag Crop Handle": "Przeciągnij uchwyt kadrowania", + "Evaluate Face Recognition": "Oceń rozpoznawanie twarzy", + "Face Model Downloads": "Pobieranie modeli twarzy", + "Face Models": "Modele twarzy", + "Face Recognition On/Off": "Rozpoznawanie twarzy wł./wył.", + "Face detector": "Detektor twarzy", + "Face recognition": "Rozpoznawanie twarzy", + "Faces": "Twarze", + "Faces of a Person": "Twarze osoby", + "Files with the same content as another file in this import: only the first is imported.": "Pliki o tej samej zawartości co inny plik w tym imporcie: importowany jest tylko pierwszy.", + "Fill Location": "Uzupełnij lokalizację", + "Fill Location from GPS": "Uzupełnij lokalizację z GPS", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Uzupełnij puste pola Miasto, Region i Kraj na podstawie GPS (nazwy miejsc GeoNames, offline)", + "Go back to listing them by name": "Wróć do listy według nazwy", + "Hide This Section": "Ukryj tę sekcję", + "Horizontal inset (% of short edge)": "Margines poziomy (% krótszego boku)", + "In Use": "W użyciu", + "Include selected photos": "Uwzględnij zaznaczone zdjęcia", + "Index Faces": "Indeksuj twarze", + "Index Faces in the Background": "Indeksuj twarze w tle", + "Inspect Face Model File": "Sprawdź plik modelu twarzy", + "Install Face Model": "Zainstaluj model twarzy", + "Intent:": "Metoda odwzorowania:", + "Library Folders": "Foldery biblioteki", + "Library name": "Nazwa biblioteki", + "Light Mode": "Tryb jasny", + "Light Theme": "Jasny motyw", + "Lights Out": "Zgaszone światła", + "Loading preview…": "Wczytywanie podglądu…", + "Locate Missing Files…": "Odszukaj brakujące pliki…", + "Looking up places": "Wyszukiwanie miejsc", + "Midnight": "Północ", + "Move Selected Photos Here": "Przenieś tu zaznaczone zdjęcia", + "Move Sidebar Section": "Przenieś sekcję paska bocznego", + "Move to Folder": "Przenieś do folderu", + "Move to Folder…": "Przenieś do folderu…", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Przenosi pliki wraz z plikami towarzyszącymi XMP do tego folderu; niczego nie nadpisuje", + "Name Face": "Nazwij twarz", + "Name Faces": "Nazwij twarze", + "New Folder on Disk": "Nowy folder na dysku", + "New Folder on Disk…": "Nowy folder na dysku…", + "New Library": "Nowa biblioteka", + "New Library: Choose Where to Put It": "Nowa biblioteka: wybierz miejsce zapisu", + "New Library…": "Nowa biblioteka…", + "Next Appearance Mode": "Następny tryb wyglądu", + "No Recent Libraries": "Brak ostatnich bibliotek", + "No folders to browse": "Brak folderów do przeglądania", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nie znaleziono procesora graficznego: LightCraft renderuje na CPU, co jest znacznie wolniejsze. Szczegóły w Pomoc → Informacje o systemie.", + "No imported folders yet": "Brak zaimportowanych folderów", + "No photos yet": "Brak zdjęć", + "On NixOS, start it from `nix develop`.": "W systemie NixOS uruchom go z `nix develop`.", + "Open Recent Library": "Otwórz ostatnią bibliotekę", + "Other…": "Inne…", + "Paper": "Papier", + "Perceptual": "Percepcyjna", + "Place Names Status": "Stan nazw miejsc", + "Place names download cancelled": "Anulowano pobieranie nazw miejsc", + "Place names downloaded": "Pobrano nazwy miejsc", + "Preview unavailable": "Podgląd niedostępny", + "Reading photos…": "Odczytywanie zdjęć…", + "Recent Libraries": "Ostatnie biblioteki", + "Recognise faces (experimental)": "Rozpoznawaj twarze (eksperymentalne)", + "Recognition models": "Modele rozpoznawania", + "Relative": "Względna", + "Remove Face Model": "Usuń model twarzy", + "Remove from favorites": "Usuń z ulubionych", + "Reorder Album": "Zmień kolejność w albumie", + "Reset Sidebar Sections": "Przywróć sekcje paska bocznego", + "Show All Mask Overlays": "Pokaż wszystkie nakładki masek", + "Show Person": "Pokaż osobę", + "Show Sidebar Section": "Pokaż sekcję paska bocznego", + "Show all overlays": "Pokaż wszystkie nakładki", + "Sidebar Sections": "Sekcje paska bocznego", + "Silver": "Srebrny", + "Simulate Paper & Ink": "Symuluj papier i tusz", + "Sort Albums A–Z": "Sortuj albumy A–Z", + "Sort Albums by Name": "Sortuj albumy według nazwy", + "Sort Contents A–Z": "Sortuj zawartość A–Z", + "Suggest Names for Faces": "Proponuj imiona dla twarzy", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Podpowiada, kto jest na zdjęciu, na podstawie nazwanych przez Ciebie twarzy. Wszystko zostaje na Twoim komputerze.", + "Sync with System": "Zgodnie z systemem", + "Test Face Model": "Przetestuj model twarzy", + "The first time, macOS asks whether LightCraft may control Photos.": "Za pierwszym razem macOS zapyta, czy LightCraft może sterować aplikacją Zdjęcia.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Import odczytuje katalog bez zmieniania go i dodaje jego zdjęcia, edycje, słowa kluczowe i kolekcje do otwartej teraz biblioteki.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Ta wersja nie może uruchamiać modeli rozpoznawania: można je dodać i wybrać, ale nie używać.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Ta wersja nie ma gdzie przechowywać modeli twarzy, więc lista modeli jest niedostępna. (Aplikacja na komputer ją ma.)", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?", + "Unnamed Faces": "Nienazwane twarze", + "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", + "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)" } From eec4746a56d0b55e4d592253fee76531fb2bafb0 Mon Sep 17 00:00:00 2001 From: draco892 <draconian892@gmail.com> Date: Tue, 13 Oct 2026 11:01:40 +0200 Subject: [PATCH 080/315] build: automatically include craft-fonts in desktop packages Detect ../craft-fonts or use CRAFT_FONTS_DIR on macOS, Linux and Windows. Download the pinned release revision when the checkout is missing and require Chinese and Japanese fonts during the build. Document font setup and preserve skip-build behavior. --- docs/releasing.md | 10 ++++++++++ packaging/fonts.sh | 31 +++++++++++++++++++++++++++++++ packaging/linux/package.sh | 4 ++++ packaging/macos/package.sh | 4 ++++ packaging/windows/fonts.ps1 | 24 ++++++++++++++++++++++++ packaging/windows/package.ps1 | 4 ++++ 6 files changed, 77 insertions(+) create mode 100644 packaging/fonts.sh create mode 100644 packaging/windows/fonts.ps1 diff --git a/docs/releasing.md b/docs/releasing.md index feff91d87..44a1afaaa 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -64,6 +64,16 @@ whose glyph-coverage test checks the characters in available faces. `CRAFT_FONTS the native build fail if the pinned manifest lacks Noto Sans CJK SC Regular (`Hans`) or BIZ UDPGothic Regular; the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a separate asset. +Local macOS, Linux and Windows packaging scripts automatically use the sibling `../craft-fonts` +checkout. If it is absent, they clone it at revision +`8dcdacd5153e64560d109541a47d806f26f048c0` (keep the pins in `packaging/fonts.sh` and +`packaging/windows/fonts.ps1` in sync with the release workflow). Existing checkouts are left untouched. +Set `CRAFT_FONTS_DIR` to use another +checkout; relative paths resolve from the workspace root. The script requires the Chinese and Japanese +UI faces and includes their OFL licences in the package. Download failures or incomplete checkouts +stop packaging with an error. `--skip-build` (`-SkipBuild` on Windows) packages existing binaries without downloading fonts or +changing what they embed; pass the same `CRAFT_FONTS_DIR` used to compile them so their licences ship. + ## Signing credentials All credentials are stored as secrets in the GitHub `release` environment. Signing is optional: diff --git a/packaging/fonts.sh b/packaging/fonts.sh new file mode 100644 index 000000000..61dc913f4 --- /dev/null +++ b/packaging/fonts.sh @@ -0,0 +1,31 @@ +# shellcheck shell=bash +# Font setup for local packaging; sourced after packaging/env.sh. +prepare_fonts() { + local font_dir="${CRAFT_FONTS_DIR:-$ROOT/../craft-fonts}" + # Relative overrides are documented relative to the workspace, regardless of the caller's cwd. + case "$font_dir" in + /*) ;; + *) font_dir="$ROOT/$font_dir" ;; + esac + + if [ -z "${CRAFT_FONTS_DIR:-}" ] && [ ! -e "$font_dir" ]; then + # Same revision as release.yml. Never update or reset an existing checkout. + local revision=8dcdacd5153e64560d109541a47d806f26f048c0 + echo "==> downloading craft-fonts ($revision) to $font_dir" + if ! git clone --no-checkout https://github.com/storytold/craft-fonts "$font_dir" || + ! git -C "$font_dir" checkout --detach "$revision"; then + echo "error: could not prepare craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR" >&2 + return 1 + fi + fi + + if [ ! -f "$font_dir/fonts/manifest.txt" ]; then + echo "error: missing $font_dir/fonts/manifest.txt; set CRAFT_FONTS_DIR to a complete craft-fonts checkout" >&2 + return 1 + fi + CRAFT_FONTS_DIR="$(cd "$font_dir" && pwd)" || return 1 + export CRAFT_FONTS_DIR + # Fail the build if required faces are missing, rather than ship empty glyph boxes. + export CRAFT_FONTS_REQUIRED=1 + echo "==> embedding fonts from $CRAFT_FONTS_DIR" +} diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index b5e1d9457..383c20f07 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -8,6 +8,7 @@ # $DIST/lightcraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/) # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] +# Fonts: use CRAFT_FONTS_DIR or ../craft-fonts; download the pinned checkout if absent. # # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, @@ -45,6 +46,9 @@ BASENAME="lightcraft-$VERSION-linux-$ARCH" echo "==> LightCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then + # shellcheck source=../fonts.sh + . "$ROOT/packaging/fonts.sh" + prepare_fonts (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif \ ${CROSS_TARGET:+--target "$CROSS_TARGET"}) fi diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b26aa96d6..f26237a4c 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -5,6 +5,7 @@ # $DIST/lightcraft-cli-<version>-macos-<arch>.zip the headless CLI # # Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build] +# Fonts: use CRAFT_FONTS_DIR or ../craft-fonts; download the pinned checkout if absent. # # Signing (env): # MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing; @@ -56,6 +57,9 @@ echo "==> LightCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: # ---- build ------------------------------------------------------------------------------------- if [ "$SKIP_BUILD" = 0 ]; then + # shellcheck source=../fonts.sh + . "$ROOT/packaging/fonts.sh" + prepare_fonts args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif "${args[@]}") diff --git a/packaging/windows/fonts.ps1 b/packaging/windows/fonts.ps1 new file mode 100644 index 000000000..c927fbb7a --- /dev/null +++ b/packaging/windows/fonts.ps1 @@ -0,0 +1,24 @@ +# Font setup for local packaging; dot-sourced by package.ps1. +function Initialize-CraftFonts([string] $WorkspaceRoot) { + $fontDir = $env:CRAFT_FONTS_DIR + if (-not $fontDir) { $fontDir = Join-Path $WorkspaceRoot '../craft-fonts' } + if (-not [IO.Path]::IsPathRooted($fontDir)) { $fontDir = Join-Path $WorkspaceRoot $fontDir } + + if (-not $env:CRAFT_FONTS_DIR -and -not (Test-Path -LiteralPath $fontDir)) { + # Same revision as release.yml and packaging/fonts.sh. Existing checkouts stay untouched. + $revision = '8dcdacd5153e64560d109541a47d806f26f048c0' + Write-Output "==> downloading craft-fonts ($revision) to $fontDir" + & git clone --no-checkout https://github.com/storytold/craft-fonts $fontDir + if ($LASTEXITCODE -ne 0) { throw 'Could not download craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' } + & git -C $fontDir checkout --detach $revision + if ($LASTEXITCODE -ne 0) { throw 'Could not check out craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' } + } + + $manifest = Join-Path $fontDir 'fonts/manifest.txt' + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + throw "Missing $manifest; set CRAFT_FONTS_DIR to a complete craft-fonts checkout" + } + $env:CRAFT_FONTS_DIR = (Resolve-Path -LiteralPath $fontDir).Path + $env:CRAFT_FONTS_REQUIRED = '1' + Write-Output "==> embedding fonts from $env:CRAFT_FONTS_DIR" +} diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index e2cd2a8e7..27e0f0a03 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -11,6 +11,8 @@ zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a warning when no signing secrets are set). + Fonts: uses CRAFT_FONTS_DIR or ../craft-fonts; downloads the pinned checkout if absent. + Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe), and WiX v5: dotnet tool install --global wix --version 5.0.2 The WiX UI and Util extensions (installer dialogs, "Launch LightCraft" on Finish) are added to @@ -81,6 +83,8 @@ if (-not $env:LIGHTCRAFT_BUILD_DATE) { $env:LIGHTCRAFT_BUILD_DATE = (Get-Date).T Write-Output "LightCraft $Version for Windows $Arch ($Target)" if (-not $SkipBuild) { + . (Join-Path $PSScriptRoot 'fonts.ps1') + Initialize-CraftFonts $Root # Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and # proc-macros are unaffected. $flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS' From d9cea1404a7f7f09d10074bda9bcd617e96acb7e Mon Sep 17 00:00:00 2001 From: cookingwithsimon <335935942+cookingwithsimon@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:08:53 +0000 Subject: [PATCH 081/315] LR-IMP-PROFILES: presets apply the imported profile they name (#1001) A Lightroom preset's crs:Look (name, group, UUID, amount; the Look table in .lrtemplate files) is kept as profile.look and resolved against the user's imported profiles by name, the group breaking ties. Lightroom's six basic profiles map to ours; its own creative/camera profiles and profiles not imported yet are reported per preset. References are resolved again after profile.import / profile.deleteImported, so profiles and presets can be imported in either order. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxP2xEy623xzWGqCWXVwtb --- crates/engine/src/cmd/lut_profiles.rs | 3 + crates/engine/src/cmd/preset_files.rs | 14 +- crates/engine/src/lib.rs | 1 + crates/engine/src/preset_import.rs | 31 ++++ crates/engine/src/profile_refs.rs | 205 ++++++++++++++++++++++++++ crates/engine/src/tests_import.rs | 68 +++++++++ docs/parity-checklist.md | 5 +- docs/xmp-interop.md | 15 +- 8 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/profile_refs.rs diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 349e66ea8..f03809b62 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { imported.push(json!({"id": id, "name": display, "group": group})); } s.save_prefs()?; + // presets that name these profiles use them now + crate::profile_refs::refresh(s); Ok(json!({"imported": imported, "failed": failed})) } @@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> { } lightcraft_pipeline::lut::unregister(&id); s.save_prefs()?; + crate::profile_refs::refresh(s); Ok(json!({"deleted": id})) }), ] diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c..6ece6d63c 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -33,10 +33,20 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { if let Some(g) = &group { it.preset.group = g.clone(); } + // the profile it names (issue #1001): one the user imported, or ours for a basic one + let mut profile = Value::Null; + if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) { + let look = it.preset.settings["profile"]["look"].clone(); + it.unmapped.retain(|u| u != "Look"); + it.unmapped.extend(crate::profile_refs::report(&look, &r)); + profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")}); + } let (name, grp) = (it.preset.name.clone(), it.preset.group.clone()); let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() }; match id { - Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})), + Some(id) => { + imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile})) + } None => skipped += 1, } } @@ -83,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed}", always, import ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9631b7d84..70c97b1e1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub mod originals; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod profile_refs; pub mod proof; pub mod rename; pub mod segment; diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 044139ff9..282d08535 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -371,6 +371,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma let mut values = crate::crs_masks::Values::new(); for (k, v) in fields { let key = format!("crs:{k}"); + // the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…) + if k == "Look" + && let Lua::Table(_, look) = v + { + for (f, fv) in look { + let val = match fv { + Lua::Num(n) => format!("{n}"), + Lua::Str(s) => delocalize(s), + _ => continue, + }; + props.insert(format!("crs:Look/crs:{f}"), vec![val]); + } + continue; + } if crate::crs_masks::CONTAINERS.contains(&key.as_str()) { values.insert(key.clone(), crate::crs_masks::from_lua(v)); } @@ -473,6 +487,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri o.remove("wb"); } } + // the profile it names, resolved against the user's imported profiles by `preset.import` + if let Some(look) = crate::profile_refs::look_ref(props) + && let Some(o) = settings.as_object_mut() + { + o.insert("profile".into(), serde_json::json!({"look": look})); + } if settings.as_object().is_none_or(Map::is_empty) { return None; } @@ -660,6 +680,17 @@ text]], z = ZSTR "loc" }"#, assert_eq!(d.light.exposure, 0.35); } + #[test] + fn lrtemplate_keeps_the_profile_it_names() { + let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2, + Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#; + let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap(); + assert_eq!( + v[0].preset.settings["profile"], + serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}}) + ); + } + #[test] fn old_process_version_fields_are_approximated() { let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }"; diff --git a/crates/engine/src/profile_refs.rs b/crates/engine/src/profile_refs.rs new file mode 100644 index 000000000..b9a7d2d37 --- /dev/null +++ b/crates/engine/src/profile_refs.rs @@ -0,0 +1,205 @@ +//! Profiles named by imported presets (issue #1001). +//! +//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group, +//! UUID, amount), and many presets get most of their look from that profile. On import the +//! reference is kept in the preset's settings as `profile.look`, and resolved against the +//! profiles the user has imported (by name, the group breaking ties), so the preset applies the +//! profile like the other app does. The six basic profiles of the other app map to our own basic +//! profiles; its other built-in profiles can't be used here and the import says so. +//! +//! References are resolved again whenever profiles are imported or deleted, so a pack works in +//! either order. Only the user's own files are read; no profile content ships with LightCraft. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::lut_profiles::LutProfile; +use crate::crs::Props; + +/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`): +/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]). +pub fn look_ref(props: &Props) -> Option<Value> { + let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty()); + let name = field("Name")?; + // stored as a fraction (1 = 100 %), 0–2 + let amount = field("Amount").and_then(|a| a.parse::<f64>().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0)); + let mut look = json!({"name": name, "amount": amount}); + if let Some(g) = field("Group") { + look["group"] = json!(g); + } + if let Some(u) = field("UUID") { + look["uuid"] = json!(u.to_ascii_lowercase()); + } + Some(look) +} + +/// What a preset's profile reference resolved to. +#[derive(Clone, Debug, PartialEq)] +pub enum Resolved { + /// A profile the user imported (`lut:…`). + Imported(String), + /// One of our built-in profiles standing in for the other app's basic profile of that name. + BuiltIn(String), + /// One of the other app's own creative or camera profiles: not available here. + OtherAppBuiltIn, + /// A third-party profile the user hasn't imported (yet). + NotImported, +} + +fn norm(s: &str) -> String { + s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase() +} + +/// The other app's basic profiles and ours that stand in for them. +const BASIC: &[(&str, &str)] = &[ + ("adobe color", "lc.color"), + ("adobe neutral", "lc.neutral"), + ("adobe vivid", "lc.vivid"), + ("adobe landscape", "lc.landscape"), + ("adobe portrait", "lc.portrait"), + ("adobe monochrome", "lc.mono"), +]; + +/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered +/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`). +fn other_app_builtin(name: &str) -> bool { + let n = norm(name); + if n.starts_with("adobe ") || n.starts_with("camera ") { + return true; + } + ["artistic ", "b&w ", "modern ", "vintage "] + .iter() + .any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit()))) +} + +/// Resolve `look` (a `profile.look` value) against the imported profiles. +pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved { + let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default()); + let group = look.get("group").and_then(Value::as_str).map(norm); + if !name.is_empty() { + let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect(); + let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first()); + if let Some(p) = pick { + return Resolved::Imported(p.id.clone()); + } + } + if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) { + return Resolved::BuiltIn((*ours).to_string()); + } + if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported } +} + +/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its +/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile) +/// and whether `settings` changed. +pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> { + let profile = settings.get_mut("profile")?.as_object_mut()?; + let look = profile.get("look")?.clone(); + let r = resolve(&look, luts); + let before = (profile.get("id").cloned(), profile.get("amount").cloned()); + match &r { + Resolved::Imported(id) | Resolved::BuiltIn(id) => { + profile.insert("id".into(), json!(id)); + profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0))); + } + // the photo keeps its own profile + Resolved::OtherAppBuiltIn | Resolved::NotImported => { + profile.remove("id"); + profile.remove("amount"); + } + } + let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned()); + Some((r, changed)) +} + +/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names). +pub fn report(look: &Value, r: &Resolved) -> Option<String> { + let name = look.get("name").and_then(Value::as_str).unwrap_or_default(); + match r { + Resolved::Imported(_) | Resolved::BuiltIn(_) => None, + Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")), + Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")), + } +} + +/// Resolve every user preset's profile reference again (after profiles were imported or deleted). +/// Only the presets that change are touched, so an unchanged list isn't saved again. +pub fn refresh(s: &mut Session) { + let luts = s.lut_profiles.clone(); + let stale: Vec<usize> = s + .presets + .iter() + .enumerate() + .filter(|(_, p)| !p.builtin) + .filter(|(_, p)| { + let mut settings = p.settings.clone(); + apply(&mut settings, &luts).is_some_and(|(_, changed)| changed) + }) + .map(|(i, _)| i) + .collect(); + if stale.is_empty() { + return; + } + let presets = &mut *s.presets; + for i in stale { + if let Some(p) = presets.get_mut(i) { + apply(&mut p.settings, &luts); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lut(id: &str, name: &str, group: &str) -> LutProfile { + LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() } + } + + #[test] + fn resolves_by_name_with_the_group_breaking_ties() { + let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")]; + let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into())); + let look = json!({"name": "kodak portra 400", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group"); + assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported); + } + + #[test] + fn other_app_profiles_map_to_ours_or_are_reported() { + assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into())); + for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] { + assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}"); + } + assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported); + // an imported profile wins over the mapping + assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into())); + } + + #[test] + fn apply_sets_and_clears_the_profile() { + let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}}); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false))); + let luts = [lut("lut:mb1", "mb1 profile", "Imported")]; + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true))); + assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0))); + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved"); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted"); + assert!(s["profile"].get("id").is_none()); + assert_eq!(apply(&mut json!({"light": {}}), &luts), None); + } + + #[test] + fn look_ref_reads_name_group_uuid_and_amount() { + let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0"> + <crs:Look><rdf:Description crs:Name="mb1 profile" crs:Amount="0.75" crs:UUID="0A1B2C"> + <crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Erich McVey B&W Profiles</rdf:li></rdf:Alt></crs:Group> + </rdf:Description></crs:Look> + </rdf:Description></rdf:RDF></x:xmpmeta>"#; + let props = lightcraft_meta::parse_xmp(x).unwrap().properties; + assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"}))); + assert_eq!(look_ref(&Props::new()), None); + } +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..ec5e1437e 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1612,3 +1612,71 @@ fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup } let _ = std::fs::remove_dir_all(&src); } + +/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a +/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and +/// reports the ones it can't use. +#[test] +fn presets_apply_the_imported_profile_they_name() { + let src = temp_dir("lookref"); + let preset = |file: &str, name: &str, look: &str| { + let x = format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" + crs:PresetType="Normal" crs:Name="{name}" crs:UUID="{file}" crs:Exposure2012="0" crs:GrainAmount="20"> + <crs:Look><rdf:Description crs:Name="{look}" crs:Amount="0.6" crs:UUID="1234ABCD"> + <crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Film Pack</rdf:li></rdf:Alt></crs:Group> + </rdf:Description></crs:Look> + </rdf:Description></rdf:RDF></x:xmpmeta>"# + ); + std::fs::write(src.join(format!("{file}.xmp")), x).unwrap(); + }; + preset("a1", "Portra Soft", "Portra 400"); + preset("a2", "Mono", "Adobe Monochrome"); + preset("a3", "Artsy", "Artistic 01"); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap(); + assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}"); + assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"})); + assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"])); + let photo = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone(); + let apply = |s: &mut Session, n: &str| { + let id = pid(s, n); + s.execute("preset.apply", &json!({"id": id})).unwrap(); + s.develop_of(photo).unwrap() + }; + // not imported yet: only grain comes across, the photo keeps its profile + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0)); + // the basic Adobe profiles are ours + assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono"); + s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours"); + // importing the profile afterwards makes the preset use it, at the preset's amount + let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{r} {g} {b}\n")); + } + } + } + std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap(); + std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap(); + let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap(); + let lut = r["imported"][0]["id"].as_str().unwrap().to_string(); + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0)); + // a preset imported after the profile resolves straight away + preset("a4", "Portra Hard", "portra 400"); + let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap(); + assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}"); + // deleting the profile leaves the photo's own profile alone again + s.execute("profile.deleteImported", &json!({"id": lut})).unwrap(); + s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..00ed83a9b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-IMP-PROFILES: presets apply the imported profile they name (#1001); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -77,7 +77,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | @@ -814,3 +814,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-13 | minor | LR-IMP-PROFILES: presets that name a profile apply the imported profile of that name, and Lightroom's basic profiles map to ours (issue #1001); status unchanged (✅) | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..706a754ed 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -147,7 +147,18 @@ Values pass through our control specs, so anything outside our slider ranges get `crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for. -**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, +**Profiles named by presets (`Look`, issue #1001).** A preset keeps the profile it names (`crs:Look`: `Name`, `Group`, +`UUID`, `Amount`; in `.lrtemplate` files the `Look` table) as `profile.look` in its settings. When the user has imported +a profile of that name (`profile.import`; the name compared ignoring case and repeated spaces, the group breaking +ties), applying the preset selects it at the look's amount (`Amount` 1 = 100 %). Lightroom's basic profiles map to our +own (`Adobe Color` → Color, `Adobe Neutral`, `Adobe Vivid`, `Adobe Landscape`, `Adobe Portrait`, `Adobe Monochrome` +→ Monochrome). Any other `Adobe …` / `Camera …` profile and the numbered creative ones (`Artistic 01`, `B&W 05`, +`Modern 10`, `Vintage 02`) can't be used here, and a third-party profile not imported yet leaves the photo's profile as it +is; import reports either per preset (`Profile “Name” (Lightroom's own)` / `(not imported yet)` in `unmapped`). +References are resolved again whenever profiles are imported or deleted, so a pack's profiles and presets can come in +either order. The look's embedded `Parameters` are not read. + +**Not mapped:** camera profiles (`CameraProfile`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. ## Spot removal @@ -198,7 +209,7 @@ carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`. |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, a profile named by `Look` that isn't imported, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). From 5cb57ef4ea23a3892e7d45729d7846424686602d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 13 Oct 2026 09:10:47 +0000 Subject: [PATCH 082/315] LR-PROF-CAMERACOLOR: Canon EOS R7 / R6 Mark II calibrate measurements Held-out and library-wide calibrate runs on real CR3s (calibrate's CR3 / CR2 input is #1073): profiles change little (R7 2.52 -> 2.57, R6 Mark II 6.12 -> 6.09 mean dE), so none is bundled; notes the CRX adaptive QP 172/175 files that stay preview-only. The catalog test pins that an EOS R6 Mark II (Exif "Canon EOS R6m2") edits in Kelvin through its measured matrices and an EOS R7 stays relative. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzWyUn77ZWLe3q3qMiQbVt --- crates/catalog/src/model.rs | 8 +++++++- docs/camera-preview-colour.md | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..c55864ae7 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -609,6 +609,8 @@ mod edited_tests { ("SONY ILCE-7RM4A", "ARW", false), ("NIKON CORPORATION NIKON D850", "NEF", false), ("Canon Canon EOS 5D Mark III", "CR2", false), + ("Canon Canon EOS R6m2", "CR3", false), + ("Canon Canon EOS R7", "CR3", true), ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), ("SONY ILCE-7M2", "ARW", true), @@ -624,7 +626,11 @@ mod edited_tests { p.as_shot_wb = Some((4986.0, -2.0)); assert_eq!(p.relative_wb(), relative, "{camera} {format}"); assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}"); - assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}"); + assert_eq!( + p.fitted_look(), + format == "ARW" || format == "NEF" || format == "CR2" || format == "CR3" || format == "RAF", + "{camera} {format}" + ); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}"); assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}"); diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..4daf7fb56 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -122,6 +122,15 @@ Without a profile, 123 of the 131 photos accept their own fit; the other 8 open The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. +### Canon EOS R7 and EOS R6 Mark II + +With CR3 and CR2 input to `calibrate` (#1073), tried on one photographer's library (CR3, RAW and C-RAW, October 2025 to September 2026) and measured as above, against each file's own full-size embedded JPEG (6960 × 4640 R7, 6000 × 4000 R6 Mark II): + +- Held out: 40 photos per body from different shoots, every fifth withheld (8 per body), profiles fitted on the other 32 (30 R7 photos with 107,286 colour pairs, 28 R6 Mark II with 93,200; both with a hue/saturation table). On the withheld photos the profile changes little: R7 mean ΔE 2.52 → 2.57 (median 2.37 → 2.25; 3 better, 3 worse, 2 unchanged; largest regression +0.51 on an almost colourless photo), R6 Mark II 6.12 → 6.09 (median 5.73 → 5.50; 3 better, 4 worse, 1 unchanged; largest regression +0.48, best gain −0.67). Mean ΔL\* 0.00 (R7) and +0.57 (R6 Mark II) either way. Not bundled. +- The profile only replaces the colour inside an accepted per-photo fit (`fit_preview`), so a photo whose own fit is rejected renders the neutral fallback with or without it: 2 of the 16 withheld photos (one per body), plus one with a black-and-white camera JPEG, rendered identically both ways. Unlike the ILCE-7CR, where the profile rescued every rejected photo, here it cannot. +- Library-wide (`--max 600` over 35,796 CR3s): 410 R7 photos (1.39 million pairs), 119 R6 Mark II (386k) and 38 EOS R100 (129k) gave profiles; 32 photos had no usable camera JPEG or colour (mostly moon, night and other near-colourless scenes) and one did not decode. The R6 Mark II writes its Exif model as "Canon EOS R6m2", the profile's file name too. +- Decoding: 4 of the R6 Mark II C-RAW files tried (3 of 40 sampled, 1 of 119 in the library run) are refused with "unsupported raw variant: Canon CRX adaptive QP value 172" (or 175) and open preview-only. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. From 25314aaacf39ed2c0616237489ae5bde1bbc94f6 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:09:47 +0300 Subject: [PATCH 083/315] LR-MERGE-PANO: a panorama of raws keeps the as-shot white balance of its frames (#1144) A raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral of its own. Loading a frame dropped the multipliers and stitch copied the first frame's colour tags, so a panorama of frames that reach the stitcher without a neutral wrote a DNG with neither AsShotNeutral nor AsShotWhiteXY, and a viewer had no white to start from (a strong colour cast until White Balance > Auto). Frames now carry the multipliers (Frame::wb_multipliers, filled from the decoded raw; load_frame_with is split so a decoded raw can be tested without a file). stitch takes the median R/G and B/G of the multipliers of the frames that are in the panorama (the mean of the middle two for an even count; frames without multipliers, or with non-finite or non-positive ones, do not vote) and sets the result's AsShotNeutral to their reciprocal, green = 1. The source's AsShotWhiteXY is dropped with it: a reader takes it before the neutral and the DNG spec makes the two exclusive. With no multipliers anywhere (DNG sources) the first frame's tags are kept, as before. In the app and the CLI a raw without colour matrices already gets a neutral from starting_colour_tags (#965). For frames balanced alike the new neutral renders the same (checked in the engine crate); it differs when the camera balanced the frames differently: the panorama follows the median instead of the first frame. Tests: the frame carries the multipliers; the consensus for odd and even counts, with missing and unusable values; stitching raw-style frames gives the median AsShotNeutral, ignores frames outside the panorama and leaves the colour as it was without multipliers; the written DNG, read back with the raw reader, has the reciprocal AsShotNeutral and the white balance the source's multipliers give. --- crates/engine/src/camera_preview.rs | 30 ++++ crates/engine/src/merge.rs | 1 + crates/merge/src/frame.rs | 250 ++++++++++++++++++++++++---- crates/merge/src/output.rs | 5 +- crates/merge/src/pano/mod.rs | 9 +- crates/merge/tests/white_balance.rs | 191 +++++++++++++++++++++ docs/camera-preview-colour.md | 11 ++ docs/parity-checklist.md | 5 +- 8 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 crates/merge/tests/white_balance.rs diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..fb714025c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -2404,6 +2404,36 @@ mod tests { } } + /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without + /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking + /// it from the multipliers again leaves the merged DNG rendering as the tags do. + #[test] + fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 6a51dc316..c1b17be6d 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -183,6 +183,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, + wb_multipliers: None, }; f.orient(); merged.push(f); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 69743a1b3..ae91adffe 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -6,7 +6,7 @@ use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use lightcraft_raw::ColorData; +use lightcraft_raw::{ColorData, RawImage}; use crate::{MergeError, Result}; @@ -19,6 +19,21 @@ pub enum FrameColor { Linear { to_xyz_d50: Mat3 }, } +impl FrameColor { + /// Make `multipliers` (green = 1) the as-shot white balance of camera colour. A DNG says it as `AsShotNeutral`, the + /// reciprocal of the multipliers. The source's `AsShotWhiteXY` goes: a reader takes it before the neutral and the + /// DNG spec has the two exclude each other, so a stale one would hide the white balance set here. Linear colour has + /// no white balance to set, and multipliers that aren't finite and above zero change nothing. + pub fn set_as_shot_multipliers(&mut self, multipliers: [f32; 3]) { + if let FrameColor::Camera(c) = self + && multipliers.iter().all(|v| v.is_finite() && *v > 0.0) + { + c.as_shot_neutral = Some(multipliers.map(|v| 1.0 / f64::from(v))); + c.as_shot_white_xy = None; + } + } +} + /// One input photo. #[derive(Clone, Debug)] pub struct Frame { @@ -36,6 +51,9 @@ pub struct Frame { pub metadata: Metadata, /// Raw files: the source's `BaselineExposure` (EV), carried into the output. pub baseline_exposure: f64, + /// Raw files: the as-shot white-balance multipliers from the vendor's maker notes (green = 1), for formats such + /// as CR2 and NEF that have no `AsShotNeutral` of their own. `None` when the file doesn't say. + pub wb_multipliers: Option<[f32; 3]>, } impl Frame { @@ -53,6 +71,7 @@ impl Frame { orientation: Orientation::Normal, metadata: Metadata::default(), baseline_exposure: 0.0, + wb_multipliers: None, } } @@ -81,6 +100,29 @@ pub fn exif_exposure(m: &Metadata) -> Option<f64> { Some((t * iso / (n * n)).log2()) } +/// The as-shot white balance `frames` agree on, as multipliers with green = 1: the median R/G and the median B/G of +/// the frames that have usable multipliers (all three finite and above zero), so one frame the camera balanced +/// differently doesn't move it. With an even number of voters the median is the mean of the middle two. Frames without +/// usable multipliers don't vote; `None` when none does. +pub fn consensus_multipliers<'a>(frames: impl IntoIterator<Item = &'a Frame>) -> Option<[f32; 3]> { + let (mut r_over_g, mut b_over_g) = (Vec::new(), Vec::new()); + for [r, g, b] in frames.into_iter().filter_map(|f| f.wb_multipliers) { + if [r, g, b].iter().all(|v| v.is_finite() && *v > 0.0) { + r_over_g.push(f64::from(r) / f64::from(g)); + b_over_g.push(f64::from(b) / f64::from(g)); + } + } + Some([median(r_over_g)? as f32, 1.0, median(b_over_g)? as f32]) +} + +/// The median of `v` (the mean of the middle two when the count is even); `None` when empty. +fn median(mut v: Vec<f64>) -> Option<f64> { + v.sort_by(f64::total_cmp); + let upper = *v.get(v.len() / 2)?; + let lower = *v.get(v.len().checked_sub(1)? / 2)?; + Some((lower + upper) / 2.0) +} + /// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the /// file's white level is optimistic. Returned slightly below the level. pub fn detect_clip(img: &Rgb32f) -> f32 { @@ -119,41 +161,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo let metadata = lightcraft_meta::extract(bytes); let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { - let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; - // geometric lens corrections stay with the develop settings ("profile corrections") - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(color) = colour(&mut raw, bytes) { - raw.color = color; - } - let small = max_edge.is_some_and(|m| m <= 1200); - let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; - for p in image.data.iter_mut() { - *p = p.map(|v| v.max(0.0)); - } - let clip = detect_clip(&image); - if let Some(m) = max_edge - && image.width.max(image.height) > m - { - image = fit(&image, m, m, Filter::Box); - } - let mut f = Frame { - image, - clip, - exposure, - raw: true, - color: FrameColor::Camera(Box::new(raw.color.clone())), - orientation: raw.orientation, - metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, - baseline_exposure: raw.color.baseline_exposure, - }; - if f.exposure.is_none() { - f.exposure = exif_exposure(&f.metadata); - } - if orient { - f.orient(); - } - return Ok(f); + let raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; + return raw_frame(raw, bytes, metadata, exposure, max_edge, orient, colour); } let opts = match max_edge { Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32), @@ -176,6 +185,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo orientation: Orientation::from_exif(d.orientation), metadata, baseline_exposure: 0.0, + wb_multipliers: None, }; if orient { f.orient(); @@ -183,6 +193,54 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo Ok(f) } +/// A decoded raw (from `bytes`, which `colour` may read) as a frame: developed to camera RGB, with its colour tags, +/// its as-shot white-balance multipliers and its orientation. +fn raw_frame( + mut raw: RawImage, + bytes: &[u8], + metadata: Metadata, + exposure: Option<f64>, + max_edge: Option<usize>, + orient: bool, + colour: &RawColour, +) -> Result<Frame> { + // geometric lens corrections stay with the develop settings ("profile corrections") + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } + let small = max_edge.is_some_and(|m| m <= 1200); + let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; + for p in image.data.iter_mut() { + *p = p.map(|v| v.max(0.0)); + } + let clip = detect_clip(&image); + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let mut f = Frame { + image, + clip, + exposure, + raw: true, + color: FrameColor::Camera(Box::new(raw.color.clone())), + orientation: raw.orientation, + metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, + baseline_exposure: raw.color.baseline_exposure, + wb_multipliers: raw.wb_multipliers, + }; + if f.exposure.is_none() { + f.exposure = exif_exposure(&f.metadata); + } + if orient { + f.orient(); + } + Ok(f) +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +266,124 @@ mod tests { } assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6); } + + /// A 16×12 CR2-style mosaic (no colour tags, as the vendor decoders leave them) with as-shot multipliers. + fn mosaic(wb_multipliers: Option<[f32; 3]>) -> RawImage { + use lightcraft_raw::{BlackLevel, Cfa, OpcodeLists, RawData, RawFormat, Rect}; + let (w, h) = (16usize, 12usize); + RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + } + } + + fn frame_of(raw: RawImage, colour: &RawColour) -> Frame { + raw_frame(raw, &[], Metadata::default(), None, None, true, colour).unwrap() + } + + /// Issue #1144: the as-shot multipliers of a CR2, NEF… (its only white balance) were dropped on loading, so + /// nothing downstream could tell a merged DNG what the camera had balanced to. + #[test] + fn a_raw_frame_carries_the_as_shot_multipliers() { + let wb = [2.05, 1.0, 1.62]; + let f = frame_of(mosaic(Some(wb)), &|_, _| None); + assert!(f.raw); + assert_eq!(f.wb_multipliers, Some(wb)); + // colour tags the caller works out leave them alone + let own = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + let f = frame_of(mosaic(Some(wb)), &|_, _| Some(own.clone())); + assert_eq!(f.wb_multipliers, Some(wb)); + assert_eq!(f.color, FrameColor::Camera(Box::new(own))); + // a file that gives none gives none + assert_eq!(frame_of(mosaic(None), &|_, _| None).wb_multipliers, None); + assert_eq!(Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).wb_multipliers, None); + } + + fn voter(wb_multipliers: Option<[f32; 3]>) -> Frame { + Frame { wb_multipliers, ..Frame::from_linear_rec2020(Rgb32f::new(2, 2), None) } + } + + fn consensus(wb: &[Option<[f32; 3]>]) -> Option<[f32; 3]> { + let frames: Vec<Frame> = wb.iter().map(|m| voter(*m)).collect(); + consensus_multipliers(&frames) + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) + } + + #[test] + fn the_consensus_is_the_median_of_the_voters() { + // one voter is its own median + assert_eq!(consensus(&[Some([2.0, 1.0, 1.6])]), Some([2.0, 1.0, 1.6])); + // an odd count takes the middle one of each ratio, whichever frame it came from, whatever the order + let odd = [Some([2.4, 1.0, 1.4]), Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&odd), Some([2.0, 1.0, 1.6])); + // an outlier doesn't pull it + let outlier = [Some([2.0, 1.0, 1.6]), Some([2.1, 1.0, 1.5]), Some([1.9, 1.0, 1.7]), Some([5.0, 1.0, 0.7]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&outlier), Some([2.0, 1.0, 1.6])); + // an even count takes the mean of the middle two + let even = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + assert!(close(consensus(&even).unwrap(), [2.1, 1.0, 1.55]), "{:?}", consensus(&even)); + let two = [Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]; + assert!(close(consensus(&two).unwrap(), [2.5, 1.0, 1.5]), "{:?}", consensus(&two)); + // green is the unit even when a file's multipliers aren't normalised to it + assert!(close(consensus(&[Some([4.0, 2.0, 3.0])]).unwrap(), [2.0, 1.0, 1.5])); + } + + #[test] + fn frames_without_usable_multipliers_do_not_vote() { + assert_eq!(consensus(&[]), None); + assert_eq!(consensus(&[None, None]), None); + let bad = |m: [f32; 3]| consensus(&[Some(m)]); + assert_eq!(bad([0.0, 1.0, 1.5]), None); + assert_eq!(bad([2.0, 0.0, 1.5]), None); + assert_eq!(bad([2.0, 1.0, -1.5]), None); + assert_eq!(bad([f32::NAN, 1.0, 1.5]), None); + assert_eq!(bad([2.0, f32::INFINITY, 1.5]), None); + assert_eq!(bad([2.0, 1.0, f32::NEG_INFINITY]), None); + // mixed: the good ones decide, the missing and the unusable ones are not counted as anything + let mixed = [None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0]), Some([2.2, 1.0, 1.4]), Some([0.0, 0.0, 0.0])]; + assert!(close(consensus(&mixed).unwrap(), [2.1, 1.0, 1.5]), "{:?}", consensus(&mixed)); + assert_eq!(consensus(&[None, Some([2.0, 1.0, 1.6]), None]), Some([2.0, 1.0, 1.6])); + } + + #[test] + fn multipliers_become_the_reciprocal_as_shot_neutral() { + use lightcraft_color::{D50, Xy}; + let tagged = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.9]), as_shot_white_xy: Some(Xy::new(0.34, 0.35)), ..Default::default() }; + let mut color = FrameColor::Camera(Box::new(tagged.clone())); + color.set_as_shot_multipliers([2.0, 1.0, 1.6]); + let FrameColor::Camera(c) = &color else { panic!("still camera colour") }; + assert_eq!(c.as_shot_neutral, Some([0.5, 1.0, 1.0 / f64::from(1.6f32)])); + assert_eq!(c.as_shot_white_xy, None, "a stale AsShotWhiteXY would be read before the neutral"); + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..tagged.clone() }, "nothing else changes"); + // multipliers that aren't usable change nothing + for bad in [[0.0, 1.0, 1.0], [2.0, f32::NAN, 1.6], [2.0, 1.0, f32::INFINITY], [-2.0, 1.0, 1.6]] { + let mut color = FrameColor::Camera(Box::new(ColorData { as_shot_white_xy: Some(D50), ..Default::default() })); + let before = color.clone(); + color.set_as_shot_multipliers(bad); + assert_eq!(color, before, "{bad:?}"); + } + // linear colour has no white balance to set + let mut linear = Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).color; + let before = linear.clone(); + linear.set_as_shot_multipliers([2.0, 1.0, 1.6]); + assert_eq!(linear, before); + } } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 8128644b6..2d966c2aa 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -4,8 +4,9 @@ //! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the //! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. //! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`, -//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is -//! still camera RGB. +//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over — the data is still camera +//! RGB. A raw with its white balance only in vendor multipliers (CR2, NEF…) has none of its own: the +//! result's `AsShotNeutral` comes from the frames' multipliers ([`FrameColor::set_as_shot_multipliers`]). //! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, //! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 309505614..076c0b85e 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; pub use project::Projection; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::linalg; use crate::{MergeError, Progress, Result, check}; use blend::{Blender, Buf, push_pull}; @@ -362,6 +362,11 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re image = finish::fill_edges(&image, &alpha); } let r = &frames[used[0]]; + // the panorama's white balance is the median of the frames that are in it, not whichever frame came first + let mut color = r.color.clone(); + if let Some(multipliers) = consensus_multipliers(used.iter().filter_map(|&i| frames.get(i))) { + color.set_as_shot_multipliers(multipliers); + } Ok(PanoResult { image, alpha, @@ -375,7 +380,7 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re fov, focal: f_out, origin: (ox, oy), - color: r.color.clone(), + color, metadata: r.metadata.clone(), raw: r.raw, baseline_exposure: r.baseline_exposure, diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs new file mode 100644 index 000000000..4139250f6 --- /dev/null +++ b/crates/merge/tests/white_balance.rs @@ -0,0 +1,191 @@ +//! A panorama of raw files keeps their as-shot white balance (issue #1144). +//! +//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has +//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. + +use lightcraft_color::{D50, Xy}; +use lightcraft_geom::Orientation; +use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::output::color_data; +use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; +use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::ColorData; + +type Multipliers = Option<[f32; 3]>; + +/// A camera that sees linear sRGB, with the colour matrices a DNG gives but no white balance of its own, as a raw +/// file whose white balance is only in the vendor multipliers would be after the app has worked its colour out. +fn camera_without_white_balance() -> ColorData { + let srgb = FrameColor::Linear { + to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()), + }; + ColorData { as_shot_neutral: None, ..color_data(&srgb) } +} + +fn raw_frame(image: Rgb32f, color: &ColorData, wb_multipliers: Multipliers) -> Frame { + let clip = detect_clip(&image); + Frame { + image, + clip, + exposure: None, + raw: true, + color: FrameColor::Camera(Box::new(color.clone())), + orientation: Orientation::Normal, + metadata: Metadata::default(), + baseline_exposure: 0.0, + wb_multipliers, + } +} + +/// Overlapping views of one scene, one frame per entry of `wb`, in order. +fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec<Frame> { + let n = wb.len(); + let yaws: Vec<f64> = (0..n).map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * 25.0).collect(); + let views = lightcraft_merge::synth::pano_views(400, 300, 350.0, &yaws).unwrap(); + views.into_iter().zip(wb).map(|(image, m)| raw_frame(image, color, *m)).collect() +} + +fn pano(frames: Vec<Frame>) -> PanoResult { + stitch(frames, &PanoOptions { projection: Projection::Cylindrical, ..Default::default() }, &no_progress).unwrap() +} + +fn neutral_of(res: &PanoResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +fn reciprocal(m: [f64; 3]) -> [f64; 3] { + m.map(|v| 1.0 / v) +} + +fn assert_close(got: [f64; 3], want: [f64; 3], tolerance: f64) { + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() <= tolerance), "{got:?} vs {want:?}"); +} + +/// The panorama as a DNG and read back with the repo's own raw reader. +fn dng_of(res: &PanoResult) -> lightcraft_raw::RawImage { + let bytes = write_linear_dng(&res.image, &res.color, Orientation::Normal, &res.metadata, res.baseline_exposure, DngSamples::U16).unwrap(); + lightcraft_raw::decode(&bytes).unwrap() +} + +const DAYLIGHT: [f32; 3] = [2.0, 1.0, 1.6]; + +#[test] +fn a_panorama_of_an_odd_number_of_raw_frames_has_their_median_white_balance() { + // three frames the camera balanced slightly differently: the middle R/G and the middle B/G, from different frames + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used, [0, 1, 2]); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); +} + +#[test] +fn a_panorama_of_an_even_number_of_frames_takes_the_mean_of_the_middle_two() { + let wb = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 4); + let (r, b) = ((f64::from(2.0f32) + f64::from(2.2f32)) / 2.0, (f64::from(1.6f32) + f64::from(1.5f32)) / 2.0); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([r, 1.0, b]), 1e-6); + // two frames: the mean of both + let res = pano(shoot(&camera_without_white_balance(), &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); +} + +#[test] +fn frames_without_multipliers_do_not_vote() { + // one frame of three has none (a DNG, say): the other two decide + let wb = [Some([2.0, 1.0, 1.6]), None, Some([2.2, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 3); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.1, 1.0, 1.5]), 1e-6); + // a first frame without them doesn't hide the others' + let wb = [None, Some(DAYLIGHT), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // unusable values don't count either + let wb = [Some([f32::NAN, 1.0, 1.0]), Some(DAYLIGHT), Some([0.0, 1.0, 1.4]), Some([2.0, f32::INFINITY, 1.6]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn a_frame_outside_the_panorama_does_not_vote() { + let color = camera_without_white_balance(); + let mut frames = shoot(&color, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]); + // four photos of other scenes, balanced very differently: counted, they would be the majority + let others = lightcraft_scenes::demo_library().into_iter().filter(|s| s.kind != lightcraft_scenes::Kind::Canyon).take(4); + frames.extend(others.map(|scene| raw_frame(scene.render(400, 300), &color, Some([4.0, 1.0, 0.8])))); + assert_eq!(frames.len(), 7); + let res = pano(frames); + assert_eq!(res.used, [0, 1, 2], "the others don't connect"); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn without_any_multipliers_the_colour_is_the_first_frames_as_before() { + for source in [ + camera_without_white_balance(), + ColorData::default(), + ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }, + ] { + let frames = shoot(&source, &[None, None, None]); + let first = frames[0].color.clone(); + let res = pano(frames); + assert_eq!(res.color, first); + assert_eq!(neutral_of(&res), source.as_shot_neutral); + } + // a DNG's own AsShotWhiteXY survives when no frame brings multipliers + let xy = ColorData { as_shot_white_xy: Some(D50), ..camera_without_white_balance() }; + assert_eq!(pano(shoot(&xy, &[None, None, None])).color, FrameColor::Camera(Box::new(xy))); +} + +#[test] +fn the_consensus_replaces_a_neutral_or_white_point_the_first_frame_had() { + // a neutral the app worked out for the first frame, and a white point of the source: both give way + let had = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.8]), as_shot_white_xy: Some(Xy::new(0.31, 0.33)), ..camera_without_white_balance() }; + let res = pano(shoot(&had, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); + // everything else of the first frame's colour is as it was + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..had }); +} + +#[test] +fn the_dng_says_the_median_white_balance_as_as_shot_neutral() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let frames = shoot(&source, &wb); + let median = consensus_multipliers(&frames).unwrap(); + let res = pano(frames); + let raw = dng_of(&res); + // AsShotNeutral is the reciprocal of the median multipliers, to the precision of a TIFF rational + let got = raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"); + assert_close(got, reciprocal(median.map(f64::from)), 2e-6); + assert_close(got, [0.5, 1.0, 0.625], 2e-6); + assert_eq!(raw.color.as_shot_white_xy, None, "the DNG spec has the two tags exclude each other"); + // what a viewer starts from: the white and the camera transform the source's multipliers give + let want = lightcraft_raw::color::as_shot_white_of(&source, Some(median)); + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + assert!((xy.x - want.x).abs() < 1e-4 && (xy.y - want.y).abs() < 1e-4, "{xy:?} vs {want:?}"); + let (got, want) = (lightcraft_raw::color::camera_transform(&raw, xy), lightcraft_raw::color::camera_transform_of(&source, want)); + assert!(got.wb.iter().zip(want.wb).all(|(a, b)| (a - b).abs() < 1e-3), "{:?} vs {:?}", got.wb, want.wb); + // the colour model and the picture are the source's, and the same without the colour matrices + assert_eq!(raw.color.forward_matrix[0].is_some(), source.forward_matrix[0].is_some()); + assert_eq!((raw.width, raw.height, raw.cpp), (res.image.width, res.image.height, 3)); + let bare = dng_of(&pano(shoot(&ColorData::default(), &wb))); + assert_close(bare.color.as_shot_neutral.expect("AsShotNeutral without colour matrices"), [0.5, 1.0, 0.625], 2e-6); +} + +#[test] +fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { + let res = pano(shoot(&ColorData::default(), &[None, None, None])); + let raw = dng_of(&res); + assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..f092e88f0 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -194,6 +194,17 @@ as the fitted table does, and the chroma curve has no DNG tag, so it is left out HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). +A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance +from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two +for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as +`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the +DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its +frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept +as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG +renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames +without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..7fea9cf66 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -348,7 +348,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 0cd7513a8d76a35436efb2d528889afb969965c9 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:14:16 +0300 Subject: [PATCH 084/315] LR-MERGE-HDR: an HDR merge of raws keeps the as-shot white balance of its inputs (#1144) The HDR merge has the same cause as the panorama: its result took the reference frame's colour tags, and a raw whose white balance is only in the vendor's multipliers (CR2, NEF, ARW...) has no AsShotNeutral in them. merge_hdr now takes the median multipliers of all its inputs (the rule of consensus_multipliers: the mean of the middle two for an even count; missing, non-finite and non-positive values do not vote) and sets the result's AsShotNeutral to their reciprocal, as stitch does. With no multipliers (DNG sources) the reference frame's tags are kept, as before. HdrResult carries the multipliers so that the brackets of an HDR panorama vote as frames of their own when the merged brackets are stitched (the engine passes them on). In the app and the CLI the neutral from starting_colour_tags (#965) is already there for frames balanced alike, so those renders do not change. Tests: an HDR merge of raw-style frames gets the median AsShotNeutral (odd and even counts, missing and unusable multipliers ignored) and the written DNG says it; without multipliers the reference frame's colour is unchanged. --- crates/engine/src/merge.rs | 2 +- crates/merge/src/hdr.rs | 14 ++++++- crates/merge/tests/white_balance.rs | 57 ++++++++++++++++++++++++++++- docs/camera-preview-colour.md | 19 +++++----- docs/parity-checklist.md | 5 ++- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index c1b17be6d..c190cd489 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -183,7 +183,7 @@ impl MergeJob { orientation: r.orientation, metadata: r.metadata, baseline_exposure: r.baseline_exposure, - wb_multipliers: None, + wb_multipliers: r.wb_multipliers, }; f.orient(); merged.push(f); diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 7c596c4a7..23f960187 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -26,7 +26,7 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::align::{self, Alignment}; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::{MergeError, Progress, Result, check}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -83,6 +83,9 @@ pub struct HdrResult { pub orientation: lightcraft_geom::Orientation, pub metadata: lightcraft_meta::Metadata, pub baseline_exposure: f64, + /// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in + /// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own. + pub wb_multipliers: Option<[f32; 3]>, } #[inline] @@ -265,6 +268,12 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) None => Plane::new(0, 0), }; let rf = &frames[reference]; + // the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none + let wb_multipliers = consensus_multipliers(&frames); + let mut color = rf.color.clone(); + if let Some(multipliers) = wb_multipliers { + color.set_as_shot_multipliers(multipliers); + } Ok(HdrResult { radiance: out, ghost, @@ -272,11 +281,12 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) ev, ev_exif, alignments, - color: rf.color.clone(), + color, raw: rf.raw, orientation: rf.orientation, metadata: rf.metadata.clone(), baseline_exposure: rf.baseline_exposure, + wb_multipliers, }) } diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs index 4139250f6..84474a494 100644 --- a/crates/merge/tests/white_balance.rs +++ b/crates/merge/tests/white_balance.rs @@ -1,4 +1,4 @@ -//! A panorama of raw files keeps their as-shot white balance (issue #1144). +//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144). //! //! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has //! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. @@ -6,6 +6,7 @@ use lightcraft_color::{D50, Xy}; use lightcraft_geom::Orientation; use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr}; use lightcraft_merge::output::color_data; use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; @@ -189,3 +190,57 @@ fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { let raw = dng_of(&res); assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); } + +/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers. +fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec<Frame> { + let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0); + evs.iter() + .zip(wb) + .map(|(ev, m)| { + let k = 2f32.powf(*ev as f32); + let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m); + frame.exposure = Some(*ev); + frame + }) + .collect() +} + +fn hdr(frames: Vec<Frame>) -> HdrResult { + merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap() +} + +fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +#[test] +fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])]; + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb)); + assert_eq!(res.wb_multipliers, Some(DAYLIGHT)); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // the DNG says it, to the precision of a TIFF rational + let bytes = write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6); + // an even count takes the mean of the middle two; frames without multipliers do not vote + let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])])); + assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() { + let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }; + let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]); + let reference = frames[1].color.clone(); + let res = hdr(frames); + assert_eq!((res.reference, res.wb_multipliers), (1, None)); + assert_eq!(res.color, reference); +} diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index f092e88f0..1661bf793 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -195,15 +195,16 @@ HDR merge of three copies of the photo against the photo alone (200 px, CIE76): percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a -frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama takes its white balance -from the frames that are in it: the median R/G and the median B/G of their multipliers (the mean of the middle two -for an even count; frames without usable multipliers, or outside the panorama, do not vote), written as -`AsShotNeutral`, the reciprocal, and any `AsShotWhiteXY` of the source dropped (a reader takes it first, and the -DNG spec has the two exclude each other). So the panorama is balanced for what the camera measured for most of its -frames, not for whichever frame came first. With no multipliers anywhere (DNG sources) the first frame's tags are kept -as before. For frames balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG -renders as before; it matters when the camera balanced the frames differently, and for a caller that loads frames -without a colour hook (`load_frame`), whose raw frames carried no white balance at all (issue #1144). +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama, and an HDR merge from its +inputs, takes its white balance from the frames that are in it: the median R/G and the median B/G of their multipliers +(the mean of the middle two for an even count; frames without usable multipliers, or outside the panorama, do not +vote), written as `AsShotNeutral`, the reciprocal. Any `AsShotWhiteXY` of the source is dropped with it: a reader takes +it first, and the DNG spec has the two exclude each other. So the result is balanced for what the camera measured for +most of its frames, not for whichever frame came first; an HDR panorama's merged brackets vote with their bracket's +median. With no multipliers anywhere (DNG sources) the reference or first frame's tags are kept as before. For frames +balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG renders as before; it matters +when the camera balanced the frames differently, and for a caller that loads frames without a colour hook +(`load_frame`), whose raw frames carried no white balance at all (issue #1144). ## Nikon crop and preview colour metadata diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7fea9cf66..49c10e79a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO: a panorama of raws takes its as-shot white balance from the median of its frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-MERGE-PANO, LR-MERGE-HDR: a panorama and an HDR merge of raws take their as-shot white balance from the median of their frames (#1144); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -347,7 +347,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | +| 2026-10-13 | minor | LR-MERGE-HDR: an HDR merge's DNG takes its `AsShotNeutral` from the median of its inputs' vendor white-balance multipliers instead of the reference frame's tags (issue #1144); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 951d52788ac6c1ee6e86ca77ffaf0ec304cfb2a0 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 13 Oct 2026 05:22:19 +0000 Subject: [PATCH 085/315] LR-MERGE-HDR: pixels next to clipped ones don't tint HDR merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the darkest frame on real ARW brackets, each frame is linear to 1 % up to the clip far from clipped pixels, but within 2 px of them the +2 EV frame's green reads 3–5 % low: demosaicing interpolates it from clipped photosites. That frame carries the most weight (k = 4) and those pixels were unclipped themselves, so they kept it, and bright windows took a magenta tinge. A frame's weight now goes by its brightest channel within 1 px (3 × 3; wider margins changed nothing more). On two real interior brackets (ILCE-7M3, 0 / −2 / +2 EV), bright pixels whose green against red and blue is over 10 % below the darkest frame's, in camera RGB: dining room 0.32 % → 0.08 %, kitchen 2.86 % → 0.28 %. Rendered at −3 EV with main's colour tags (#1123), window pixels with G < 0.85·min(R, B) go 50 → 3 and 16 → 10 (the darkest frame alone: 2 and 205); most of the visible magenta those brackets showed before came from the missing colour tags. Test: a bracket whose +2 EV frame has 5 % less green next to its clipped pixels merges without the deficit (fails before: −1.96 %). Docs: docs/merge.md, the LR-MERGE-HDR row, docs/whats-new.md (also for the frame-order fix). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/merge/src/hdr.rs | 39 ++++++++++++++++++++++++++++++++++----- crates/merge/tests/hdr.rs | 38 ++++++++++++++++++++++++++++++++++++++ docs/merge.md | 5 ++++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 +++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index aff017892..fbb24cad7 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -10,10 +10,11 @@ //! measured robustly — the median of log ratios over pixels well exposed in both — and chained //! to the reference; EXIF is the fallback when too few pixels overlap. Radiance //! `Rᵢ = vᵢ / kᵢ` is expressed in reference units. -//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer -//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none -//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a -//! tiny weight so fully saturated pixels still get a value. +//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel within 1 px))`: +//! longer exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels +//! none (all channels together, so a clipped channel never shifts colour), nor their neighbours, +//! which demosaicing interpolated from clipped photosites. The darkest frame keeps a tiny weight +//! so fully saturated pixels still get a value. //! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test //! against the reference: the reference radiance (or, where the reference is clipped/too dark, //! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that @@ -221,6 +222,8 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) let darkest = order[0]; let clips: Vec<f32> = frames.iter().map(|f| f.clip).collect(); + // how close each pixel of each frame is to saturation: its brightest channel within 1 px (see `saturation`) + let sat: Vec<Vec<f32>> = frames.par_iter().map(|f| saturation(&f.image)).collect(); let mut out = Rgb32f::new(w, h); let fr = &frames; let vd = &valid; @@ -235,7 +238,7 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) continue; } let v = fr[i].image.data[i_px]; - let m = maxc(v); + let m = sat[i][i_px]; let mut wt = k[i] * (1.0 - smoothstep(0.80 * clips[i], 0.96 * clips[i], m)); if i == darkest { wt += 1e-6; @@ -478,3 +481,29 @@ fn dilate(p: &Plane, r: usize) -> Plane { } out } + +/// A frame's saturation per pixel: the brightest channel within 1 px (3 × 3). A demosaiced pixel next to clipped +/// photosites is interpolated from them and reads low in the channel that clipped (green, in camera RGB), so it loses +/// its weight with them. On real ARW brackets the brightest frame's green was 3–5 % low within 2 px of clipped pixels +/// (and linear to 1 % elsewhere), and as that frame carries the most weight, bright windows got magenta fringes. +fn saturation(img: &Rgb32f) -> Vec<f32> { + let (w, h) = (img.width, img.height); + let m: Vec<f32> = img.data.iter().map(|p| maxc(*p)).collect(); + if w == 0 || h == 0 { + return m; + } + let mut rows = vec![0f32; w * h]; + rows.par_chunks_mut(w).zip(m.par_chunks(w)).for_each(|(out, src)| { + for (x, o) in out.iter_mut().enumerate() { + *o = src[x.saturating_sub(1)..=(x + 1).min(w - 1)].iter().copied().fold(0.0, f32::max); + } + }); + let mut out = vec![0f32; w * h]; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let (a, b) = (y.saturating_sub(1), (y + 1).min(h - 1)); + for (x, v) in row.iter_mut().enumerate() { + *v = (a..=b).map(|yy| rows[yy * w + x]).fold(0.0, f32::max); + } + }); + out +} diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index abfc471ec..27b395824 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -150,6 +150,44 @@ fn merge_does_not_depend_on_the_frame_order() { } } +/// Pixels demosaiced next to clipped photosites read low in the clipped channel (real ARW brackets: the brightest +/// frame's green 3–5 % low within 2 px of clipped pixels, linear to 1 % elsewhere); the merge must not take them from +/// that frame, or bright windows get magenta fringes. Here the +2 EV frame's green is 5 % low around its clipped areas. +#[test] +fn pixels_next_to_clipped_ones_do_not_tint_the_merge() { + let t = truth(); + let ks = [0.25f32, 1.0, 4.0]; + let mut frames: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], 0.0, 0.0, i as u64 + 1)).collect(); + let (w, h) = (frames[2].image.width, frames[2].image.height); + let clipped = |img: &Rgb32f, x: usize, y: usize| img.data[y * w + x].iter().any(|&v| v >= 1.0); + let mut ring = vec![false; w * h]; + for y in 1..h - 1 { + for x in 1..w - 1 { + let img = &frames[2].image; + ring[y * w + x] = !clipped(img, x, y) && (y - 1..=y + 1).any(|yy| (x - 1..=x + 1).any(|xx| clipped(img, xx, yy))); + } + } + for (p, &r) in frames[2].image.data.iter_mut().zip(&ring) { + if r { + p[1] *= 0.95; + } + } + let r = merge_hdr(frames, &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + let ratio = |p: [f32; 3]| p[1] / (p[0] + p[2]); + let mut devs = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let want = t.get(x + M, y + M); + if ring[y * w + x] && want[0] > 0.01 && want[2] > 0.01 { + devs.push(ratio(r.radiance.get(x, y)) / ratio(want) - 1.0); + } + } + } + assert!(devs.len() > 500, "ring pixels: {}", devs.len()); + let med = pct(devs, 0.5); + assert!(med.abs() < 0.01, "green against red and blue on the ring: {:+.2} %", 100.0 * med); +} + #[test] fn deghost_removes_a_moving_object() { let t = truth(); diff --git a/docs/merge.md b/docs/merge.md index e78c356fb..abd58b695 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -25,7 +25,10 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max -channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise +channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped +channel, so it goes with them (on real ARW brackets the brightest frame's green was 3–5 % low there, and bright +windows took a magenta tinge). The frames are taken in brightness order (ties by content), so the result doesn't +depend on the order they come in. Deghost: a per-pixel consistency test against the reference radiance (with a noise floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. **Panorama** (`pano/`): MOPS-style features, pairwise RANSAC homographies with Brown & Lowe's diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0ab638f35..b57958ed4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -346,7 +346,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | +| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; JPEG brackets treated as linear; the same result whatever order the frames come in (`merge_does_not_depend_on_the_frame_order`); pixels next to clipped ones lose their weight with them (`pixels_next_to_clipped_ones_do_not_tint_the_merge`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 81d9ad7fc..6585271ad 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-13 +### Cleaner HDR merges +- Bright windows in an HDR made with Photo ▸ Photo Merge ▸ HDR no longer take a magenta tinge from the brighter + photos along the areas they clipped: right next to a clipped area, a photo's colour isn't used. It showed most when + the highlights were darkened or saturated. +- The same photos give the same HDR whatever order they are selected in; before, a different order could change a + few pixels. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From bd0ca28bb0186ec8e74b53862763949fabbf71cb Mon Sep 17 00:00:00 2001 From: draco892 <draconian892@gmail.com> Date: Tue, 13 Oct 2026 12:00:04 +0200 Subject: [PATCH 086/315] build: fix host architecture detection in Windows PowerShell 5.1 Use Windows environment variables instead of RuntimeInformation to avoid a null value during the final CLI smoke test. Account for emulated shells using PROCESSOR_ARCHITEW6432. --- packaging/windows/package.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 27e0f0a03..24101e707 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -167,7 +167,15 @@ Compress-Archive -Path $Portable -DestinationPath $Zip # Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run # here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead. -$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +# Windows PowerShell 5.1 can expose OSArchitecture as null. Use Windows' environment +# variables instead; PROCESSOR_ARCHITEW6432 identifies the OS when the shell is emulated. +$nativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +$HostArch = switch ($nativeArchitecture) { + 'AMD64' { 'x64' } + 'x86' { 'x86' } + 'ARM64' { 'arm64' } + default { 'unknown' } +} if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { Invoke-Native 'lightcraft-cli --version' { & (Join-Path $Stage 'lightcraft-cli.exe') --version } } else { From 2dad18d2ecdb590e99f64f7cb2e0919fe7568c07 Mon Sep 17 00:00:00 2001 From: radialmonster <radial@gmail.com> Date: Tue, 13 Oct 2026 05:15:31 -0400 Subject: [PATCH 087/315] LR-RAW-NEF: stretch non-square pixels (D1X/D1H) to square The D1X/D1H raw SubIFD is 4028x1324 with XResolution 600 / YResolution 300: the pixel is twice as tall as it is wide. Nothing read that, so the picture came out about 3:1. Add a pixel aspect to RawImage/RawInfo, read it from the raw IFD resolution ratio (NEF) and from DefaultScale (DNG), accept only 1.5..4, and resample the rows after demosaic. developed_size() reports the stretched size, binning is off for non-square pixels, and the catalog and lens-profile sizing use developed_size(). --- crates/engine/src/camera_preview.rs | 3 ++ crates/engine/src/files.rs | 6 ++-- crates/engine/src/lens_profiles.rs | 3 +- crates/engine/src/tests_xmp.rs | 1 + crates/merge/src/output.rs | 1 + crates/raw/src/binned.rs | 3 +- crates/raw/src/color.rs | 1 + crates/raw/src/dng.rs | 19 ++++++++++++ crates/raw/src/dngwrite.rs | 1 + crates/raw/src/lib.rs | 46 ++++++++++++++++++++++++++--- crates/raw/src/saturation.rs | 1 + crates/raw/src/vendor/arw.rs | 1 + crates/raw/src/vendor/cr2.rs | 1 + crates/raw/src/vendor/cr2/sraw.rs | 1 + crates/raw/src/vendor/cr3.rs | 1 + crates/raw/src/vendor/kodak.rs | 1 + crates/raw/src/vendor/nef.rs | 14 ++++++++- crates/raw/src/vendor/orf.rs | 1 + crates/raw/src/vendor/pef.rs | 1 + crates/raw/src/vendor/raf.rs | 1 + crates/raw/src/vendor/rw2.rs | 1 + crates/raw/src/vendor/srw.rs | 1 + crates/raw/tests/dng.rs | 1 + crates/raw/tests/robust.rs | 1 + 24 files changed, 99 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..8fbcde9cb 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1656,6 +1656,7 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), }; @@ -1999,6 +2000,7 @@ mod tests { color, wb_multipliers: Some([2.0, 1.0, 1.5]), linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, } @@ -3125,6 +3127,7 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, }; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..4ae58ceff 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -158,10 +158,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> { } Err(Err(e)) => return Err(e), }; - let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); - if w <= 1 || h <= 1 { - (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); - } + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 1894f7189..6338b3b40 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) - pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> { crate::files::embedded_lens(raw).or_else(|| { let db = database()?; - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + let (w, h) = raw.developed_size(); lookup(db, &meta(), w as f64, h as f64, raw.orientation) }) } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..386fd7c1d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -584,6 +584,7 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xm }, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 8128644b6..ed24e636e 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -107,6 +107,7 @@ pub fn write_linear_dng( color: cd, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: meta, }; diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..0ad9adb3d 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -24,7 +24,7 @@ impl RawImage { /// and B; Bayer: `k` even, so that every block has the same layout)? pub fn can_bin(&self, k: usize) -> bool { let Some(cfa) = &self.cfa else { return false }; - if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { + if k < 2 || self.cpp != 1 || (self.pixel_aspect - 1.0).abs() >= 0.01 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { return false; } // every window position relative to the pattern @@ -163,6 +163,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..88081eebc 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -287,6 +287,7 @@ mod tests { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: Default::default(), } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 3c7ece61b..69185e193 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -331,6 +331,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R color: color_data(ifd0, raw, tiff.order), wb_multipliers: None, linearized, + pixel_aspect: default_scale_aspect(raw.f64s(t::DEFAULT_SCALE).as_deref()), opcodes, metadata, }; @@ -338,8 +339,25 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R Ok(img) } +/// Pixel height over width from `DefaultScale` (horizontal, vertical): only a clear 1.5 to 4 ratio counts (the rows stretch; a wider-than-tall pixel is left alone). +fn default_scale_aspect(scale: Option<&[f64]>) -> f32 { + match scale { + Some([h, v]) if *h > 0.0 && *v > 0.0 && (1.5..=4.0).contains(&(v / h)) => (v / h) as f32, + _ => 1.0, + } +} + #[cfg(test)] mod tests { + #[test] + fn default_scale_makes_tall_pixels() { + assert_eq!(default_scale_aspect(Some(&[1.0, 2.0])), 2.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[2.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.2])), 1.0); + assert_eq!(default_scale_aspect(None), 1.0); + } + use super::*; /// Store `image` the way DNG 1.7 interleaves it: row `r` of the image is written to stored row @@ -441,3 +459,4 @@ mod tests { assert!(deinterleave(&mut hostile, 8, 6, 1, 1 << 20, 1).is_err()); } } + diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 55a96984b..b935849d1 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -419,6 +419,7 @@ pub fn write_rendered_dng( color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), }; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 120ce98e9..98b29dbd8 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -415,13 +415,27 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// See [`RawImage::pixel_aspect`]. + #[serde(default = "square_pixels")] + pub pixel_aspect: f32, +} + +fn square_pixels() -> f32 { + 1.0 +} + +/// Rows of a picture `height` rows tall once its pixels (taller than wide by `aspect`) are made square. +pub fn stretched_height(height: usize, aspect: f32) -> usize { + if (aspect - 1.0).abs() < 0.01 { height } else { ((height as f32 * aspect).round() as usize).max(1) } } impl RawInfo { - /// Size of the developed image before orientation: the default crop, else the active area. + /// Size of the developed image before orientation: the default crop, else the active area, with the rows + /// stretched for non-square pixels. pub fn developed_size(&self) -> (usize, usize) { let c = self.crop.clipped(self.active_area.width, self.active_area.height); - if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + let (w, h) = if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) }; + (w, stretched_height(h, self.pixel_aspect)) } } @@ -646,6 +660,10 @@ pub struct RawImage { pub wb_multipliers: Option<[f32; 3]>, /// Whether a `LinearizationTable` was applied to `data`. pub linearized: bool, + /// Height of a sensor pixel over its width (1.0 for square pixels). The D1X and D1H write 2.0: their frame is + /// 4028 x 1324 and the picture is 3:2 only once the rows are doubled. [`Self::develop`] stretches the rows by + /// this factor. + pub pixel_aspect: f32, pub opcodes: OpcodeLists, pub metadata: Metadata, } @@ -679,6 +697,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + pixel_aspect: self.pixel_aspect, } } @@ -698,6 +717,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + pixel_aspect: self.pixel_aspect, } } @@ -791,10 +811,16 @@ impl RawImage { pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f { opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.develop_crop(rgb.width, rgb.height); - if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + let rgb = if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + rgb + } else { + rgb.into_crop(c.x, c.y, c.width, c.height) + }; + let h = stretched_height(rgb.height, self.pixel_aspect); + if h == rgb.height { return rgb; } - rgb.into_crop(c.x, c.y, c.width, c.height) + lightcraft_raster::resample::resize(&rgb, rgb.width, h, lightcraft_raster::resample::Filter::Mitchell) } /// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default @@ -853,6 +879,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), }; @@ -869,6 +896,17 @@ mod tests { // a crop that pokes out is clipped to the picture whole.crop = Rect::new(6, 4, 10, 10); assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); } #[test] diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..76c1cd503 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -135,6 +135,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..c2edc9f80 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -767,6 +767,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, + pixel_aspect: 1.0, // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..6c797ec4a 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -249,6 +249,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..f8ef18e32 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -391,6 +391,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result // the camera's white balance is already applied, as in Sony's linear YCbCr wb_multipliers: Some([1.0; 3]), linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..b4c3d2d54 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -253,6 +253,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: levels.wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..def2e369d 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -115,6 +115,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 695d55712..0b7a68e6a 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -98,6 +98,16 @@ fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) } +/// Pixel height over width from the raw IFD's resolution tags: the D1X and D1H write 600 x 300 per inch for a +/// 4028 x 1324 frame, rows twice as tall as the columns are wide. Only ratios from 1.5 to 4 count (a frame +/// squashed 3:1 or 2:1), the rest is rounding or a stray tag. +pub(crate) fn pixel_aspect_of(raw: &Ifd) -> f32 { + match (raw.f64(t::X_RESOLUTION), raw.f64(t::Y_RESOLUTION)) { + (Some(x), Some(y)) if x > 0.0 && y > 0.0 && (1.5..=4.0).contains(&(x / y)) => (x / y).round() as f32, + _ => 1.0, + } +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -190,9 +200,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), _ => default_crop(mn.as_ref(), active_w, h), }; + let pixel_aspect = pixel_aspect_of(raw); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); - metadata.height = Some(crop.height as u32); + metadata.height = Some(crate::stretched_height(crop.height, pixel_aspect) as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -212,6 +223,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..5a4776c39 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -329,6 +329,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index abb928e68..8ce426a24 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -318,6 +318,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..b45cf4902 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -212,6 +212,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, }; diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..c96a1310f 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -616,6 +616,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, }; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..a48f944b4 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -184,6 +184,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9876e349e..d9c161779 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -59,6 +59,7 @@ fn synthetic(w: usize, h: usize, cfa: Option<Cfa>, cpp: usize) -> RawImage { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, } diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 7bd5a5e54..733b029b9 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -24,6 +24,7 @@ fn sample(comp: DngCompression) -> Vec<u8> { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Default::default(), }; From 6467d5432552ce86106773d5438e9adc72f0467f Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Mon, 12 Oct 2026 20:43:12 +0200 Subject: [PATCH 088/315] Perf: camera-look colour step 1.5-1.9x faster on full-size raws, same pixels The hue/saturation table of a fitted camera look (ILCE-7RM4A, APS-C ILCE-7RM6, ...) ran HsvTable::apply pixel by pixel: branches on the data (largest channel, hue sector), saturating float-to-usize casts and bounds-checked table reads, ~550 instructions and 1.2 branch misses per pixel. CameraColourModel::apply now runs it 16 pixels at a time (HueSat::apply_lanes -> HsvTable::apply_lanes, structure of arrays): per pixel the same IEEE operations in the same order, with the branches turned into selects and the table indices computed as exact f32 integers, so the compiler vectorises everything but the table reads, powf and the rare rem_euclid, which run lane by lane. DNG profile tables and the other colour models keep the per-pixel path. Measured on a Core Ultra 9 185H P-core (busy machine, min of N): - HsvTable alone, 72x5x5 fitted-look-shaped table, random colours, one thread: 243 -> 106 cycles (57 -> 26 ns) per pixel. - Colour stage of a full-size load, one thread: 60 MP ILCE-7RM4A 3.80 -> 1.99 s, 26 MP APS-C ILCE-7RM6 HQ 1.88 -> 0.98 s; 4 CPUs: 1.02 -> 0.67 s, 0.56 -> 0.30 s. Exactness: new tests compare the lanes with the one-by-one path bit for bit (to_bits) on tens of thousands of colours (every combination of zero, -0, tiny, negative, around 1, huge, NaN and +-inf components; grey; hues at the 0/360 degree wrap; near-black noise in the restore's blend) for fitted, value-scaled, 2D, many-turn and degenerate tables, 16 and 5 lanes, and on an image whose rows aren't a whole number of lanes. Stage fingerprints (decode, AHD, 2560 px and full-size loads) of 7 Sony raws are identical to origin/main. Sources: own design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/camera_preview.rs | 81 ++++++++ crates/engine/src/files.rs | 75 ++++++-- crates/raw/src/profile.rs | 277 ++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..a3d13bfdc 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1180,6 +1180,42 @@ impl<'a> HueSat<'a> { } std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) } + + /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through + /// [`HsvTable::apply_lanes`], with the guards as selects. + #[inline] + pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { + let mut out = [[0.0f32; N]; 3]; + for (row, out) in m.iter().zip(&mut out) { + for l in 0..N { + out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; + } + } + out + }; + let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); + let mut restored = rgb; + for l in 0..N { + let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); + let (before, after) = (luminance_2020(input), luminance_2020(out)); + let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); + let w = restore_weight(after / before); + for (c, restored) in restored.iter_mut().enumerate() { + let full = out[c] * before / after; + let blend = input[c] + (full - input[c]) * w; + // (`w >= 1` and `w <= 0` exclude each other) + restored[l] = if untouched | (w <= 0.0) { + input[c] + } else if w >= 1.0 { + full + } else { + blend + }; + } + } + restored + } } /// HSV hue (degrees) and saturation; `None` for black or non-finite colours. @@ -1951,6 +1987,51 @@ mod tests { assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); } + /// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, + /// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. + #[test] + fn hue_sat_lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; + let fitted = (0..72 * 5 * 5) + .map(|_| { + let r = next(); + [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] + }) + .collect(); + let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; + let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; + let mut colours = Vec::new(); + for a in special { + for b in special { + colours.extend(special.map(|c| [a, b, c])); + } + } + for _ in 0..60_000 { + let r = next(); + let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; + colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); + } + for table in [fitted, saturating] { + let hue_sat = HueSat::new(&table).unwrap(); + for chunk in colours.chunks(16) { + let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + } + #[test] fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { assert_eq!(restore_weight(0.0), 0.0); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index feefb21a0..0940f3125 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -266,19 +266,43 @@ impl CameraColourModel<'_> { /// White balance, matrix, tables and baseline exposure, in place. pub fn apply(&self, img: &mut Rgb32f) { let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain); - img.map_in_place(|p| { - let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let rgb = [ - m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], - m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], - m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], - ]; - let rgb = match self.tables { - Some(tables) => tables.apply(rgb, gain), - None => rgb.map(|v| v * gain), - }; - self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) - }); + if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) { + // a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits) + const LANES: usize = 16; + lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| { + for chunk in row.chunks_mut(LANES) { + let mut rgb = [[0.0f32; LANES]; 3]; + for (l, p) in chunk.iter().enumerate() { + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + for (i, rgb) in rgb.iter_mut().enumerate() { + rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain; + } + } + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter_mut().enumerate() { + *p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0)); + } + } + }); + return; + } + img.map_in_place(|p| self.pixel(&m, p)); + } + + /// [`Self::apply`] to one pixel; `m` is the matrix as `f32`. + fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] { + let (wb, gain) = (self.wb, self.gain); + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + let rgb = match self.tables { + Some(tables) => tables.apply(rgb, gain), + None => rgb.map(|v| v * gain), + }; + self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) } } @@ -1647,6 +1671,31 @@ mod tests { lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) } + /// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the + /// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. + #[test] + fn camera_look_in_lanes_matches_pixel_by_pixel() { + let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); + let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; + let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); + let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; + let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; + let img = Rgb32f::from_fn(37, 5, |x, y| { + let k = y * 37 + x; + if k % 5 == 0 { + [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] + } else { + [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] + } + }); + let mut lanes = img.clone(); + colour.apply(&mut lanes); + let m = colour.matrix.to_f32(); + for (p, got) in img.data.iter().zip(&lanes.data) { + assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); + } + } + #[test] fn clipped_white_renders_neutral_through_the_colour_model() { use lightcraft_color::{LUMA_2020, Mat3}; diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 566c2b1c3..7ca6b0e38 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -123,6 +123,177 @@ impl HsvTable { }; hsv_to_rgb(hue + shift, s2, v2) } + + /// [`Self::apply`] to `N` colours at once (`rgb[channel][lane]`), bit for bit: the same operations, with the + /// branches on the data (largest channel, hue sector, wrap-around) turned into selects so the compiler + /// vectorises them. The table reads, `powf` and the rare `rem_euclid` run lane by lane. + pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let [r, g, b] = rgb; + if !self.fits_lanes() { + let mut out = rgb; + for l in 0..N { + [out[0][l], out[1][l], out[2][l]] = self.apply([r[l], g[l], b[l]]); + } + return out; + } + // `active`: the colours the table applies to (`apply` returns black, NaN and infinite ones as they are) + let (mut max, mut active, mut first) = ([0.0f32; N], [true; N], [false; N]); + let (mut d, mut ratio, mut second, mut third) = ([0.0f32; N], [0.0f32; N], [0.0f32; N], [0.0f32; N]); + for l in 0..N { + let m = r[l].max(g[l]).max(b[l]); + active[l] = !(m.is_nan() | (m <= 0.0) | !m.is_finite()); + d[l] = m - r[l].min(g[l]).min(b[l]); + // hue_of, for each largest channel + ratio[l] = (g[l] - b[l]) / d[l]; + second[l] = (b[l] - r[l]) / d[l] + 2.0; + third[l] = (r[l] - g[l]) / d[l] + 4.0; + first[l] = active[l] & (d[l] > 0.0) & (m == r[l]); + max[l] = m; + } + let ratio = wrap_lanes(ratio, 6.0, first); + let (mut hue, mut sat) = ([0.0f32; N], [0.0f32; N]); + for l in 0..N { + let h = if max[l] == r[l] { + ratio[l] + } else if max[l] == g[l] { + second[l] + } else { + third[l] + }; + let h = h * 60.0; + hue[l] = if d[l] <= 0.0 { 0.0 } else { h }; + sat[l] = d[l] / max[l]; + } + let mut v_index = max; + if self.srgb_value { + for l in 0..N { + if active[l] { + v_index[l] = linear_to_srgb(max[l].min(1.0)); + } + } + } + let [shift, ss, vs] = self.lookup_lanes(&hue, &sat, &v_index, active); + let (mut s2, mut v2, mut curve) = ([0.0f32; N], [0.0f32; N], [false; N]); + for l in 0..N { + s2[l] = limited_select(sat[l], ss[l]); + v2[l] = if self.srgb_value { max[l] } else { limited_select(max[l], vs[l]) }; + curve[l] = self.srgb_value & active[l] & !((max[l] <= 1.0) & (vs[l] == 1.0)); + } + for l in 0..N { + if curve[l] { + v2[l] = if max[l] <= 1.0 { srgb_to_linear((v_index[l] * vs[l]).min(1.0)) } else { max[l] * srgb_to_linear(vs[l].min(1.0)).min(1.0) }; + } + } + // hsv_to_rgb + let hue = wrap_lanes(std::array::from_fn(|l| hue[l] + shift[l]), 360.0, active); + let mut out = rgb; + for l in 0..N { + let hp = hue[l] / 60.0; + // `(hp as u32).min(5) as f32` for every `hp`, NaN included + let step = |k: f32| if hp >= k { 1.0 } else { 0.0 }; + let sector = step(1.0) + step(2.0) + step(3.0) + step(4.0) + step(5.0); + let f = hp - sector; + let (s, v) = (s2[l], v2[l]); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + let red = if (sector == 0.0) | (sector == 5.0) { + v + } else if sector == 1.0 { + q + } else if sector == 4.0 { + t + } else { + p + }; + let green = if sector == 0.0 { + t + } else if sector <= 2.0 { + v + } else if sector == 3.0 { + q + } else { + p + }; + let blue = if sector <= 1.0 { + p + } else if sector == 2.0 { + t + } else if sector == 5.0 { + q + } else { + v + }; + out[0][l] = if active[l] { red } else { r[l] }; + out[1][l] = if active[l] { green } else { g[l] }; + out[2][l] = if active[l] { blue } else { b[l] }; + } + out + } + + /// Whether [`Self::lookup_lanes`] can do this table's index arithmetic in `f32`: every index below 2²³. + fn fits_lanes(&self) -> bool { + let entries = self.hue_divisions.checked_mul(self.sat_divisions).and_then(|n| n.checked_mul(self.val_divisions.max(1))); + self.hue_divisions >= 1 && self.sat_divisions >= 1 && entries.is_some_and(|n| n <= 1 << 23) + } + + /// [`Self::lookup`] at `N` points (`(shift, saturation scale, value scale)[lane]`), bit for bit for the lanes + /// `wanted`, on a table that [`Self::fits_lanes`]. The indices are computed as `f32` (exact integers below 2²³: + /// `x as usize` is a saturating conversion that doesn't vectorise). + fn lookup_lanes<const N: usize>(&self, h: &[f32; N], s: &[f32; N], v: &[f32; N], wanted: [bool; N]) -> [[f32; N]; 3] { + const EXACT: f32 = 8_388_608.0; // 2²³: ulp 1 from here + // `x as usize` for `0 ≤ x < 2²³`: rounded to an integer, minus 1 if it rounded up; NaN is 0 + let floor = |x: f32| { + let nearest = (x + EXACT) - EXACT; + let floor = if nearest > x { nearest - 1.0 } else { nearest }; + if x.is_nan() { 0.0 } else { floor } + }; + let (hn, sn, vn) = (self.hue_divisions as f32, self.sat_divisions as f32, self.val_divisions as f32); + let axis = |x: f32, n: f32| { + let f = x.clamp(0.0, 1.0) * (n - 1.0); + let i0 = floor(f).min((n - 2.0).max(0.0)); + let i1 = (i0 + 1.0).min(n - 1.0); + (i0, i1, (f - i0).clamp(0.0, 1.0)) + }; + let h = wrap_lanes(*h, 360.0, wanted); + // the corners' indices (v, h, s), s fastest, and the weights between them + let (mut at, mut t) = ([[0u32; N]; 8], [[0.0f32; N]; 3]); + for l in 0..N { + let hf = (h[l] / 360.0 * hn).clamp(0.0, hn); + let h0 = floor(hf).min(hn - 1.0); + // (h0 + 1) % hn + let h1 = if h0 + 1.0 == hn { 0.0 } else { h0 + 1.0 }; + let (s0, s1, ts) = axis(s[l], sn); + let (v0, v1, tv) = axis(v[l], vn); + let (v0, v1, tv) = if self.val_divisions > 1 { (v0, v1, tv) } else { (0.0, 0.0, 0.0) }; + for (k, (vi, hi)) in [(v0, h0), (v0, h1), (v1, h0), (v1, h1)].into_iter().enumerate() { + let row = (vi * hn + hi) * sn; + // an exact integer below 2²³ plus 2²³ has it as its mantissa + at[2 * k][l] = (row + s0 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + at[2 * k + 1][l] = (row + s1 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + } + [t[0][l], t[1][l], t[2][l]] = [(hf - h0).clamp(0.0, 1.0), ts, tv]; + } + let mut corner = [[[0.0f32; N]; 3]; 8]; + for l in 0..N { + for (corner, at) in corner.iter_mut().zip(&at) { + [corner[0][l], corner[1][l], corner[2][l]] = self.data.get(at[l] as usize).copied().unwrap_or([0.0, 1.0, 1.0]); + } + } + let lerp = |a: &[f32; N], b: &[f32; N], t: &[f32; N]| { + let mut out = [0.0f32; N]; + for l in 0..N { + out[l] = a[l] + (b[l] - a[l]) * t[l]; + } + out + }; + let [th, ts, tv] = &t; + let mut out = [[0.0f32; N]; 3]; + for (c, out) in out.iter_mut().enumerate() { + let plane = |k: usize| lerp(&lerp(&corner[k][c], &corner[k + 1][c], ts), &lerp(&corner[k + 2][c], &corner[k + 3][c], ts), th); + // `lookup`'s `v0 == v1` for every lane: `axis` gives `v1 = v0 + 1` whenever there are two values or more + *out = if self.val_divisions > 1 { lerp(&plane(0), &plane(4), tv) } else { plane(0) }; + } + out + } } /// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1). @@ -145,6 +316,37 @@ fn wrap(x: f32, p: f32) -> f32 { } } +/// [`wrap`] of `N` values, bit for bit for the lanes `wanted`: branch-free but for the `rem_euclid` fallback. +#[inline] +fn wrap_lanes<const N: usize>(x: [f32; N], p: f32, wanted: [bool; N]) -> [f32; N] { + let (mut out, mut slow) = ([0.0f32; N], [false; N]); + for l in 0..N { + let (first, below, second) = ((x[l] >= 0.0) & (x[l] < p), (x[l] > -p) & (x[l] < 0.0), (x[l] >= p) & (x[l] < 2.0 * p)); + let (up, down) = (x[l] + p, x[l] - p); + out[l] = if first { + x[l] + } else if below { + up + } else { + down + }; + slow[l] = wanted[l] & !(first | below | second); + } + for l in 0..N { + if slow[l] { + out[l] = x[l].rem_euclid(p); + } + } + out +} + +/// [`limited`] without a branch. +#[inline] +fn limited_select(x: f32, k: f32) -> f32 { + let (below, above) = ((x * k).min(1.0), x * k.min(1.0)); + if x <= 1.0 { below } else { above } +} + fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { if d <= 0.0 { return 0.0; @@ -330,6 +532,81 @@ mod tests { } } + /// Every combination of awkward components (zero, negative, tiny, around 1, huge, NaN, ±infinity), and random + /// colours from black to above 1: grey, each channel largest, hues either side of the wrap-around at 0°. + fn awkward_colours() -> Vec<[f32; 3]> { + let special = [0.0, -0.0, 1e-30, -1e-30, 1e-45, 0.003_130_8, 0.5, -0.5, 1.0, 1.0f32.next_up(), 2.0, 1e30, -1e30]; + let special = [&special[..], &[f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]].concat(); + let mut out = Vec::new(); + for a in &special { + for b in &special { + out.extend(special.iter().map(|c| [*a, *b, *c])); + } + } + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..40_000 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let scale = [1.0, 1e-4, 8.0, 1e-38][(seed >> 56) as usize & 3]; + let c: [f32; 3] = std::array::from_fn(|i| (((seed >> (16 * i)) & 0xffff) as f32 / 65535.0 * 1.6 - 0.2) * scale); + out.push(match seed >> 60 { + 0 => [c[0]; 3], + 1 => [c[0].abs() + 0.1, c[1], c[1].next_up()], + 2 => [c[0].abs() + 0.1, c[1], c[1].next_down()], + _ => c, + }); + } + out + } + + /// `apply_lanes` against `apply`, bit for bit, `N` colours at a time (the last batch padded). + fn assert_lanes_match<const N: usize>(t: &HsvTable, colours: &[[f32; 3]]) { + for chunk in colours.chunks(N) { + let rgb: [[f32; N]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.25, |p| p[c]))); + let out = t.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], t.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut table = |h: usize, s: usize, v: usize, srgb_value: bool, shift: f32, value_scale: bool| { + let data = (0..h * s * v) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let x = |k: u64| ((seed >> k) & 0xffff) as f32 / 65535.0; + [(x(0) * 2.0 - 1.0) * shift, 0.5 + x(16), if value_scale { 0.5 + x(32) } else { 1.0 }] + }) + .collect(); + HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value } + }; + let colours = awkward_colours(); + // a fitted camera look's shape; with value scales; a 2D DNG map turning hues by up to 5 turns; one hue and + // one saturation + for t in [ + table(72, 5, 5, true, 30.0, false), + table(72, 5, 5, true, 30.0, true), + table(90, 30, 1, false, 1800.0, true), + table(1, 1, 3, true, 30.0, true), + ] { + assert_lanes_match::<16>(&t, &colours); + assert_lanes_match::<5>(&t, &colours); + } + // entries missing (the identity fallback); too big for the lanes' index arithmetic (one by one) + let short = HsvTable { data: vec![[10.0, 1.5, 0.8]; 7], ..identity(4, 3, 2) }; + let huge = HsvTable { data: Vec::new(), ..identity(4096, 4096, 1) }; + for t in [short, huge] { + assert_lanes_match::<16>(&t, &colours); + } + } + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) } From 8907f5f56fc8195b5ef22384f16baa0dfcfe547f Mon Sep 17 00:00:00 2001 From: Markus Friedel <20045158+MarkusFriedel@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:38:21 +0200 Subject: [PATCH 089/315] LR-VIEW-PHOTOGRID: a mouse-wheel notch scrolls a third of the grid or filmstrip (#1481) One notch scrolls the photo grid by a third of its visible height and the filmstrip by a third of its width, as in Lightroom Classic, instead of the fixed 100 points a side panel scrolls. It jumps at once instead of gliding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/detail.rs | 6 +++ crates/ui-egui/src/panels/grid.rs | 44 ++++++++++++++++++ crates/ui-egui/src/tests_scroll.rs | 69 ++++++++++++++++++++++------- crates/ui-egui/src/theme.rs | 4 ++ docs/parity-checklist.md | 4 +- 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 66f9c7997..ccec7561a 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2272,7 +2272,13 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source); + // a wheel notch scrolls a third of its width; see `grid::wheel_step` + register(ui.ctx(), "filmstrip", child.max_rect()); + let wheel = super::grid::wheel_step(&child, egui::Id::new("film-wheel"), child.max_rect(), child.max_rect().width(), true); egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { + if wheel != egui::Vec2::ZERO { + ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); + } let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); app.film_scroll = Some(vp.left()); for (i, id) in ids.iter().enumerate() { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..f6865f3bd 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -236,7 +236,14 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; let mut visible_ids = HashSet::new(); let mut visited = 0u64; + // a wheel notch scrolls a third of the grid's height + let scroll_rect = ui.available_rect_before_wrap(); + crate::widgets::register(ui.ctx(), "grid:scroll", scroll_rect); + let wheel = wheel_step(ui, egui::Id::new("grid-wheel"), scroll_rect, scroll_rect.height(), false); egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + if wheel != egui::Vec2::ZERO { + ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); + } let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover()); let origin = area.min; app.grid_scroll = Some(viewport.top()); @@ -318,6 +325,43 @@ pub(crate) fn follow_changed( was != active || where_was != source || drawn + 1 < pass } +/// How far a mouse wheel scrolls a photo strip this frame (`key`: the grid, the filmstrip; `area`: its +/// rect on screen; `span`: its visible height, or width when `sideways`), for `Ui::scroll_with_delta_animation` +/// inside its `ScrollArea`. A notch scrolls [`crate::theme::WHEEL_STRIP_SHARE`] (a third) of `span`, as in +/// Lightroom (about two rows of a six-row grid), instead of the [`crate::theme::WHEEL_LINE_POINTS`] a side +/// panel scrolls. Not whole rows: the photos land somewhere else after each notch, so the move shows. It +/// jumps at once instead of gliding, so the photos stay sharp while scrolling; egui's glide of the notch is +/// swallowed until it ends. Trackpads report points and scroll as before. A vertical wheel scrolls a +/// `sideways` strip. +pub(crate) fn wheel_step(ui: &egui::Ui, key: egui::Id, area: Rect, span: f32, sideways: bool) -> egui::Vec2 { + if !ui.rect_contains_pointer(area) || !span.is_finite() { + ui.data_mut(|d| d.remove_temp::<bool>(key)); + return egui::Vec2::ZERO; + } + let (notches, gliding) = ui.input(|i| { + let notches = i.events.iter().fold(egui::Vec2::ZERO, |sum, e| match e { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta, modifiers, .. } + if *modifiers == egui::Modifiers::NONE && delta.is_finite() => + { + sum + *delta + } + _ => sum, + }); + (notches, i.smooth_scroll_delta != egui::Vec2::ZERO) + }); + if notches != egui::Vec2::ZERO { + ui.data_mut(|d| d.insert_temp(key, true)); + } else if !gliding { + ui.data_mut(|d| d.remove_temp::<bool>(key)); + } + if !ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) { + return egui::Vec2::ZERO; + } + ui.ctx().input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let step = span * crate::theme::WHEEL_STRIP_SHARE; + if sideways { egui::vec2((notches.x + notches.y) * step, 0.0) } else { egui::vec2(0.0, notches.y * step) } +} + /// Height of a date header row (points). pub const HEADER_H: f32 = 40.0; diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index d04a818a4..dd999108a 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -209,24 +209,63 @@ fn filmstrip_shows_every_selected_photo() { assert_eq!(fill(&mut h, second), active); } -/// A mouse wheel notch (a line) scrolls `theme::WHEEL_LINE_POINTS`, not egui's default 40: long -/// panels took 50+ notches. +/// One notch of a mouse wheel (a line). +fn notch(h: &mut Headless, dy: f32) { + h.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.step(); +} + +/// A mouse wheel notch scrolls the grid a third of its height, as in Lightroom (two rows of six), not the +/// `theme::WHEEL_LINE_POINTS` a side panel scrolls: at big thumbnails that was a quarter of a row. Exactly +/// a third, not whole rows, so the photos don't land where the last ones were, and at once, without a +/// glide that blurs them. A trackpad still scrolls the points it reports. #[test] -fn a_wheel_notch_scrolls_a_hundred_points() { +fn a_wheel_notch_scrolls_a_third_of_the_grid() { let mut h = demo("photoGrid"); - let canvas = h.app.canvas_rect.expect("grid canvas"); - h.request("ui.move", json!({"x": canvas.center().x, "y": canvas.center().y}), T); + let view = widget(&h, "grid:scroll"); + h.request("ui.move", json!({"x": view.center().x, "y": view.center().y}), T); let y0 = grid_y(&h); - for _ in 0..3 { - h.events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, -1.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::NONE, - }); - h.step(); + notch(&mut h, -1.0); + // at once, no glide: the frame after the notch draws it there + h.step(); + h.step(); + let moved = grid_y(&h) - y0; + assert!((moved - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", view.height()); + idle(&mut h, 120); + assert!((grid_y(&h) - y0 - moved).abs() < 0.5, "and no further: {} after the glide", grid_y(&h) - y0); + let y1 = grid_y(&h); + for _ in 0..2 { + notch(&mut h, -1.0); } idle(&mut h, 120); - let moved = grid_y(&h) - y0; - assert!(moved >= 3.0 * crate::theme::WHEEL_LINE_POINTS - 1.0, "three notches scrolled {moved} points"); + let moved = grid_y(&h) - y1; + assert!((moved - 2.0 * view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "two notches scrolled {moved} of {} points", view.height()); + // back up + let y2 = grid_y(&h); + notch(&mut h, 1.0); + idle(&mut h, 120); + assert!((y2 - grid_y(&h) - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch up scrolled back a third"); + // a trackpad (points) scrolls what it reports + let y3 = grid_y(&h); + h.request("ui.scroll", json!({"dy": -60.0}), T); + idle(&mut h, 120); + assert!((grid_y(&h) - y3 - 60.0).abs() < 1.0, "a 60-point trackpad scroll moved {}", grid_y(&h) - y3); +} + +/// The same in the filmstrip: a notch scrolls it sideways by a third of its width (about five photos). +#[test] +fn a_wheel_notch_scrolls_a_third_of_the_filmstrip() { + let mut h = demo("detail"); + let strip = widget(&h, "filmstrip"); + h.request("ui.move", json!({"x": strip.center().x, "y": strip.center().y}), T); + let x0 = film_x(&h); + notch(&mut h, -1.0); + idle(&mut h, 120); + let moved = film_x(&h) - x0; + assert!((moved - strip.width() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", strip.width()); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 282f9c70e..52abb2609 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -370,6 +370,10 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { /// Points one mouse-wheel notch (a line) scrolls. pub const WHEEL_LINE_POINTS: f32 = 100.0; +/// Share of its visible size one mouse-wheel notch scrolls the photo grid and the filmstrip (Lightroom +/// Classic: three notches move a photo from one edge to the other, at any window size). +pub const WHEEL_STRIP_SHARE: f32 = 1.0 / 3.0; + /// Zoom per wheel point (`exp(speed × points)`): egui's 1/200 at its stock 40-point notch, rescaled so /// one notch still zooms the same amount now that it scrolls [`WHEEL_LINE_POINTS`]. pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..1c5f7f930 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -123,12 +123,12 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own | +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report) | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | From db69e288c87c6ba896ffa9774c66ff95f3ea3764 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Tue, 13 Oct 2026 16:11:45 +0530 Subject: [PATCH 090/315] KEYC-RATING: backtick cycles the flag; Cmd+Up / Cmd+Down raise / lower it photo.cycleFlag steps each selected photo's own flag unflagged -> pick -> reject -> unflagged; photo.increaseFlag / photo.decreaseFlag move it up or down reject < unflagged < pick without wrapping. One batch undo step, Compare and Survey act on the active photo, Auto Advance follows, text fields keep the key. Shown in Photo > Set Flag and Help > Keyboard Shortcuts. --- apps/lightcraft/src/native_menu.rs | 6 +- crates/engine/src/cmd/library.rs | 57 +++++++ .../src/cmd/library/tests_flag_cycle.rs | 100 +++++++++++++ crates/ui-egui/src/menubar.rs | 15 +- crates/ui-egui/src/shortcuts.rs | 16 ++ .../ui-egui/src/shortcuts/tests_flag_cycle.rs | 140 ++++++++++++++++++ docs/library-shortcuts.md | 4 + docs/parity-checklist.md | 5 +- docs/ui-parity.md | 5 +- 9 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/library/tests_flag_cycle.rs create mode 100644 crates/ui-egui/src/shortcuts/tests_flag_cycle.rs diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 256faa1f8..4ac40bcac 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -26,8 +26,9 @@ const QUIT: &str = "app.quit"; const SETTINGS: &str = "app.settings"; const SETTINGS_KEY: &str = "Cmd+,"; /// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo -/// or the active mask): left to the egui handler, which knows the context. -const CONTEXTUAL: &[&str] = &["X", "Delete"]; +/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui +/// handler, which knows the context. +const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"]; /// Shortcuts text fields need while they have focus. const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"]; @@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> { "=" => Code::Equal, "-" => Code::Minus, "'" => Code::Quote, + "`" => Code::Backquote, "," => Code::Comma, "Delete" => Code::Backspace, // with modifiers only: a bare Enter belongs to text fields and tools diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..80cfdc8c1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -196,6 +196,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V Ok(json!({"changed": n})) } +/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged, +/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. +fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> { + let mut ops = Vec::new(); + for id in s.targets(p) { + let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; + let flag = step(old); + if flag != old { + ops.push(Op::SetFlag { id, flag }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + advance_if(s, p); + Ok(json!({"changed": n})) +} + fn advance_if(s: &mut Session, p: &Value) { if bool_or(p, "advance", false) { let _ = step(s, 1); @@ -687,6 +706,40 @@ pub fn specs() -> Vec<CommandSpec> { advance_if(s, p); Ok(v) }), + // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) + cmd!( + "photo.cycleFlag", + "Cycle Flag", + ["Photo", "Set Flag"], + Some("`"), + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged", + has_selection, + |s, p| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) + } + ), + cmd!( + "photo.increaseFlag", + "Increase Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Up"), + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)", + has_selection, + |s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) } + ), + cmd!( + "photo.decreaseFlag", + "Decrease Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Down"), + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)", + has_selection, + |s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) } + ), cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| { for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick })) }), @@ -1623,3 +1676,7 @@ mod gps_tests { #[cfg(test)] #[path = "library/tests_relative_ratings.rs"] mod relative_rating_tests; + +#[cfg(test)] +#[path = "library/tests_flag_cycle.rs"] +mod flag_cycle_tests; diff --git a/crates/engine/src/cmd/library/tests_flag_cycle.rs b/crates/engine/src/cmd/library/tests_flag_cycle.rs new file mode 100644 index 000000000..6a476dc1d --- /dev/null +++ b/crates/engine/src/cmd/library/tests_flag_cycle.rs @@ -0,0 +1,100 @@ +//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine. +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +fn session() -> Result<Session> { + let mut s = Session::new(); + for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-13", + ); + photo.flag = flag; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?; + Ok(s) +} + +fn flags(s: &Session) -> Vec<Flag> { + (1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect() +} + +#[test] +fn cycle_steps_unflagged_pick_reject_and_wraps() { + let mut s = session().unwrap(); + s.execute("library.select", &json!({"ids": [1]})).unwrap(); + let seen: Vec<Flag> = (0..4) + .map(|_| { + s.execute("photo.cycleFlag", &json!({})).unwrap(); + flags(&s)[0] + }) + .collect(); + assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]); +} + +#[test] +fn cycle_uses_each_photos_own_flag_in_one_undo_step() { + let mut s = session().unwrap(); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3); + assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.active(), Some(PhotoId(2))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]); +} + +#[test] +fn explicit_ids_active_fallback_advance_and_errors() { + let mut s = session().unwrap(); + s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]); + s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) }; + s.execute("photo.cycleFlag", &json!({})).unwrap(); + assert_eq!(flags(&s)[1], Flag::Reject); + s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap(); + assert_eq!(flags(&s)[1], Flag::None); + assert_eq!(s.active(), Some(PhotoId(3))); + let undo = s.undo.len(); + assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err()); + assert_eq!(flags(&s)[0], Flag::None); + assert_eq!(s.undo.len(), undo); + s.selection = Selection::default(); + assert!(s.execute("photo.cycleFlag", &json!({})).is_err()); + assert_eq!(s.undo.len(), undo); +} + +#[test] +fn increase_and_decrease_clamp_without_wrapping() { + let mut s = session().unwrap(); + assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2); + assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]); + assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0); + s.execute("photo.decreaseFlag", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]); + s.execute("photo.decreaseFlag", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0); + assert_eq!(s.undo.len(), undo, "no change, no history step"); +} + +#[test] +fn registered_with_classic_shortcuts() { + let specs = crate::command_specs(); + let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut); + assert_eq!(key("photo.cycleFlag"), Some("`")); + assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up")); + assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down")); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..b2cfa948d 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -766,7 +766,18 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> { /// Compare/Survey; Auto Advance moves on). pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Value, String> { let mut params = if params.is_null() { json!({}) } else { params }; - let culling_cmd = matches!(id, "photo.rate" | "photo.flag" | "photo.label" | "photo.pick" | "photo.reject" | "photo.unflag"); + let culling_cmd = matches!( + id, + "photo.rate" + | "photo.flag" + | "photo.label" + | "photo.pick" + | "photo.reject" + | "photo.unflag" + | "photo.cycleFlag" + | "photo.increaseFlag" + | "photo.decreaseFlag" + ); if culling_cmd { crate::panels::compare::target_active(app, &mut params); } @@ -799,6 +810,8 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String { "Escape" => "⎋", "Left" => "←", "Right" => "→", + "Up" => "↑", + "Down" => "↓", k => k, }; format!("{mods}{key}") diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1a21ab2fc..4211bdff7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -168,6 +168,7 @@ pub fn format(m: Modifiers, k: Key) -> Option<String> { Key::CloseBracket => "]", Key::Quote => "'", Key::Comma => ",", + Key::Backtick => "`", k => k.name(), }; let mut s = String::new(); @@ -451,6 +452,17 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { let d = if f == "library.next" { 1 } else { -1 }; let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; + } else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { + // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) + let active = app.session.active(); + cull(app, &f, json!({}), false); + let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag); + match flag { + Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), + Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), + Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")), + None => {} + } } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { cull(app, &f, json!({}), false); match f.as_str() { @@ -946,3 +958,7 @@ mod tests { #[cfg(test)] #[path = "shortcuts/tests_relative_ratings.rs"] mod relative_rating_tests; + +#[cfg(test)] +#[path = "shortcuts/tests_flag_cycle.rs"] +mod flag_cycle_tests; diff --git a/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs new file mode 100644 index 000000000..d4e6deec5 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs @@ -0,0 +1,140 @@ +//! The backtick (`) key cycles the flag of the selection / active photo; Cmd+Up / Cmd+Down raise / lower it. +use egui::{Key, Modifiers}; +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::state::ViewMode; + +fn app() -> lightcraft_engine::Result<LightcraftApp> { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=3 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-flag-cycle-keys/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-13", + ); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.select", &json!({"ids": [2]}))?; + Ok(LightcraftApp::new(s, crate::Services { png: None, ..Default::default() })) +} + +fn flags(app: &LightcraftApp) -> Vec<Flag> { + (1..=3).map(|id| app.session.catalog.photo(PhotoId(id)).unwrap().flag).collect() +} + +fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<(Key, Modifiers)>, text: Option<&mut String>) { + let events = + key.map(|(key, modifiers)| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers }).into_iter().collect(); + let mut text = text; + let mut out = ctx.run_ui( + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, + |ui| { + if let Some(text) = text.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("flag-cycle-search"))).request_focus(); + } + super::handle(app, ui.ctx()); + }, + ); + out.textures_delta.clear(); +} + +fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + frame(app, ctx, Some((Key::Backtick, Modifiers::NONE)), None); +} + +#[test] +fn backtick_parses_and_formats_as_the_grave_key() { + assert_eq!(super::parse("`"), Some((Modifiers::NONE, Key::Backtick))); + assert_eq!(super::format(Modifiers::NONE, Key::Backtick).as_deref(), Some("`")); + assert_eq!(super::shortcut_of(&Default::default(), "photo.cycleFlag"), Some("`")); +} + +#[test] +fn backtick_cycles_the_selected_photo_in_grid_and_loupe() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { + let mut app = app().unwrap(); + app.ui.view = view; + let ctx = egui::Context::default(); + let mut seen = Vec::new(); + for _ in 0..3 { + tick(&mut app, &ctx); + seen.push(flags(&app)[1]); + } + assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None], "{view:?}"); + assert_eq!(flags(&app)[0], Flag::None); + assert_eq!(flags(&app)[2], Flag::None); + assert_eq!(app.session.undo.len(), 3); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&app)[1], Flag::Reject); + } +} + +#[test] +fn cmd_up_and_down_raise_and_lower_the_flag() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + let cmd = Modifiers::COMMAND; + frame(&mut app, &ctx, Some((Key::ArrowUp, cmd)), None); + assert_eq!(flags(&app)[1], Flag::Pick); + frame(&mut app, &ctx, Some((Key::ArrowDown, cmd)), None); + frame(&mut app, &ctx, Some((Key::ArrowDown, cmd)), None); + assert_eq!(flags(&app)[1], Flag::Reject); + assert_eq!(app.session.undo.len(), 3); +} + +#[test] +fn backtick_does_nothing_while_a_text_field_has_focus() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + let mut text = String::new(); + frame(&mut app, &ctx, None, Some(&mut text)); + frame(&mut app, &ctx, Some((Key::Backtick, Modifiers::NONE)), Some(&mut text)); + assert!(ctx.egui_wants_keyboard_input()); + assert_eq!(flags(&app), [Flag::None; 3]); + assert!(app.session.undo.is_empty()); +} + +#[test] +fn compare_flags_only_the_active_photo_and_auto_advance_moves_on() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::Compare; + let ctx = egui::Context::default(); + app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + tick(&mut app, &ctx); + assert_eq!(flags(&app), [Flag::None, Flag::Pick, Flag::None]); + app.ui.view = ViewMode::PhotoGrid; + app.ui.auto_advance = true; + app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); + tick(&mut app, &ctx); + assert_eq!(flags(&app), [Flag::Pick, Flag::Pick, Flag::None]); + assert_eq!(app.session.active(), Some(PhotoId(2))); +} + +#[test] +fn a_saved_assignment_of_the_key_wins_over_the_default() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + super::assign(&mut app.ui.settings.keymap, "photo.reject", Some("`")).unwrap(); + tick(&mut app, &ctx); + assert_eq!(flags(&app)[1], Flag::Reject); +} + +#[test] +fn the_menu_item_follows_the_same_culling_rules() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::Compare; + app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + crate::menubar::run_item(&mut app, "photo.cycleFlag", serde_json::Value::Null).unwrap(); + assert_eq!(flags(&app), [Flag::None, Flag::Pick, Flag::None]); + assert_eq!(crate::menubar::shortcut_text("Cmd+Up", true), "⌘↑"); +} diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 414e9166b..123070bca 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -11,6 +11,8 @@ Select photos in Photo Grid or Square Grid, then use: | `P` | Flag as a pick | | `X` | Flag as rejected | | `U` | Clear the flag | +| `` ` `` (backtick) | Cycle the flag: unflagged → pick → reject → unflagged | +| `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | Purple and clearing a colour label are available in Photo → Set Color Label and the photo context @@ -29,6 +31,8 @@ step. AutoWrite uses the normal rating metadata path. In Detail, including Maski remapping and disabling the default brush bindings take precedence over their grid bracket actions. The separate Decrease Rating / Increase Rating commands can also be assigned in Help → Keyboard Shortcuts. +Flag cycling and raise/lower use each photo's own flag, so a mixed selection steps together without being made uniform. Adobe's shortcut list names the backtick "Cycle flag settings" but does not state the order; unflagged → pick → reject is LightCraft's choice. The key is the logical grave key, so it follows the keyboard layout (on layouts where it is a dead accent key, assign another key in Help → Keyboard Shortcuts to `photo.cycleFlag`). + Grid actions apply to all selected photos and support Undo / Redo. In Compare and Survey, culling actions apply to the active photo. Auto Advance moves forward after an action; holding Shift with Auto Advance on still advances only once. The last photo stays selected. Text fields keep their diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..cc722ac71 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEYC-RATING: ` cycles the flag, ⌘↑/⌘↓ raise/lower it; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -770,7 +770,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | | KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | -| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), filter-bar keys | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEYC-RATING: backtick cycles the flag (`photo.cycleFlag`), ⌘↑ / ⌘↓ raise / lower it (`photo.increaseFlag`, `photo.decreaseFlag`); status unchanged (🟡) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..6557687df 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (backtick flag cycling, ⌘↑/⌘↓ flag status; accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -65,7 +65,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. Remaining Classic gaps: filter-bar keys. **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, @@ -105,6 +105,7 @@ left panel, canvas, right panel, which is sensible but not tuned). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | From 25e832b404ba7ee78ce509565f30cd14d3cbbc99 Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Tue, 13 Oct 2026 16:11:47 +0530 Subject: [PATCH 091/315] KEYC-COMPARE: = and - make the grid thumbnails larger and smaller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View ▸ Increase / Decrease Grid Thumbnail Size (view.thumbSizeUp / view.thumbSizeDown) step the bottom-bar slider by 30 pt within its 90–480 range in Photo Grid, Square Grid and People, as Lightroom Classic's Library Grid keys do; + also enlarges. Detail, Compare, Survey and Reference ignore the keys, text fields keep them, and ⌘= / ⌘− still zoom the photo. The slider's range is now shared constants. --- crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 18 ++++ crates/ui-egui/src/panels/bottombar.rs | 5 +- crates/ui-egui/src/shortcuts.rs | 3 + crates/ui-egui/src/state.rs | 8 +- crates/ui-egui/src/tests_zoom_keys.rs | 139 ++++++++++++++++++++++++- docs/library-shortcuts.md | 12 ++- docs/parity-checklist.md | 11 +- docs/ui-parity.md | 5 +- 9 files changed, 192 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..6dd974105 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -123,6 +123,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.photoGrid", "view.squareGrid", "@Grid Info", + "view.thumbSizeUp", + "view.thumbSizeDown", "view.detail", "view.compare", "view.survey", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..b50e2d099 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -86,6 +86,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.navigate", "Set Image Zoom and Pan", None, ""), ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), + // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key + ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), + ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.visualizeHdr", "Visualize HDR", None, "View"), // in grids S expands/collapses stacks (the engine command it shadows) @@ -737,6 +740,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; Ok(Value::Null) } + "view.thumbSizeUp" | "view.thumbSizeDown" => { + // the bottom-bar slider's range, one `THUMB_SIZE_STEP` per call; {} → {size} + if !thumb_size_view(app) { + return Some(Err(format!("{id}: only in Photo Grid, Square Grid or People"))); + } + let step = if id == "view.thumbSizeUp" { crate::state::THUMB_SIZE_STEP } else { -crate::state::THUMB_SIZE_STEP }; + app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); + Ok(json!({"size": app.ui.thumb_size})) + } "view.navigate" => { // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). // Validate the complete request before changing either part of the viewport. @@ -1845,8 +1857,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Some(r) } +/// Views whose bottom bar has the thumbnail-size slider and that draw tiles of that size. +fn thumb_size_view(app: &LightcraftApp) -> bool { + matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid | ViewMode::People) +} + pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { match id { + "view.thumbSizeUp" | "view.thumbSizeDown" => thumb_size_view(app), "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 874df26cc..1079f16df 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -239,13 +239,14 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo let p = child.painter(); let y = r.center().y; p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); - let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0); + let (min, max) = (crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); + let f = (app.ui.thumb_size - min) / (max - min); let x = r.left() + f * r.width(); p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover); if let Some(pp) = resp.interact_pointer_pos() && (resp.dragged() || resp.clicked()) { - app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0); + app.ui.thumb_size = min + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (max - min); } } if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1a21ab2fc..d07b75d9d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -203,6 +203,9 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("F6", "view.filmstrip", "{}"), ("F7", "view.leftPanel", "{}"), ("Shift+M", "version.create", "{}"), + // grid thumbnail size: Classic's key is `=`; `+` (keypad, or Shift+= reported as Plus) agrees + ("Plus", "view.thumbSizeUp", "{}"), + ("Shift+Plus", "view.thumbSizeUp", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..a38d5f5e7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -449,6 +449,12 @@ impl AppSettings { /// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; +/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step +/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range. +pub const THUMB_SIZE_MIN: f32 = 90.0; +pub const THUMB_SIZE_MAX: f32 = 480.0; +pub const THUMB_SIZE_STEP: f32 = 30.0; + /// Width limits of a side panel the user resizes (points). #[derive(Clone, Copy, Debug, PartialEq)] pub struct PanelWidth { @@ -1193,7 +1199,7 @@ impl UiState { } /// Clamp values restored from disk. pub fn sanitized(mut self) -> Self { - self.thumb_size = self.thumb_size.clamp(90.0, 480.0); + self.thumb_size = self.thumb_size.clamp(THUMB_SIZE_MIN, THUMB_SIZE_MAX); self.left_width = LEFT_WIDTH.clamp(self.left_width); self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); diff --git a/crates/ui-egui/src/tests_zoom_keys.rs b/crates/ui-egui/src/tests_zoom_keys.rs index 3c1111217..e814230e2 100644 --- a/crates/ui-egui/src/tests_zoom_keys.rs +++ b/crates/ui-egui/src/tests_zoom_keys.rs @@ -1,7 +1,8 @@ //! Headless tests of the View ▸ Zoom shortcuts and modifier-wheel zoom: Cmd+= / Cmd+- / Cmd+0 //! (Ctrl on Windows and Linux) zoom the photo and never egui's interface scale, which would //! otherwise take the same keys at the end of the frame; Ctrl+wheel over the photo in the Edit -//! view zooms it. +//! view zooms it. Also the grid thumbnail-size keys `=` / `-` (Classic's Library Grid keys), which +//! share the physical keys with Cmd+= / Cmd+- but act on the grids only. use std::time::Duration; @@ -85,3 +86,139 @@ fn ctrl_wheel_over_the_photo_zooms_it_in_the_edit_view() { } assert!((h.app.image_rect.unwrap().width() - zoomed.width()).abs() < 0.01, "a plain wheel changed the zoom"); } + +fn grid_view(view: crate::state::ViewMode) -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + app.ui.view = view; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + h +} + +fn press(h: &mut Headless, k: &str, shift: bool, cmd: bool) { + let r = h.request("ui.key", json!({"key": k, "shift": shift, "cmd": cmd}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn equals_and_minus_step_the_grid_thumbnail_size_and_clamp() { + use crate::state::{THUMB_SIZE_MAX, THUMB_SIZE_MIN, THUMB_SIZE_STEP, ViewMode}; + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::People] { + let mut h = grid_view(view); + h.app.ui.thumb_size = 220.0; + press(&mut h, "=", false, false); + assert_eq!(h.app.ui.thumb_size, 220.0 + THUMB_SIZE_STEP, "{view:?}: ="); + press(&mut h, "-", false, false); + press(&mut h, "-", false, false); + assert_eq!(h.app.ui.thumb_size, 220.0 - THUMB_SIZE_STEP, "{view:?}: - -"); + // `+` (Shift+= on a US layout, or the keypad) is the same key for the same job + press(&mut h, "Plus", true, false); + press(&mut h, "Plus", false, false); + assert_eq!(h.app.ui.thumb_size, 220.0 + THUMB_SIZE_STEP, "{view:?}: +"); + // clamped at both ends of the bottom-bar slider's range, however many presses + h.app.ui.thumb_size = THUMB_SIZE_MAX - 5.0; + for _ in 0..3 { + press(&mut h, "=", false, false); + assert!(h.app.ui.thumb_size <= THUMB_SIZE_MAX); + } + assert_eq!(h.app.ui.thumb_size, THUMB_SIZE_MAX, "{view:?}"); + h.app.ui.thumb_size = THUMB_SIZE_MIN + 5.0; + for _ in 0..3 { + press(&mut h, "-", false, false); + assert!(h.app.ui.thumb_size >= THUMB_SIZE_MIN); + } + assert_eq!(h.app.ui.thumb_size, THUMB_SIZE_MIN, "{view:?}"); + // the whole range is walked in whole steps from either end + let mut presses = 0; + while h.app.ui.thumb_size < THUMB_SIZE_MAX { + press(&mut h, "=", false, false); + presses += 1; + assert!(presses <= 20, "never reached the maximum"); + } + assert_eq!(presses, ((THUMB_SIZE_MAX - THUMB_SIZE_MIN) / THUMB_SIZE_STEP).ceil() as usize); + } +} + +#[test] +fn thumbnail_size_keys_do_nothing_outside_the_grids() { + use crate::state::ViewMode; + for view in [ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] { + let mut h = grid_view(view); + h.app.ui.thumb_size = 220.0; + let zoom = h.app.ui.zoom; + for (k, shift) in [("=", false), ("-", false), ("Plus", true)] { + press(&mut h, k, shift, false); + } + assert_eq!(h.app.ui.thumb_size, 220.0, "{view:?}"); + assert_eq!(h.app.ui.zoom, zoom, "{view:?}: a bare = / - is not a zoom key"); + // the command says why, and the menu shows it unavailable + let r = h.request("ui.menu.invoke", json!({"id": "view.thumbSizeUp"}), T); + assert_eq!(r["ok"], false, "{view:?}: {r}"); + assert_eq!(h.app.ui.thumb_size, 220.0); + let list = h.request("ui.menu.list", json!({}), T); + let entry = list["result"].as_array().and_then(|a| a.iter().find(|e| e["id"] == "view.thumbSizeUp")).cloned().unwrap_or_default(); + assert_eq!(entry["enabled"], false, "{view:?}: {entry}"); + } +} + +#[test] +fn cmd_equals_and_minus_still_zoom_and_leave_the_thumbnail_size_alone() { + // in the loupe: the photo zooms, the grid size is untouched + let mut h = edit_view(); + h.app.ui.thumb_size = 220.0; + key(&mut h, "="); + assert_eq!(h.app.ui.zoom, Zoom::Percent(50.0)); + key(&mut h, "-"); + assert_eq!(h.app.ui.zoom, Zoom::Percent(25.0)); + assert_eq!(h.app.ui.thumb_size, 220.0); + // in a grid Cmd+= / Cmd+- are View ▸ Zoom In / Out, not the size keys + let mut h = grid_view(crate::state::ViewMode::PhotoGrid); + h.app.ui.thumb_size = 220.0; + key(&mut h, "="); + key(&mut h, "-"); + assert_eq!(h.app.ui.thumb_size, 220.0, "Cmd+= / Cmd+- changed the grid size"); +} + +#[test] +fn search_field_keeps_equals_and_minus() { + let mut h = grid_view(crate::state::ViewMode::PhotoGrid); + h.app.ui.thumb_size = 220.0; + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + for (k, text) in [("-", "-"), ("=", "="), ("Plus", "+")] { + press(&mut h, k, false, false); + let r = h.request("ui.text", json!({"text": text}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + assert_eq!(h.app.ui.thumb_size, 220.0, "typing in the search field resized the grid"); + assert_eq!(h.app.ui.search, "-=+", "the search field did not receive the keys"); +} + +#[test] +fn thumbnail_size_commands_run_from_the_control_channel_and_persist() { + let mut h = grid_view(crate::state::ViewMode::SquareGrid); + h.app.ui.thumb_size = 120.0; + let r = h.request("ui.menu.invoke", json!({"id": "view.thumbSizeUp"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["size"], 150.0); + let r = h.request("engine.execute", json!({"command": "view.thumbSizeDown"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.thumb_size, 120.0); + let r = h.request("ui.menu.invoke", json!({"id": "view.thumbSizeUp"}), T); + assert_eq!(r["ok"], true, "{r}"); + // the size is saved with the rest of the UI state, like the slider's, and restored clamped + let saved = serde_json::to_value(&h.app.ui).unwrap(); + assert_eq!(saved["thumbSize"], 150.0); + let loaded = serde_json::from_value::<crate::state::UiState>(saved).unwrap().sanitized(); + assert_eq!(loaded.thumb_size, 150.0); + let mut wild = serde_json::to_value(&h.app.ui).unwrap(); + wild["thumbSize"] = json!(9000.0); + assert_eq!(serde_json::from_value::<crate::state::UiState>(wild).unwrap().sanitized().thumb_size, crate::state::THUMB_SIZE_MAX); +} diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 414e9166b..fab02d628 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -46,7 +46,17 @@ commit `4e012a59a6d2442ba749f109ea27aadb5c0b11a5` by Gardy, and addresses the ca [issue #283](https://github.com/storytold/lightcraft/issues/283). That PR was open when checked on 2026-10-08; this work extends its fix with library culling bindings and tests. -Public behaviour references, checked 2026-10-08: +## Grid thumbnail size + +`=` makes the grid thumbnails larger and `-` smaller (`+` also enlarges), as in Lightroom Classic. Each +press moves the bottom-bar slider by 30 pt within its 90–480 pt range and stops at either end. The keys +work in Photo Grid, Square Grid and People (the views with the slider), do nothing in Detail, Compare, +Survey and Reference, and leave text fields alone, so the search box still types `-` and `=`. `⌘=` / +`⌘−` remain Zoom In / Out. The same steps are the View ▸ Increase / Decrease Grid Thumbnail Size +commands (`view.thumbSizeUp`, `view.thumbSizeDown`, reachable from the control channel and MCP, which +return `{"size"}`); the size is saved with the other interface settings. + +Public behaviour references, checked 2026-10-08 (thumbnail size: 2026-10-13): - [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html) - [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..5a5c0f317 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEYC-COMPARE: `=` / `-` change the grid thumbnail size; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 52 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 533 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 173. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -769,7 +769,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | -| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | +| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs` | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); no compare, stacks keys | | KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | @@ -814,3 +814,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..f15a34864 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`=` / `-` grid thumbnail size keys; accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -67,6 +67,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. +**Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). + **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). @@ -105,6 +107,7 @@ left panel, canvas, right panel, which is sensible but not tuned). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` grid thumbnail size keys noted in the shortcuts section | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | From 21861c2f142dfed8722ae56a8041e60a0abec5ad Mon Sep 17 00:00:00 2001 From: Atmik Shetty <atmikshetty10@gmail.com> Date: Tue, 13 Oct 2026 16:14:19 +0530 Subject: [PATCH 092/315] LR-EDIT-DETAIL-NR: noise reduction works in the original's pixels, so previews match the full-size render scaled down (fixes #960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid thumbnails looked much more denoised than the loupe. `nr_params` sized the radii from the output's scale relative to the source buffer it was given, not the original: a 256-px cell drawn from the 512-px thumbnail level used half the full-size radius on pixels that each average ~23 sensor pixels, and below 0.4x (0.35x for colour) the radius stopped shrinking at all. The guided filter's eps (the variance treated as noise) also stayed at its full-size value although downscaling divides the noise variance by 1/scale², so texture in small previews was flattened as noise. - `SourceInfo::native_long` (filled by the engine from the catalog's photo size, kept when the decoder supplies its own facts, carried to web workers) and `Plan::detail_scale` = output pixels per original pixel (from `px_per_long`, so a crop no longer changes it). - `nr_params(s, scale)`: radii x scale with no floor, eps x scale². Below 1 px the smallest blur the box Gaussian makes is blended in by the ratio of variances (`blur::box_sigma`); a fade under 1% skips the filter. At scale 1 the parameters are unchanged: 1:1 views and full-size exports render as before. The GPU takes the same parameters. - RENDER_CACHE_VERSION 63: cached thumbnails are redrawn. Measured on a 1536x1024 noisy synthetic scene (tests_nr_scale.rs), error of the preview against the full-size render scaled down, as a fraction of leaving NR off, luminance NR 50 at preview scales 0.5 / 0.33 / 0.25 / 0.167 / 0.1 / 0.05: before 0.53 / 0.70 / 1.04 / 2.26 / 3.87 / 6.07, after 0.28 / 0.37 / 0.66 / 0.90 / 0.99 / 1.00. A 256-px render from the 512-px level now matches one from the original (mean 0.07 LSB vs 0.71 before). --- apps/lightcraft-web/src/wire.rs | 6 + crates/engine/src/files.rs | 4 +- crates/engine/src/media.rs | 32 +++- crates/gpu/src/render.rs | 2 +- crates/gpu/tests/equivalence.rs | 20 +++ crates/pipeline/src/lib.rs | 26 +++- crates/pipeline/src/local.rs | 60 ++++++-- crates/pipeline/src/tests_nr_scale.rs | 211 ++++++++++++++++++++++++++ crates/raster/src/blur.rs | 24 +++ docs/parity-checklist.md | 7 +- docs/whats-new.md | 7 + 11 files changed, 375 insertions(+), 24 deletions(-) create mode 100644 crates/pipeline/src/tests_nr_scale.rs diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index b3d685f5f..08f9e898d 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -41,6 +41,9 @@ pub struct WireJob { pub as_shot_temp: f64, pub as_shot_tint: f64, pub lens: Option<EmbeddedLens>, + /// The original's long edge (`SourceInfo::native_long`). + #[serde(default)] + pub native_long: Option<usize>, pub settings: Arc<DevelopSettings>, pub max_w: usize, pub max_h: usize, @@ -80,6 +83,7 @@ impl WireJob { as_shot_temp: job.info.as_shot_temp, as_shot_tint: job.info.as_shot_tint, lens: job.info.lens, + native_long: job.info.native_long, settings: job.settings.clone(), max_w: job.request.max_w, max_h: job.request.max_h, @@ -101,6 +105,7 @@ impl WireJob { lens: self.lens, relative_wb: self.relative_wb, legacy_relative_wb: self.legacy_relative_wb, + native_long: self.native_long, ..Default::default() } } @@ -469,6 +474,7 @@ mod tests { as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, + native_long: None, settings: Arc::new(DevelopSettings::default()), max_w: 32, max_h: 32, diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..2374eafde 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -495,6 +495,8 @@ fn load_bytes_now( camera_tone, mattes, local_tone, + // (the catalog knows the original's size: `media::source_info`) + native_long: None, }, )); } @@ -2077,7 +2079,7 @@ mod tests { assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); assert!(!p.develops_raw()); // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults - assert_eq!(crate::media::source_info(&p), SourceInfo::default()); + assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); // agents see it diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index cf17c3264..9e25e5d6f 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 62; +pub const RENDER_CACHE_VERSION: u64 = 63; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -214,9 +214,13 @@ impl DecodedSource { } /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) - /// with any stored camera tone curve. + /// with any stored camera tone curve. The original's size comes from `header` either way: the + /// decoder only sees the level it decoded. pub fn info_or(&self, header: SourceInfo) -> SourceInfo { - self.info.clone().unwrap_or_else(|| SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }) + match &self.info { + Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, + None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, + } } } @@ -1096,9 +1100,16 @@ pub fn content_key(p: &Photo) -> String { pub fn source_info(p: &Photo) -> SourceInfo { // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. + // (They are drawn afresh at every size, so they have no original size.) if matches!(p.source, Source::Demo { .. }) { return SourceInfo { raw: true, ..Default::default() }; } + // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) + let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); + SourceInfo { native_long, ..header_info(p) } +} + +fn header_info(p: &Photo) -> SourceInfo { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white // balance and the display tone curve, like any other rendered file. if p.develops_raw() { @@ -1695,6 +1706,21 @@ mod thumbnail_hash_tests { mod tests { use super::*; + #[test] + fn renders_know_the_original_size_whichever_level_they_come_from() { + // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the + // 512-px thumbnail level denoises like a scaled-down export + let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); + assert_eq!(source_info(&p).native_long, Some(6000)); + let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; + let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); + let info = thumb.info_or(source_info(&p)); + assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); + let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); + assert_eq!(source_info(&unknown).native_long, None); + assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); + } + #[test] fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { let mut plain = Rgb32f::new(8, 8); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 04ae60958..3c7f56b18 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -823,7 +823,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf { let (w, h) = (plan.w, plan.h); let n = w * h; - let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h)); + let (lum, col) = local::nr_params(&plan.settings, plan.detail_scale); let mut img = img; if let Some(nr) = lum { let l = cx.gpu.buffer(n); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 96771a971..fcf222191 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -93,6 +93,26 @@ fn typical(s: &mut DevelopSettings) { s.detail.sharpen_amount = 40.0; } +/// Issue #960: a preview's noise reduction works in the original's pixels (a smaller radius, a +/// smaller `eps`, faded below one pixel). The GPU takes the same parameters (`local::nr_params`). +#[test] +fn preview_noise_reduction_matches() { + if !gpu() { + return; + } + let src = scene(3, 640, 480); + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 70.0; + s.detail.nr_detail = 30.0; + s.detail.nr_color = 60.0; + s.detail.nr_color_smoothness = 50.0; + // a preview level of a 6000-px original: loupe-, grid- and tiny-thumbnail-sized outputs + let info = SourceInfo { raw: true, native_long: Some(6000), ..Default::default() }; + for (name, edge) in [("NR preview 640 of 6000", 640), ("NR preview 320 of 6000", 320), ("NR preview 128 of 6000", 128)] { + check(name, &src, &info, &s, &RenderRequest::fit(edge, edge)); + } +} + /// A render big enough for the per-pixel stage to run in several bands of rows (and the render /// in many submissions), with grain (position-dependent) and masks. #[test] diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index d94d3a2fc..8553c23f4 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -92,6 +92,11 @@ pub struct SourceInfo { /// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the /// photo's Profile option asks for it ([`local_tone`]). pub local_tone: Option<Arc<local_tone::LocalTone>>, + /// Long edge (px) of the original photo, when the source handed to the renderer is a smaller + /// copy of it (the app's thumbnail and preview levels); `None`: the source is the original. + /// Noise reduction sizes its radii in the original's pixels ([`Plan::detail_scale`]), so a + /// preview looks like the full-size render scaled down (issue #960). + pub native_long: Option<usize>, } impl Default for SourceInfo { @@ -107,6 +112,7 @@ impl Default for SourceInfo { camera_tone: None, mattes: None, local_tone: None, + native_long: None, } } } @@ -360,6 +366,9 @@ pub struct Plan<'a> { pub px_per_long: f64, /// Long edge of the source buffer (px). pub src_long: usize, + /// Output pixels per pixel of the original photo ([`SourceInfo::native_long`], else the source + /// buffer), at most 1: the scale of noise reduction, whose radii are in the original's pixels. + pub detail_scale: f32, /// Key of the resampled source (together with the source buffer's identity). pub geo: u64, /// Key of the white-balanced, retouched, denoised image (and of the planes computed from it). @@ -423,6 +432,9 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R None => (frame, full_w, full_h), }; let src_long = src.width.max(src.height); + // (an original reported smaller than the source is the source) + let native_long = info.native_long.unwrap_or(0).max(src_long).max(1); + let detail_scale = local::detail_scale(px_per_long, native_long); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long); @@ -438,8 +450,12 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R local_tone::enabled(info, s), src_long, )); - let lin_key = hash_of((pre_nr_key, [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits))); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } + let lin_key = hash_of(( + pre_nr_key, + [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), + detail_scale.to_bits(), + )); + Plan { settings, frame, w, h, px_per_long, src_long, detail_scale, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -538,7 +554,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Src::Shared(a) => a, }; let plan = plan(src_img, info, s, req); - let Plan { ref frame, w, h, px_per_long, src_long, geo, lin_key, .. } = plan; + let Plan { ref frame, w, h, px_per_long, detail_scale, geo, lin_key, .. } = plan; let s = &*plan.settings; let shared = match (&src, cache) { @@ -558,7 +574,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende // Without a cache the resampled buffer is ours: work on it in place. let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) }; lin_cpu(&mut img, info, &plan); - local::denoise(&mut img, s, src_long, frame.output_long(w, h)); + local::denoise(&mut img, s, detail_scale); Arc::new(img) } }; @@ -713,6 +729,8 @@ mod tests_hdr; #[cfg(test)] mod tests_local; #[cfg(test)] +mod tests_nr_scale; +#[cfg(test)] mod tests_tone; #[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index ce3994cd8..89d5d51b1 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -306,28 +306,64 @@ pub struct NrColor { pub t: f32, } -/// Noise-reduction parameters at an output long edge of `out_long` px (see [`denoise`]). -pub fn nr_params(s: &DevelopSettings, src_long: usize, out_long: usize) -> (Option<NrLum>, Option<NrColor>) { +/// Output pixels per pixel of the original photo for an output of `px_per_long` pixels per unit +/// of the long edge of an original `native_long` pixels long, at most 1 ([`crate::Plan::detail_scale`]). +pub fn detail_scale(px_per_long: f64, native_long: usize) -> f32 { + let k = (px_per_long / native_long.max(1) as f64) as f32; + if k.is_finite() { k.clamp(0.0, 1.0) } else { 1.0 } +} + +/// Smallest radius (px) noise reduction filters a preview with: [`gaussian`] makes no blur below +/// about 0.58 px and one 3-px box from there ([`lightcraft_raster::blur::box_sigma`]). +const NR_MIN_SIGMA: f32 = 1.0; + +/// Below this fade a preview's noise reduction is not drawn at all (less than 1 % of a 1-px blur). +const NR_MIN_BLEND: f32 = 0.01; + +/// Radius and blend weight that stand in for a blur of `sigma` px: below [`NR_MIN_SIGMA`] the +/// smallest blur the filter makes, blended in by the ratio of their variances, which keeps the +/// smoothing's second moment (how much it softens the finest detail) that of `sigma`. +fn nr_radius(sigma: f32) -> (f32, f32) { + if sigma >= NR_MIN_SIGMA { + return (sigma, 1.0); + } + let made = lightcraft_raster::blur::box_sigma(NR_MIN_SIGMA); + (NR_MIN_SIGMA, (sigma / made).powi(2).min(1.0)) +} + +/// Noise-reduction parameters at `scale` output pixels per pixel of the original +/// ([`crate::Plan::detail_scale`]; see [`denoise`]). Noise lives at the original's pixels, so the +/// radii are the full-size radii times `scale`, and the guided filter's `eps` (the variance it +/// treats as noise) follows the noise variance, which averaging `1/scale × 1/scale` pixels into one +/// multiplies by `scale²`. A radius below [`NR_MIN_SIGMA`] fades instead ([`nr_radius`]): previews look like +/// the full-size render scaled down (issue #960). At `scale` 1 these are the full-size parameters. +pub fn nr_params(s: &DevelopSettings, scale: f32) -> (Option<NrLum>, Option<NrColor>) { let lum = (s.detail.nr_luminance / 100.0) as f32; let col = (s.detail.nr_color / 100.0) as f32; - let scale = (out_long as f32 / src_long.max(1) as f32).clamp(0.05, 1.0); - let l = (lum > 0.0).then(|| { + let scale = if scale.is_finite() { scale.clamp(0.0, 1.0) } else { 1.0 }; + let l = (lum > 0.0).then(|| nr_radius((1.0 + 2.5 * lum) * scale)).filter(|&(_, w)| w >= NR_MIN_BLEND).map(|(sigma, w)| { let detail = (s.detail.nr_detail / 100.0) as f32; - NrLum { sigma: (1.0 + 2.5 * lum) * scale.max(0.4), eps: 0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail), k: lum.sqrt() } + let eps = ((0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail)) * scale * scale).max(1e-7); + NrLum { sigma, eps, k: lum.sqrt() * w } }); - let c = (col > 0.0).then(|| { - let sigma = (1.5 + 6.0 * col) * scale.max(0.35) * (1.0 + (s.detail.nr_color_smoothness / 100.0) as f32); + let smooth = 1.0 + (s.detail.nr_color_smoothness / 100.0) as f32; + let c = (col > 0.0).then(|| nr_radius((1.5 + 6.0 * col) * scale * smooth)).filter(|&(_, w)| w >= NR_MIN_BLEND).map(|(sigma, w)| { let keep = (s.detail.nr_color_detail / 100.0) as f32 * 0.5; - NrColor { sigma, t: col * (1.0 - keep) } + NrColor { sigma, t: col * (1.0 - keep) * w } }); (l, c) } /// Noise reduction at output resolution: luminance via an edge-aware self-guided filter on /// log-luminance, colour by blurring chromaticity (rgb / Y) and re-applying the original luminance. -/// Radii scale with how much the source was downsampled (preview noise is already averaged out). -pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: usize) { - let (lum, col) = nr_params(s, src_long, out_long); +/// `scale`: output pixels per pixel of the original ([`nr_params`]). +pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, scale: f32) { + let (lum, col) = nr_params(s, scale); + apply_nr(img, lum, col); +} + +/// Noise reduction with explicit parameters (see [`denoise`]). +pub fn apply_nr(img: &mut Rgb32f, lum: Option<NrLum>, col: Option<NrColor>) { let w = img.width; if let Some(nr) = lum { let _t = crate::profiling().then(std::time::Instant::now); @@ -641,7 +677,7 @@ mod nr_tests { let (v0, m0) = var(&img); let mut s = DevelopSettings::default(); s.detail.nr_luminance = 80.0; - denoise(&mut img, &s, 64, 64); + denoise(&mut img, &s, 1.0); let (v1, m1) = var(&img); assert!(v1 < v0 * 0.5, "{v0} -> {v1}"); assert!((m1 - m0).abs() < 0.01); diff --git a/crates/pipeline/src/tests_nr_scale.rs b/crates/pipeline/src/tests_nr_scale.rs new file mode 100644 index 000000000..3247422fa --- /dev/null +++ b/crates/pipeline/src/tests_nr_scale.rs @@ -0,0 +1,211 @@ +//! Noise reduction is resolution independent (issue #960): a preview looks like the full-size +//! render scaled down, whatever the preview's size and whichever source level it is drawn from. + +use crate::local::{denoise, log_lum, nr_params}; +use crate::{RenderRequest, SourceInfo, plan, render}; +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Rgb32f, Rgba8}; + +const W: usize = 1536; +const H: usize = 1024; + +/// Deterministic standard normal samples (xorshift + Box-Muller). +struct Rng(u64); + +impl Rng { + fn uniform(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + ((self.0 >> 40) as f32 + 0.5) / (1u64 << 24) as f32 + } + fn normal(&mut self) -> f32 { + let (u, v) = (self.uniform(), self.uniform()); + (-2.0 * u.ln()).sqrt() * (std::f32::consts::TAU * v).cos() + } +} + +/// A `W × H` linear scene: a gradient, an edge and texture from 3 to 750 px wavelength, with white +/// luminance noise of `noise` stops and colour noise. +fn noisy_scene(noise: f32) -> Rgb32f { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let n: Vec<[f32; 4]> = (0..W * H).map(|_| [rng.normal(), rng.normal(), rng.normal(), rng.normal()]).collect(); + Rgb32f::from_fn(W, H, |x, y| { + let r = n[y * W + x]; + let (xf, yf) = (x as f32, y as f32); + let tex = 0.25 * (xf / 120.0).sin() * (yf / 90.0).cos() + + 0.12 * ((xf + yf) / 16.0).sin() + + 0.08 * (xf / 6.0).sin() * (yf / 7.0).sin() + + 0.05 * ((xf - 2.0 * yf) / 3.0).sin(); + let edge = if x > 900 { 1.2 } else { 0.0 }; + let y0 = 0.18 * (-1.5 + 1.5 * yf / H as f32 + tex + edge + noise * r[0]).exp2(); + let c = |v: f32| y0 * (1.0 + 0.3 * noise * v).max(0.05); + [c(r[1]), c(r[2]), c(r[3])] + }) +} + +fn scaled(img: &Rgb32f, s: f32) -> Rgb32f { + resize(img, (W as f32 * s).round() as usize, (H as f32 * s).round() as usize, Filter::Mitchell) +} + +/// RMS difference of log2 luminance (stops). +fn rms_log(a: &Rgb32f, b: &Rgb32f) -> f32 { + let n = a.data.len() as f32; + (a.data.iter().zip(&b.data).map(|(p, q)| (log_lum(*p) - log_lum(*q)).powi(2)).sum::<f32>() / n).sqrt() +} + +/// RMS difference of chromaticity (r and b over r + g + b). +fn rms_chroma(a: &Rgb32f, b: &Rgb32f) -> f32 { + let ch = |p: [f32; 3]| { + let s = (p[0] + p[1] + p[2]).max(1e-6); + [p[0] / s, p[2] / s] + }; + let d = |p: [f32; 3], q: [f32; 3]| (ch(p)[0] - ch(q)[0]).powi(2) + (ch(p)[1] - ch(q)[1]).powi(2); + (a.data.iter().zip(&b.data).map(|(p, q)| d(*p, *q)).sum::<f32>() / a.data.len() as f32).sqrt() +} + +/// For each preview scale `k`: (k, how far the preview is from the full-size render scaled down, +/// how far it would be with noise reduction left off). +fn preview_error(s: &DevelopSettings, scales: &[f32], metric: fn(&Rgb32f, &Rgb32f) -> f32) -> Vec<(f32, f32, f32)> { + let src = noisy_scene(0.15); + let mut full = src.clone(); + denoise(&mut full, s, 1.0); + scales + .iter() + .map(|&k| { + let reference = scaled(&full, k); + let plain = scaled(&src, k); + let mut preview = plain.clone(); + denoise(&mut preview, s, k); + (k, metric(&preview, &reference), metric(&plain, &reference)) + }) + .collect() +} + +const SCALES: [f32; 6] = [0.5, 0.33, 0.25, 0.167, 0.1, 0.05]; + +#[test] +fn luminance_nr_preview_matches_full_size_scaled_down() { + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 50.0; + s.detail.nr_detail = 50.0; + s.detail.nr_color = 0.0; + for (k, err, off) in preview_error(&s, &SCALES, rms_log) { + let ratio = err / off; + eprintln!("luminance NR, preview at {k}: {err:.4} stops from the full-size render scaled down ({ratio:.2} of NR off)"); + // Before #960 the radius stopped shrinking at 0.4×: 2.3× (at 0.167) to 6× (at 0.05) further + // off than no noise reduction at all, and 0.53 / 0.70 of it at 0.5 / 0.33. + assert!(ratio <= 1.0, "preview at {k} is further off than no noise reduction: {err} vs {off}"); + assert!(err <= 0.01, "preview at {k} is {err} stops off"); + if k >= 0.33 { + assert!(ratio <= 0.4, "preview at {k}: {ratio}"); + } + } +} + +#[test] +fn colour_nr_preview_matches_full_size_scaled_down() { + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 0.0; + s.detail.nr_color = 50.0; + s.detail.nr_color_detail = 50.0; + s.detail.nr_color_smoothness = 50.0; + for (k, err, off) in preview_error(&s, &SCALES, rms_chroma) { + let ratio = err / off; + eprintln!("colour NR, preview at {k}: {err:.6} from the full-size render scaled down ({ratio:.2} of NR off)"); + // before #960: 0.00020 (0.31 of NR off) at 0.167, 0.00022 (0.75) at 0.1 + assert!(err <= 1.2e-4, "preview at {k} is {err} off"); + if k >= 0.1 { + assert!(ratio <= 0.4, "preview at {k}: {ratio}"); + } + } +} + +#[test] +fn full_size_parameters_unchanged() { + // full-size renders and exports keep their look: these are the parameters from before #960 + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 50.0; + s.detail.nr_detail = 50.0; + s.detail.nr_color = 25.0; + s.detail.nr_color_detail = 50.0; + s.detail.nr_color_smoothness = 50.0; + let (l, c) = nr_params(&s, 1.0); + let (l, c) = (l.unwrap(), c.unwrap()); + assert_eq!(l.sigma, 1.0 + 2.5 * 0.5); + assert_eq!(l.eps, 0.002 + 0.5 * 0.5 * 0.25 * (1.0 - 0.7 * 0.5)); + assert_eq!(l.k, 0.5f32.sqrt()); + assert_eq!(c.sigma, (1.5 + 6.0 * 0.25) * 1.5); + assert_eq!(c.t, 0.25 * (1.0 - 0.25)); +} + +#[test] +fn tiny_previews_skip_noise_reduction_and_hostile_scales_are_safe() { + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 100.0; + s.detail.nr_color = 100.0; + // a 128-px thumbnail of a 6000-px photo: the luminance radius is a few hundredths of a pixel, + // the colour radius (wider) a quarter of one, so that fades to a tenth + let (l, c) = nr_params(&s, 128.0 / 6000.0); + assert_eq!(l, None); + let (c, full) = (c.unwrap(), nr_params(&s, 1.0).1.unwrap()); + assert!(c.t < full.t * 0.1 && c.t > 0.0, "{c:?}"); + assert_eq!(nr_params(&s, 0.0), (None, None)); + for k in [f32::NAN, f32::INFINITY, -1.0, 7.0] { + let (l, c) = nr_params(&s, k); + let vals = [l.map(|l| [l.sigma, l.eps, l.k]), c.map(|c| [c.sigma, c.t, 1.0])]; + for v in vals.into_iter().flatten().flatten() { + assert!(v.is_finite() && v >= 0.0, "{k}: {v}"); + } + } +} + +fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f32 { + let px = |p: &[u8; 4], q: &[u8; 4]| (0..3).map(|i| (f32::from(p[i]) - f32::from(q[i])).abs()).sum::<f32>() / 3.0; + a.data.iter().zip(&b.data).map(|(p, q)| px(p, q)).sum::<f32>() / a.data.len() as f32 +} + +#[test] +fn preview_from_a_smaller_source_level_matches_one_from_the_original() { + // The app draws a grid cell from the 512-px thumbnail level and the loupe from the 2560-px + // preview level. Told the original's size, a render from the smaller copy denoises like one from + // the original at the same output size; before #960 its radii were in the copy's pixels. + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 60.0; + s.detail.nr_color = 40.0; + let original = noisy_scene(0.15); + let thumb_level = scaled(&original, 512.0 / W as f32); + let req = RenderRequest::fit(256, 256); + let raw = SourceInfo { raw: true, ..Default::default() }; + let told = SourceInfo { native_long: Some(W), ..raw.clone() }; + assert!((plan(&thumb_level, &told, &s, &req).detail_scale - 256.0 / W as f32).abs() < 1e-4); + assert!((plan(&original, &raw, &s, &req).detail_scale - 256.0 / W as f32).abs() < 1e-4); + assert!((plan(&thumb_level, &raw, &s, &req).detail_scale - 0.5).abs() < 1e-4, "original unknown: the source's pixels"); + + let mut plain = s.clone(); + plain.detail.nr_luminance = 0.0; + plain.detail.nr_color = 0.0; + let from_original = render(&original, &raw, &s, &req).image; + let told_diff = mean_abs_diff(&render(&thumb_level, &told, &s, &req).image, &from_original); + let untold_diff = mean_abs_diff(&render(&thumb_level, &raw, &s, &req).image, &from_original); + // (the two sources differ a little anyway: the thumbnail level is resampled twice) + let resampling = mean_abs_diff(&render(&thumb_level, &told, &plain, &req).image, &render(&original, &raw, &plain, &req).image); + eprintln!("mean |Δ| (8-bit) from the original's render: told {told_diff:.3}, not told {untold_diff:.3}, NR off {resampling:.3}"); + assert!(told_diff <= resampling + 0.1, "{told_diff} vs {resampling}"); + assert!(untold_diff > told_diff + 0.3, "the copy's own pixels change the result: {untold_diff} vs {told_diff}"); +} + +#[test] +fn crop_does_not_change_the_noise_reduction_scale() { + // a 1:1 render of a crop is at the original's pixels, like a 1:1 render of the whole photo + let s = DevelopSettings::default(); + let src = Rgb32f::filled(400, 200, [0.2; 3]); + let info = SourceInfo::default(); + assert!((plan(&src, &info, &s, &RenderRequest::fit(400, 200)).detail_scale - 1.0).abs() < 1e-6); + let mut cropped = s.clone(); + cropped.crop.geometry.rect = lightcraft_geom::Rect::new(0.25, 0.25, 0.75, 0.75); + let p = plan(&src, &info, &cropped, &RenderRequest::fit(200, 100)); + assert_eq!((p.w, p.h), (200, 100)); + assert!((p.detail_scale - 1.0).abs() < 1e-6, "{}", p.detail_scale); +} diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 8deba2d13..9d954a814 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -17,6 +17,16 @@ pub fn box_radii(sigma: f32) -> [usize; 3] { std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize) } +/// Standard deviation (px) of the blur [`gaussian`] actually makes for `sigma`: its boxes are +/// whole pixels wide, so small radii snap to a few steps (0 below about 0.58 px, then √(2/3) px for one +/// 3-px box per axis, …). +pub fn box_sigma(sigma: f32) -> f32 { + if sigma.is_nan() || sigma <= 0.3 { + return 0.0; + } + box_radii(sigma).iter().map(|&r| (((2 * r + 1) * (2 * r + 1) - 1) as f32) / 12.0).sum::<f32>().sqrt() +} + /// One horizontal box pass over `row` (clamped edges), using `tmp` as scratch. #[inline] fn box_row<T: Pixel>(row: &mut [T], tmp: &mut Vec<T>, r: usize) { @@ -114,6 +124,20 @@ mod tests { assert!((b.get(50, 45) - b.get(50, 55)).abs() < 1e-6); } + #[test] + fn box_sigma_is_the_blur_made() { + assert_eq!(box_sigma(0.3), 0.0); + assert_eq!(box_sigma(0.5), 0.0, "no box wider than 1 px"); + assert!((box_sigma(1.0) - (2.0f32 / 3.0).sqrt()).abs() < 1e-6); + for sigma in [1.0f32, 2.5, 8.0] { + let mut imp = Image::<f32>::new(201, 1); + imp.set(100, 0, 1.0); + let b = gaussian(&imp, sigma); + let var: f32 = b.data.iter().enumerate().map(|(i, v)| v * ((i as f32 - 100.0).powi(2))).sum(); + assert!((var.sqrt() - box_sigma(sigma)).abs() < 1e-3, "{sigma}: {} vs {}", var.sqrt(), box_sigma(sigma)); + } + } + #[test] fn variance_close_to_sigma_squared() { let mut imp = Image::<f32>::new(201, 1); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..a15e8cc92 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-EDIT-DETAIL-NR, LR-EDIT-DETAIL-CNR: noise reduction no longer stronger in the grid than in the loupe (#960); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -204,8 +204,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | | LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | -| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | -| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960) | +| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | | LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-EDIT-DETAIL-NR, LR-EDIT-DETAIL-CNR: noise-reduction radii in the original's pixels, so previews at any size and smaller exports look like the full-size render scaled down (issue #960); status unchanged | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..35ac52f5f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-13 +### Noise reduction looks the same at every size +- Luminance and colour noise reduction no longer look much stronger in grid thumbnails and the filmstrip than in + the loupe (issue #960). Their radii are now set in the original photo's pixels, so a thumbnail, the fitted loupe + and a smaller export look like the full-size result scaled down; 1:1 views and full-size exports are unchanged. + Like in Lightroom, judge noise reduction at 1:1: at small sizes the noise it removes is mostly averaged away. + Cached thumbnails are redrawn. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From 17a68863f6baf023f778cb3b254c3cfd826cc460 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Sat, 10 Oct 2026 11:55:03 +0200 Subject: [PATCH 093/315] LR-EDIT-OPTICS-PROFILE: share the radial warp fit between RW2 and Sony Move the forward-map inversion, least-squares fit and solve4 out of the RW2 decoder into vendor::warp_from_forward_map; RW2 calls it in DNG-normalised units. Co-Authored-By: Claude <noreply@anthropic.com> --- crates/raw/src/vendor/mod.rs | 121 +++++++++++++++++++++++++++++++++++ crates/raw/src/vendor/rw2.rs | 83 +----------------------- 2 files changed, 124 insertions(+), 80 deletions(-) diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 0d59fd6ed..e5218f390 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -111,6 +111,99 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 { } } +/// Output radii sampled to fit the inverse mapping. +const DISTORTION_SAMPLES: usize = 64; + +/// Least-squares radial warp from a camera's forward distortion map. +/// +/// `corrected` maps a source radius to the output radius it lands at; it must rise monotonically up to `t_max`, the +/// output radius of the image corners. Radii use the caller's unit; pass `WarpRectilinear`'s normalisation (distance +/// from the centre to the **farthest** corner = 1) so the result is directly a `WarpRectilinear` plane. The map is +/// scanned at 4096 points up to a source radius of `3 * t_max`: it must reach `t_max` within that range and rise at +/// every scan point (monotonicity is checked only there), otherwise `None`; a NaN at a scan point is rejected the +/// same way, other non-finite values are caught by the fit-error gate. The map is inverted by bisection and +/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1]: the returned +/// `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when the worst +/// fit error exceeds `max_fit_error`. +pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, max_fit_error: f64) -> Option<[f64; 4]> { + if !(t_max.is_finite() && t_max > 0.0) { + return None; + } + let mut top = None; + let mut prev = 0.0; + for i in 1..=4096 { + let r = 3.0 * t_max * i as f64 / 4096.0; + let v = corrected(r); + if v.is_nan() || v <= prev { + return None; + } + prev = v; + if v >= t_max { + top = Some(r); + break; + } + } + let top = top?; + let source = |t: f64| { + let (mut lo, mut hi) = (0.0, top); + for _ in 0..60 { + let m = 0.5 * (lo + hi); + if corrected(m) < t { lo = m } else { hi = m } + } + 0.5 * (lo + hi) + }; + let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) + .map(|i| { + let u = i as f64 / DISTORTION_SAMPLES as f64; + (u, source(u * t_max)) + }) + .collect(); + let basis = |u: f64| { + let u2 = u * u; + [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] + }; + let mut a = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + for &(u, v) in &samples { + let f = basis(u); + for (i, fi) in f.iter().enumerate() { + for (j, fj) in f.iter().enumerate() { + a[i][j] += fi * fj; + } + rhs[i] += fi * v; + } + } + let k = solve4(a, rhs)?; + let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>(); + let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); + (worst <= max_fit_error).then_some(k) +} + +/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for c in 0..4 { + let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; + if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { + return None; + } + a.swap(c, p); + b.swap(c, p); + for r in c + 1..4 { + let f = a[r][c] / a[c][c]; + for k in c..4 { + a[r][k] -= f * a[c][k]; + } + b[r] -= f * b[c]; + } + } + let mut x = [0.0; 4]; + for c in (0..4).rev() { + let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); + x[c] = (b[c] - s) / a[c][c]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + #[cfg(test)] mod tests { use super::*; @@ -160,4 +253,32 @@ mod tests { let w = white_from_data(&d, 14); assert!((13500.0..=13583.0).contains(&w), "{w}"); } + + #[test] + fn warp_from_forward_map_recovers_identity_and_rejects_folds() { + let k = warp_from_forward_map(&|r| r, 1.0, 1e-9).unwrap(); + assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9); + let barrel = |r: f64| r * (1.0 + 0.05 * r * r); // mild barrel: source < output radius + let t_max = 0.9; + let k = warp_from_forward_map(&barrel, t_max, 1e-3).unwrap(); + assert!(k[0] < t_max && k[0] > 0.8 && k[1] < 0.0); // source radius at u = 1 is below t_max + for i in 0..=20 { + let u = i as f64 / 20.0; + let s = u * (k[0] + u * u * (k[1] + u * u * (k[2] + u * u * k[3]))); + assert!((barrel(s) - u * t_max).abs() < 1e-5, "u {u}"); + } + assert!(warp_from_forward_map(&|r| if r < 0.5 { r } else { 0.5 - (r - 0.5) }, 1.0, 1e-3).is_none()); // fold, never reaches t_max + // a tiny dip (two scan points wide, 1e-4 deep) that still reaches t_max and fits well: only the monotonicity check rejects it + let dip = |r: f64| { + if r < 0.5 { + r + } else if r < 0.5015 { + 0.5 - 0.05 * (r - 0.5) + } else { + r - 0.001575 + } + }; + assert!(warp_from_forward_map(&dip, 1.0, 1e-2).is_none()); + assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none()); + } } diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..62a8f1130 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -145,8 +145,6 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ const DISTORTION_INFO: u16 = 0x0119; /// Fixed-point one of the distortion coefficients and zoom. const DISTORTION_ONE: f64 = 32768.0; -/// Output radii sampled to fit the inverse mapping. -const DISTORTION_SAMPLES: usize = 64; /// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4. const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3; /// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). @@ -650,87 +648,12 @@ fn distortion(ifd: &Ifd, active: Rect) -> Option<Opcode> { let r2 = r * r; r * (1.0 + r2 * (k2 + r2 * (k4 + r2 * k6))) }; - // the output corners show corrected radius `t_max`; the map must rise monotonically to it (a fold is no lens) - let t_max = half_diag * zoom / radius; - let mut top = None; - let mut prev = 0.0; - for i in 1..=4096 { - let r = 3.0 * t_max * i as f64 / 4096.0; - let v = corrected(r); - if v.is_nan() || v <= prev { - return None; - } - prev = v; - if v >= t_max { - top = Some(r); - break; - } - } - let top = top?; - let source = |t: f64| { - let (mut lo, mut hi) = (0.0, top); - for _ in 0..60 { - let m = 0.5 * (lo + hi); - if corrected(m) < t { lo = m } else { hi = m } - } - 0.5 * (lo + hi) - }; - // least-squares fit of source radius = u·(kr0 + kr1·u² + kr2·u⁴ + kr3·u⁶) over the output radii u ∈ [0, 1] - let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) - .map(|i| { - let u = i as f64 / DISTORTION_SAMPLES as f64; - (u, source(u * t_max) * radius / half_diag) - }) - .collect(); - let basis = |u: f64| { - let u2 = u * u; - [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] - }; - let mut a = [[0.0; 4]; 4]; - let mut rhs = [0.0; 4]; - for &(u, v) in &samples { - let f = basis(u); - for (i, fi) in f.iter().enumerate() { - for (j, fj) in f.iter().enumerate() { - a[i][j] += fi * fj; - } - rhs[i] += fi * v; - } - } - let k = solve4(a, rhs)?; - let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>(); - let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); - if worst.is_nan() || worst > DISTORTION_MAX_FIT_ERROR { - return None; - } + // work in DNG-normalised units (corner distance = 1), where the output corners sit at radius `zoom` + let scale = radius / half_diag; + let k = super::warp_from_forward_map(&|s| corrected(s / scale) * scale, zoom, DISTORTION_MAX_FIT_ERROR)?; Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] }) } -/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. -fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { - for c in 0..4 { - let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; - if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { - return None; - } - a.swap(c, p); - b.swap(c, p); - for r in c + 1..4 { - let f = a[r][c] / a[c][c]; - for k in c..4 { - a[r][k] -= f * a[c][k]; - } - b[r] -= f * b[c]; - } - } - let mut x = [0.0; 4]; - for c in (0..4).rev() { - let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); - x[c] = (b[c] - s) / a[c][c]; - } - x.iter().all(|v| v.is_finite()).then_some(x) -} - #[cfg(test)] mod tests { use super::*; From dfe87240b09331e09d42efdde176bc3a0daaedbc Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Tue, 13 Oct 2026 13:07:36 +0200 Subject: [PATCH 094/315] Nix: evaluate the flake in CI so a new git dependency can't break nix build / nix develop again Since UI.9 (9bfabc98) Cargo.lock has a git source, craft-ui from storytold/craft-libs. importCargoLock needs a hash for every git dependency, so evaluation stopped with "No hash was found while vendoring the git dependency craft-ui-0.1.0" and both `nix build` and `nix develop` failed on NixOS for two days (#1314 fixed it with cargoLock.allowBuiltinFetchGit). The flake is not built in CI, so nothing noticed: the new Nix workflow runs `nix flake check --no-build --all-systems` whenever flake.nix, flake.lock, nix/ or Cargo.lock change. Co-Authored-By: Claude <noreply@anthropic.com> --- .github/workflows/nix.yml | 28 ++++++++++++++++++++++++++++ AGENTS.md | 4 ++-- CLAUDE.md | 4 ++-- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..d992f7433 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,28 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: nix flake check --no-build --all-systems diff --git a/AGENTS.md b/AGENTS.md index 8cd199ff6..297682755 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,8 +131,8 @@ of it. Full standard: `../craftrules/standards/never-crash.md` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the -desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not -in CI: it may lag behind the workspace; see README → Quick start. +desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only +evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 4a736f12a..6099b1d2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,5 +138,5 @@ of it. Full standard: `../craftrules/standards/never-crash.md` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the -desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not -in CI: it may lag behind the workspace; see README → Quick start. +desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only +evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. From 6c5b4d5701334e7b1db103eb355c88ac71bdf9e3 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Mon, 12 Oct 2026 11:46:03 +0200 Subject: [PATCH 095/315] =?UTF-8?q?UI:=20find=20gsettings=20on=20PATH=20fo?= =?UTF-8?q?r=20Appearance=20Mode=20=E2=96=B8=20Auto=20(NixOS=20has=20no=20?= =?UTF-8?q?/usr/bin/gsettings)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the XDG settings portal, the start-up fallback asks gsettings for the GNOME colour scheme, but only looked in /usr/bin, /bin and /usr/local/bin. NixOS keeps it in /run/current-system/sw/bin or the per-user profile, so Auto never followed the desktop there. It now tries the absolute PATH entries first, then those three; relative entries are still skipped, so nothing runs from the working folder. Co-Authored-By: Claude <noreply@anthropic.com> --- apps/lightcraft/src/appearance.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs index 74333a301..223dbc560 100644 --- a/apps/lightcraft/src/appearance.rs +++ b/apps/lightcraft/src/appearance.rs @@ -51,7 +51,7 @@ fn decode(code: u32) -> Option<egui::Theme> { /// absolute path, and is never repeated. #[cfg(target_os = "linux")] fn gtk_theme() -> Option<egui::Theme> { - let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let exe = gsettings_exe(std::env::var_os("PATH").as_deref())?; let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; if !output.status.success() { return None; @@ -59,6 +59,15 @@ fn gtk_theme() -> Option<egui::Theme> { parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) } +/// `gsettings` by absolute path: the absolute `PATH` entries first (NixOS has no /usr/bin/gsettings), +/// then the usual places for a sparse `PATH`. Relative entries are skipped, so nothing runs from +/// the working folder. +#[cfg(target_os = "linux")] +fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> { + let listed = path.map(std::env::split_paths).into_iter().flatten().filter(|dir| dir.is_absolute()); + listed.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|dir| dir.join("gsettings")).find(|exe| exe.is_file()) +} + #[cfg(target_os = "linux")] fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> { match s.trim().trim_matches('\'') { @@ -174,6 +183,24 @@ mod tests { assert_eq!(calls.load(Ordering::Relaxed), after_settle); } + /// NixOS keeps `gsettings` in /run/current-system/sw/bin or the per-user profile: it is found + /// on `PATH`, but never through a relative entry, even one that leads to a real file. + #[cfg(target_os = "linux")] + #[test] + fn gsettings_is_found_on_absolute_path_entries_only() { + let dir = std::env::temp_dir().join(format!("lightcraft-gsettings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("gsettings"), "").unwrap(); + let path = std::env::join_paths([std::path::Path::new("relative/bin"), &dir]).unwrap(); + assert_eq!(super::gsettings_exe(Some(&path)), Some(dir.join("gsettings"))); + let cwd = std::env::current_dir().unwrap(); + let relative = std::path::PathBuf::from("../".repeat(cwd.components().count() - 1)).join(dir.strip_prefix("/").unwrap()); + assert!(relative.join("gsettings").is_file()); + let found = super::gsettings_exe(Some(relative.as_os_str())); + assert!(found.as_ref().is_none_or(|p| p.is_absolute()), "{found:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[cfg(target_os = "linux")] #[test] fn portal_values_and_gsettings_output_decode() { From 85cc4273be9d62ba486a2d643b668660f06ee2bf Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Sat, 10 Oct 2026 22:29:41 +0200 Subject: [PATCH 096/315] LR-EDIT-OPTICS-PROFILE: Sony ARW distortion correction from the embedded table New vendor::arw_lens_rm6 reads the raw IFD's lens tables (0x7037 distortion, 0x7032 vignetting, 0x7035 lateral CA, their flags) and turns the distortion table into a one-plane WarpRectilinear in OpcodeList3, with the conventions measured on 54 ILCE-7RM6 photos: knots at i/15 of the full-frame crop half diagonal, S(r) = r (1 + v(r)/15000), frame-filling zoom, centred on the default crop. Only the ILCE-7RM6 is enabled; vignetting is read, not applied. The radial least-squares fit is split out as vendor::fit_radial_warp (RW2 results unchanged); its error gate can be limited to the radii the crop shows, so a padded active area (lossless files) doesn't refuse a fit over pixels that are never rendered. It sits next to main's vendor::arw_lens (#498), which holds the ILCE-7RM4A conventions measured against Sony's exports (unit 16384, table indexed at the output radius); the two are selected by camera model (disjoint allowlists). arw::decode chains this module's opcodes after arw_lens::distortion, with the same active area and the same crop (the DNG-style default crop when the file has one, which on the ILCE-7RM6 equals Sony's crop tags), and like it only for CFA data (not the downsized linear-RGB lossless sizes, where the knot spacing in full-frame pixels would not hold). The corpus test checks the conversion on the two lossless RawDB files (CC0). The APS-C one is pinned in xtask RAW_SAMPLES with its sha256; the full-frame one is not (at most two pins per PR, and #625 pins two Compressed ARW 6.0 files), so its case runs only where the file is present. RENDER_CACHE_VERSION 62 -> 63. Co-Authored-By: Claude <noreply@anthropic.com> --- crates/engine/src/media.rs | 2 +- crates/raw/src/opcodes.rs | 2 +- crates/raw/src/vendor/arw.rs | 43 ++- crates/raw/src/vendor/arw_lens_rm6.rs | 371 ++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 40 ++- crates/raw/tests/corpus.rs | 63 +++++ xtask/src/main.rs | 7 + 7 files changed, 516 insertions(+), 12 deletions(-) create mode 100644 crates/raw/src/vendor/arw_lens_rm6.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index cf17c3264..5755a2a43 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 62; +pub const RENDER_CACHE_VERSION: u64 = 63; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 1d3a2c546..483ac5c4a 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -454,7 +454,7 @@ fn gain_at(points_v: u32, points_h: u32, spacing: [f64; 2], origin: [f64; 2], ma } /// Radius normaliser: distance from the centre to the farthest image corner. -fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { +pub(crate) fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9) } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..5a23218c0 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -2,7 +2,8 @@ //! //! Sources: H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (IS&T Electronic Imaging 2016) for //! the ARW2 ("cRAW") scheme, and the ExifTool Sony tag-name documentation for the meaning of the raw-IFD tags -//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop). +//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop, `0x7031`–`0x7037` +//! lens-correction tables: see [`super::arw_lens`] for the ILCE-7RM4A and [`super::arw_lens_rm6`] for the ILCE-7RM6). //! //! ARW2, as described in the paper: //! 1. Sensor values are tone-mapped to 11-bit codes by a five-segment piecewise-linear curve whose step doubles at @@ -770,7 +771,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so - // it stays on the optical centre whichever crop frames the image. + // it stays on the optical centre whichever crop frames the image. The ILCE-7RM6's tables follow other + // conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); the two allowlists are + // disjoint, so at most one of them produces an opcode. opcodes: OpcodeLists { // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. // Both the image framing and the table geometry must stay within the validated 3:2 scope. @@ -778,7 +781,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { Vec::new() } else { let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); - super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), geometry).into_iter().collect() + let active = Rect::new(0, 0, w, h); + super::arw_lens::distortion(&model, raw, active, geometry) + .into_iter() + .chain(super::arw_lens_rm6::lens_opcodes(raw, &model, active, geometry)) + .collect() }, ..Default::default() }, @@ -867,6 +874,36 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() } + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode; + /// another body's table doesn't (see [`super::super::arw_lens_rm6`]). + #[test] + fn lens_tables_reach_opcode_list3() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let file = |model: &str| { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + // 3:2, the aspect the opcode is limited to + raw.set(t::IMAGE_WIDTH, Value::Long(vec![96])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![64])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + raw.set(super::super::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table)); + raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] }); + TiffWriter::default().write(&[raw]).unwrap() + }; + for mode in [Mode::Header, Mode::Full] { + let list3 = |model| decode(&file(model), mode).unwrap().opcodes.list3; + let ops = list3("ILCE-7RM6"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?}: {ops:?}"); + assert!(list3("ILCE-7M4").is_empty(), "{mode:?}"); + } + } + #[test] fn strip_larger_or_smaller_than_one_byte_per_pixel_is_the_packed_variant() { for strip in [32 * 4 + 8, 32 * 4 * 5 / 4 - 1, 32 * 4 - 1] { diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs new file mode 100644 index 000000000..ba9aaaa9d --- /dev/null +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -0,0 +1,371 @@ +//! Sony ARW lens corrections from the tables in the raw IFD, as the equivalent DNG `OpcodeList3` opcodes, so they +//! reach the same "profile corrections" (`optics.lens_profile`) as a DNG's embedded warp. +//! +//! These are the ILCE-7RM6 conventions, measured against the camera JPEG. [`super::arw_lens`] holds the ILCE-7RM4A +//! conventions, measured against Sony's Imaging Edge exports (unit 16384, the table indexed at the output radius). +//! Each is selected by camera model; no single unit fits every lens under either. See both docs: +//! docs/sony-ilce7rm6-lens-corrections.md (→ Two conventions) and docs/sony-lens-corrections.md. +//! +//! Sony records three count-prefixed signed 16-bit tables in the raw IFD, each with an in-camera on/off tag (ExifTool +//! Sony tag names): distortion [`DISTORTION_PARAMS`] (flag [`DISTORTION_FLAG`]), vignetting [`VIGNETTING_PARAMS`] +//! ([`VIGNETTING_FLAG`]) and lateral chromatic aberration [`CA_PARAMS`] ([`CA_FLAG`]). The first value is the knot +//! count: 16 / 16 / 32 in full-frame files of the ILCE-7RM6, 11 / 11 / 22 in its APS-C crop mode, the rest padding; +//! the CA table holds red's knots, then blue's. +//! +//! Their meaning was measured by registering blocks of the camera JPEG in each file on LightCraft's uncorrected render +//! (and raw data, for CA) over 54 ILCE-7RM6 photos with four lenses, 41 of them shot with in-camera distortion +//! correction on; nothing else (clean room). The conventions, with their worst residuals at a 1440 px long edge: +//! - knot `i` sits at sensor radius `i · s` from the default-crop centre, `s` = the body's full-frame default-crop +//! half diagonal / 15 (the same pixel spacing in APS-C mode: the knots are fixed on the sensor); linear in between, +//! the last segment extended beyond the last knot. +//! - distortion: a corrected radius `r` (px) reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive: +//! pincushion), and the camera then zooms (output radius = `z` · corrected radius) so the output fills the default +//! crop and reads only inside it: the largest `S(ρ/z)/ρ` over the crop border radii `ρ` is 1. 0.596 px over the 34 +//! measurable corrected photos (budget 0.75 px; corners and edge midpoints 0.570 px). +//! - `WarpRectilinear`'s 4-term odd polynomial fits that map within 2.35·10⁻⁴ of the DNG radius (0.220 px) on every +//! photo, at its 65 fit samples. A table it fits worse than 0.25 px at the samples the crop shows is refused (the +//! photo stays uncorrected rather than wrongly warped). Between samples the error is somewhat larger: 0.071 against +//! 0.049 px for the full-frame 16–35 mm example in the tests. +//! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median +//! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. +//! - lateral CA: red and blue relative to green on the same knots (unit about 1/3.36·10⁶; 0.399 px, borderline); +//! not applied yet. +//! +//! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the +//! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement +//! on its photos shot with distortion correction on and listing its full-frame default crop. The method and the +//! numbers: docs/sony-ilce7rm6-lens-corrections.md. + +use super::fit_radial_warp; +use crate::opcodes::max_radius; +use crate::{Opcode, Rect}; +use lightcraft_tiff::Ifd; +use std::ops::RangeInclusive; + +pub(crate) const DISTORTION_PARAMS: u16 = 0x7037; +pub(crate) const VIGNETTING_PARAMS: u16 = 0x7032; +pub(crate) const CA_PARAMS: u16 = 0x7035; +pub(crate) const DISTORTION_FLAG: u16 = 0x7036; +pub(crate) const VIGNETTING_FLAG: u16 = 0x7031; +pub(crate) const CA_FLAG: u16 = 0x7034; + +/// The bodies whose tables are verified (Exif `Model`), with their full-frame default crop (width, height) in px. +const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))]; +/// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7. +const DISTORTION_UNIT: f64 = 15000.0; +/// Crop border radii scanned by the frame-filling zoom. +const ZOOM_SCAN: usize = 4096; +/// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge. +const FIT_ERROR_PX_AT_1440: f64 = 0.25; + +/// A file's lens tables: the first `count` values of each (CA: red, blue); `None` when absent or malformed. +/// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera +/// applies no vignetting correction to its JPEGs, and the flags don't decide anything). +#[derive(Debug)] +// only the distortion table becomes an opcode so far +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) struct LensTables { + pub distortion: Option<Vec<f64>>, + pub vignetting: Option<Vec<f64>>, + pub ca: Option<(Vec<f64>, Vec<f64>)>, + /// In-camera on/off of distortion, vignetting and CA correction, as recorded. + pub flags: [Option<i64>; 3], +} + +/// The values of a count-prefixed integer table whose count is in `counts` and fits the array. +fn table(raw: &Ifd, tag: u16, counts: RangeInclusive<usize>) -> Option<Vec<f64>> { + let v = raw.value(tag)?; + let count = usize::try_from(v.get_i64(0)?).ok().filter(|n| counts.contains(n) && *n < v.count())?; + (1..=count).map(|i| v.get_i64(i).map(|x| x as f64)).collect() +} + +/// The lens tables of the raw IFD `raw`. +pub(crate) fn read_tables(raw: &Ifd) -> LensTables { + let ca = table(raw, CA_PARAMS, 4..=32).filter(|v| v.len().is_multiple_of(2)).map(|v| { + let (red, blue) = v.split_at(v.len() / 2); + (red.to_vec(), blue.to_vec()) + }); + LensTables { + distortion: table(raw, DISTORTION_PARAMS, 2..=16), + vignetting: table(raw, VIGNETTING_PARAMS, 2..=16), + ca, + flags: [raw.i64(DISTORTION_FLAG), raw.i64(VIGNETTING_FLAG), raw.i64(CA_FLAG)], + } +} + +/// The knot spacing (px) of a verified body: its full-frame default-crop half diagonal / 15. +fn knot_spacing(model: &str) -> Option<f64> { + let model = model.trim(); + FULL_FRAME_CROP.iter().find(|(m, _)| *m == model).map(|&(_, (w, h))| w.hypot(h) / 30.0) +} + +/// The knot table at radius `r` px: knot `i` at `i · spacing`, linear in between, the last segment extended beyond +/// the last knot. NaN with fewer than two knots. +fn interpolate(knots: &[f64], spacing: f64, r: f64) -> f64 { + let t = r / spacing; + // `as` saturates, so a huge `t` lands on the last segment; NaN fails `t > 0` + let i = if t > 0.0 { (t as usize).min(knots.len().saturating_sub(2)) } else { 0 }; + match (knots.get(i), knots.get(i + 1)) { + (Some(a), Some(b)) => a + (b - a) * (t - i as f64), + _ => f64::NAN, + } +} + +/// The camera's frame-filling zoom `z` for the source radius map `source` and a crop of half size `x × y`: the +/// largest `source(ρ/z)/ρ` over the border radii `ρ` in [min(x, y), hypot(x, y)] is 1, so the output crop reads +/// only inside the source crop and touches it (at the corner for pincushion, the short-edge midpoint for barrel). +/// Bisection over [0.5, 2]; `None` when that range holds no solution or a ratio is not finite. +fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> { + let (near, far) = (x.min(y), x.hypot(y)); + let overshoots = |z: f64| -> Option<bool> { + let mut worst = f64::NEG_INFINITY; + for k in 0..=ZOOM_SCAN { + let rb = near + k as f64 * (far - near) / ZOOM_SCAN as f64; + let ratio = source(rb / z) / rb; + if !ratio.is_finite() { + return None; + } + worst = worst.max(ratio); + } + Some(worst > 1.0) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if !overshoots(lo)? || overshoots(hi)? { + return None; + } + for _ in 0..100 { + let z = 0.5 * (lo + hi); + if overshoots(z)? { lo = z } else { hi = z } + } + Some(0.5 * (lo + hi)) +} + +/// The distortion table as a one-plane `WarpRectilinear` relative to the active area `active` (centred on the +/// default crop `crop`, as the camera corrects), with knots `knot_spacing` px apart. `None` when the table is absent +/// or all zero, the zoom or the fit fails, or the fit is worse than 0.25 px at a 1440 px long edge at one of the fit +/// samples within the crop (a padded active area, as in lossless files, reaches radii that are never shown). +pub(crate) fn distortion_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option<Opcode> { + let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; + let (aw, ah) = (active.width as f64, active.height as f64); + if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / DISTORTION_UNIT); + let z = fill_zoom(&source, cw / 2.0, ch / 2.0)?; + // DNG's normalisation: pixel-index units, radius 1 = the farthest active-area corner + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let m = max_radius(cx, cy, aw - 1.0, ah - 1.0); + let max_fit_error = FIT_ERROR_PX_AT_1440 * (cw.max(ch) / 1440.0) / m; + let shown = (cw / 2.0).hypot(ch / 2.0) / m; + let [k0, k1, k2, k3] = fit_radial_warp(&|u| source(u * m / z) / m, max_fit_error, shown)?; + Some(Opcode::WarpRectilinear { planes: vec![[k0, k1, k2, k3, 0.0, 0.0]], center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) +} + +/// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose +/// tables are not verified. +pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec<Opcode> { + let Some(spacing) = knot_spacing(model) else { return Vec::new() }; + distortion_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, Value}; + + /// The FF 16–35 mm example (corpus `arw-sony-ilce7rm6-ff-land-lossless.arw`, docs/sony-ilce7rm6-lens-corrections.md). + const FF_DISTORTION: [i16; 17] = [16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + const FF_VIGNETTING: [i16; 17] = [16, 0, 80, 336, 672, 1120, 1648, 2240, 2880, 3600, 4336, 5104, 5872, 6640, 7392, 8160, 8976]; + const FF_CA: [i16; 33] = [ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42, + 56, 60, 54, 32, + ]; + /// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding. + const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583]; + const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0]; + const APSC_CA: [i16; 33] = [ + 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + /// ARW6 geometry (the decoder's raw image size and default crop). + const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; + const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; + const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 }; + const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 }; + + fn knots() -> f64 { + knot_spacing("ILCE-7RM6").unwrap() + } + + fn ifd(tags: Vec<(u16, Value)>) -> Ifd { + let mut entries: Vec<Entry> = tags.into_iter().map(|(tag, value)| Entry { tag, value, offset: 0 }).collect(); + entries.sort_by_key(|e| e.tag); + Ifd { entries, ..Default::default() } + } + + fn sony_lens_ifd(distortion: &[i16], vignetting: &[i16], ca: &[i16], flags: [u16; 3]) -> Ifd { + ifd(vec![ + (DISTORTION_PARAMS, Value::SShort(distortion.to_vec())), + (VIGNETTING_PARAMS, Value::SShort(vignetting.to_vec())), + (CA_PARAMS, Value::SShort(ca.to_vec())), + (DISTORTION_FLAG, Value::Short(vec![flags[0]])), + (VIGNETTING_FLAG, Value::Short(vec![flags[1]])), + (CA_FLAG, Value::Short(vec![flags[2]])), + ]) + } + + fn tables_with_distortion(v: &[f64]) -> LensTables { + LensTables { distortion: Some(v.to_vec()), vignetting: None, ca: None, flags: [None; 3] } + } + + fn values(table: &[i16]) -> Vec<f64> { + table[1..=table[0] as usize].iter().map(|&v| v as f64).collect() + } + + /// The warp's one plane and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md). + fn assert_warp(op: Option<Opcode>, center: [f64; 2], k: [f64; 4]) { + let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") }; + assert_eq!(planes.len(), 1); + assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}"); + let p = planes[0]; + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + } + assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); + } + + #[test] + fn reads_the_three_tables_and_flags() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[0], d[15]), (16, 0.0, 479.0)); + let v = t.vignetting.unwrap(); + assert_eq!((v.len(), v[1], v[15]), (16, 80.0, 8976.0)); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[0], red[15]), (16, 242.0, 4.0)); + assert_eq!((blue.len(), blue[0], blue[15]), (16, -276.0, 32.0)); + assert_eq!(t.flags, [Some(0), Some(2), Some(1)]); + } + + #[test] + fn apsc_tables_use_only_the_first_count_values() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[10]), (11, -583.0)); + assert_eq!(t.vignetting.unwrap().len(), 11); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[10]), (11, 470.0)); + assert_eq!((blue.len(), blue[0], blue[10]), (11, -432.0, 34.0)); + } + + #[test] + fn missing_or_malformed_tables_yield_no_opcode() { + let mut short = FF_DISTORTION.to_vec(); + short.pop(); + let mut count_17 = FF_DISTORTION.to_vec(); + count_17[0] = 17; + count_17.push(500); + let mut too_large = FF_DISTORTION.to_vec(); + too_large[0] = 600; + for (what, table) in [ + ("count 15 with 3 values", vec![15, 0, 2, 7]), + ("count 17", count_17), + ("empty", vec![]), + ("count 16 with 15 values", short), + ("count 1", vec![1, 5]), + ("count 0", vec![0, 5, 6]), + ("negative count", vec![-16, 0, 2, 7]), + ("count larger than the array", too_large), + ] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); + let t = read_tables(&ifd); + assert!(t.distortion.is_none(), "{what}"); + assert!(distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); + } + // not an integer array, or absent + for value in [Value::Ascii("16 0 2 7".into()), Value::Double(vec![2.0, 0.0, 1.0])] { + assert!(read_tables(&ifd(vec![(DISTORTION_PARAMS, value)])).distortion.is_none()); + } + let none = read_tables(&ifd(vec![])); + assert!(none.distortion.is_none() && none.vignetting.is_none() && none.ca.is_none() && none.flags == [None; 3]); + // the CA table needs an even count of 4..=32 + for ca in [vec![3, 1, 2, 3], vec![34; 35], vec![2, 1, 2]] { + assert!(read_tables(&ifd(vec![(CA_PARAMS, Value::SShort(ca))])).ca.is_none()); + } + } + + #[test] + fn hostile_tables_are_rejected() { + let folded = [0.0, 2000.0, 1000.0, 3000.0, 2000.0, 4000.0, 3000.0, 5000.0, 4000.0, 6000.0, 5000.0, 7000.0, 6000.0, 8000.0, 7000.0, 9000.0]; + for (what, table) in [ + ("huge", vec![(i64::MAX / 4) as f64; 16]), + ("most negative", vec![i64::MIN as f64; 16]), + ("zig-zag", folded.to_vec()), + ("all zero", vec![0.0; 16]), + ("one knot", vec![100.0]), + ("no knots", vec![]), + ("not finite", vec![0.0, f64::NAN, 20.0]), + ("infinite", vec![0.0, f64::INFINITY, 20.0]), + ] { + assert!(distortion_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + } + // the same through the IFD, as 64-bit values + for v in [i64::MAX / 4, i64::MIN, i64::MAX] { + let mut table = vec![v; 17]; + table[0] = 16; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SLong8(table))]); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{v}"); + } + // degenerate geometry + let ff = tables_with_distortion(&values(&FF_DISTORTION)); + for (what, knots, active, crop) in [ + ("empty crop", knots(), FF_ACTIVE, Rect::new(12, 8, 0, 6656)), + ("1 × 1 active area", knots(), Rect::new(0, 0, 1, 1), Rect::new(0, 0, 1, 1)), + ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), + ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), + ] { + assert!(distortion_opcode(&ff, knots, active, crop).is_none(), "{what}"); + } + } + + #[test] + fn ff_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&FF_DISTORTION)); + assert_warp( + distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP), + [0.4996005991, 0.5], + [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548], + ); + } + + #[test] + fn apsc_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&APSC_DISTORTION)); + let op = distortion_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + assert_warp(op, [0.4975724473, 0.5], [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]); + } + + /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well + /// beyond the crop's (u 0.925): the gate looks only at the radii the crop shows. The FF example × 1.5 fits within + /// 0.12 px at 1440 at the samples there, 0.27 px at all 65. + #[test] + fn padded_active_area_gates_only_the_displayed_radii() { + let strong: Vec<f64> = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); + let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + let op = distortion_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); + } + + #[test] + fn unknown_model_gets_no_opcodes() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]); + assert!(lens_opcodes(&ifd, "ILCE-7M4", FF_ACTIVE, FF_CROP).is_empty()); + assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); + // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied + let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); + assert_eq!(ops, distortion_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::<Vec<_>>()); + assert_eq!(ops.len(), 1); + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index e5218f390..5512a1169 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -6,6 +6,7 @@ mod arw6_curve; #[cfg(test)] pub(crate) mod arw6_testenc; mod arw_lens; +pub(crate) mod arw_lens_rm6; pub mod cr2; pub mod cr3; pub mod crx; @@ -111,7 +112,7 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 { } } -/// Output radii sampled to fit the inverse mapping. +/// Output radii sampled to fit a radial warp. const DISTORTION_SAMPLES: usize = 64; /// Least-squares radial warp from a camera's forward distortion map. @@ -121,10 +122,10 @@ const DISTORTION_SAMPLES: usize = 64; /// from the centre to the **farthest** corner = 1) so the result is directly a `WarpRectilinear` plane. The map is /// scanned at 4096 points up to a source radius of `3 * t_max`: it must reach `t_max` within that range and rise at /// every scan point (monotonicity is checked only there), otherwise `None`; a NaN at a scan point is rejected the -/// same way, other non-finite values are caught by the fit-error gate. The map is inverted by bisection and -/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1]: the returned -/// `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when the worst -/// fit error exceeds `max_fit_error`. +/// same way, other non-finite values by the fit. The map is inverted by bisection and +/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1] ([`fit_radial_warp`]): +/// the returned `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when +/// the worst fit error exceeds `max_fit_error`. pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, max_fit_error: f64) -> Option<[f64; 4]> { if !(t_max.is_finite() && t_max > 0.0) { return None; @@ -152,12 +153,23 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, } 0.5 * (lo + hi) }; + fit_radial_warp(&|u| source(u * t_max), max_fit_error, 1.0) +} + +/// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64 +/// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when +/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples with +/// `u ≤ gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. +pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f64, gate_until: f64) -> Option<[f64; 4]> { let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) .map(|i| { let u = i as f64 / DISTORTION_SAMPLES as f64; - (u, source(u * t_max)) + (u, source_of_u(u)) }) .collect(); + if samples.iter().any(|&(_, v)| !v.is_finite()) { + return None; + } let basis = |u: f64| { let u2 = u * u; [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] @@ -175,7 +187,7 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, } let k = solve4(a, rhs)?; let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>(); - let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); + let worst = samples.iter().filter(|&&(u, _)| u <= gate_until).map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); (worst <= max_fit_error).then_some(k) } @@ -281,4 +293,18 @@ mod tests { assert!(warp_from_forward_map(&dip, 1.0, 1e-2).is_none()); assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none()); } + + #[test] + fn fit_radial_warp_identity_and_gate() { + let k = fit_radial_warp(&|u| u, 1e-9, 1.0).unwrap(); + assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9, "{k:?}"); + assert!(fit_radial_warp(&|u| if u > 0.5 { f64::NAN } else { u }, 1.0, 1.0).is_none(), "a NaN sample"); + assert!(fit_radial_warp(&|u| if u > 0.5 { f64::INFINITY } else { u }, 1.0, 1.0).is_none(), "an infinite sample"); + assert!(fit_radial_warp(&|u: f64| u.sqrt(), 1.0, 1.0).is_some()); + assert!(fit_radial_warp(&|u: f64| u.sqrt(), 0.0, 1.0).is_none(), "not a polynomial: the gate rejects it"); + // a step at u = 1 alone: worst fit error 0.057 over all samples, 0.006 over u <= 0.9 (the fit is the same) + let step = |u: f64| if u > 0.99 { 1.1 } else { u }; + assert!(fit_radial_warp(&step, 0.01, 1.0).is_none()); + assert_eq!(fit_radial_warp(&step, 0.01, 0.9), fit_radial_warp(&step, 1.0, 1.0), "gated only where shown"); + } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..60c81fd1f 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,6 +626,69 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } +/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a `WarpRectilinear` in `OpcodeList3` +/// (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 ILCE-7RM6 photos, evaluated in f64 for each +/// file's decoded active area (the padded lossless tiles) and default crop. Both files carry distortion correction +/// off in camera; the table is applied anyway. +#[test] +fn corpus_sony_lens_tables_become_opcodes() { + let dir = corpus_root().join("raw"); + // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) + let cases = [ + // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked + // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) + ( + "arw-sony-ilce7rm6-ff-land-lossless.arw", + (10240, 7168), + (12, 8, 9984, 6656), + [0.488670768629749, 0.4653969582810102], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + ), + // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + (6656, 4608), + (16, 10, 6528, 4352), + [0.49278737791134486, 0.4743868026915563], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + ), + ]; + let mut seen = 0; + for (name, (aw, ah), crop, center, k) in cases { + let bytes = match std::fs::read(dir.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {name} absent"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let (a, c) = (img.active_area, img.crop); + eprintln!("{name}: active area {a:?}, crop {c:?}"); + assert_eq!((a.x, a.y, a.width, a.height), (0, 0, aw, ah), "{name}: active area"); + assert_eq!((c.x, c.y, c.width, c.height), crop, "{name}: default crop"); + assert_eq!(probe_info(&bytes).unwrap().opcodes, img.opcodes, "{name}: the headers-only probe reads the same correction"); + let warps: Vec<_> = img + .opcodes + .list3 + .iter() + .filter_map(|op| match op { + lightcraft_raw::Opcode::WarpRectilinear { planes, center } => Some((planes, center)), + _ => None, + }) + .collect(); + let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; + assert_eq!(planes.len(), 1, "{name}: one plane (G)"); + assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); + for (i, want) in k.iter().enumerate() { + assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); + } + seen += 1; + } + eprintln!("Sony lens tables checked on {seen} files"); +} + /// The embedded JPEG as linear RGB, reduced to `gw × gh` cells. fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> { use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..28fb3a38b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -448,6 +448,13 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/DSLR-A900/_DSC7969.ARW", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", ), + // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The + // full-frame lossless file is not pinned (at most two pins per PR; #625 pins two Compressed ARW 6.0 files). + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW", + "7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc", + ), // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples ( "cr2-canon-40d.cr2", From b677fbac0a0f2bb0f3321b0440d0ce5bf375c062 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Sat, 10 Oct 2026 23:14:08 +0200 Subject: [PATCH 097/315] LR-EDIT-OPTICS-PROFILE: Sony ARW lateral CA correction from the embedded table vendor::arw_lens_rm6::warp_opcode (was distortion_opcode) turns the CA table (0x7035: red half, blue half, on the distortion knots) into the R and B planes of the WarpRectilinear: a colour's source radius is g (1 + c(g) / 3.36e6) at the green source radius g. The unit is provisional (borderline measurement, about +-30 % scene bias). Each colour plane goes through the same fit gate as G, and a table with a value beyond 2e-3 relative (about 12 px at the full-frame corner; the 54 photos' tables stay below 5.1e-4, i.e. 1724) is refused; without a CA table, or when it is refused, the warp keeps its single G plane, and CA alone is never applied. fit_radial_warp now rounds its gate up to the next sample, capped at 1 (a NaN gates every sample), so the band between the last sample inside the crop and the crop corner is checked too (RW2 passes 1.0: unchanged). Co-Authored-By: Claude <noreply@anthropic.com> --- crates/raw/src/vendor/arw.rs | 23 ++-- crates/raw/src/vendor/arw_lens_rm6.rs | 167 +++++++++++++++++++++----- crates/raw/src/vendor/mod.rs | 15 ++- crates/raw/tests/corpus.rs | 30 +++-- 4 files changed, 186 insertions(+), 49 deletions(-) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 5a23218c0..d6715d49e 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -874,12 +874,12 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() } - /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode; - /// another body's table doesn't (see [`super::super::arw_lens_rm6`]). + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as + /// one plane, or three with the CA table; another body's tables don't (see [`super::super::arw_lens_rm6`]). #[test] fn lens_tables_reach_opcode_list3() { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; - let file = |model: &str| { + let file = |model: &str, ca: bool| { let mut raw = IfdBuilder::new(); raw.set(t::MAKE, Value::Ascii("SONY".into())); raw.set(t::MODEL, Value::Ascii(model.into())); @@ -893,14 +893,23 @@ mod tests { raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; raw.set(super::super::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table)); + if ca { + let table = vec![ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, + -6, 20, 42, 56, 60, 54, 32, + ]; + raw.set(super::super::arw_lens_rm6::CA_PARAMS, Value::SShort(table)); + } raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] }); TiffWriter::default().write(&[raw]).unwrap() }; for mode in [Mode::Header, Mode::Full] { - let list3 = |model| decode(&file(model), mode).unwrap().opcodes.list3; - let ops = list3("ILCE-7RM6"); - assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?}: {ops:?}"); - assert!(list3("ILCE-7M4").is_empty(), "{mode:?}"); + for (ca, n) in [(false, 1), (true, 3)] { + let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3; + let ops = list3("ILCE-7RM6"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == n), "{mode:?} {ca}: {ops:?}"); + assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); + } } } diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs index ba9aaaa9d..37d76fdd6 100644 --- a/crates/raw/src/vendor/arw_lens_rm6.rs +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -28,8 +28,12 @@ //! 0.049 px for the full-frame 16–35 mm example in the tests. //! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median //! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. -//! - lateral CA: red and blue relative to green on the same knots (unit about 1/3.36·10⁶; 0.399 px, borderline); -//! not applied yet. +//! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` +//! the red (blue) half on the same knots, as the warp's R and B planes. The unit is **provisional**: the +//! measurement is borderline (0.399 px worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ +//! to 7.2·10⁶, about ±30 % scene bias), but it cuts the rms CA over 46 photos from 0.079 to 0.052 px. When a CA +//! value is implausible (beyond [`CA_MAX_RELATIVE`]) or a colour plane fails the fit gate above, both colour +//! planes are dropped and G alone is applied to all three channels. //! //! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the //! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement @@ -53,6 +57,12 @@ pub(crate) const CA_FLAG: u16 = 0x7034; const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))]; /// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7. const DISTORTION_UNIT: f64 = 15000.0; +/// The CA table's value of 1 (relative to the green source radius). Provisional: the median best unit of the 20 +/// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). +const CA_UNIT: f64 = 3.36e6; +/// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame +/// corner; the measured tables stay below 790 / 3.36·10⁶ ≈ 2.4·10⁻⁴. A table beyond it is refused. +const CA_MAX_RELATIVE: f64 = 2e-3; /// Crop border radii scanned by the frame-filling zoom. const ZOOM_SCAN: usize = 4096; /// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge. @@ -62,7 +72,7 @@ const FIT_ERROR_PX_AT_1440: f64 = 0.25; /// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera /// applies no vignetting correction to its JPEGs, and the flags don't decide anything). #[derive(Debug)] -// only the distortion table becomes an opcode so far +// vignetting and the flags never become an opcode #[cfg_attr(not(test), allow(dead_code))] pub(crate) struct LensTables { pub distortion: Option<Vec<f64>>, @@ -140,11 +150,13 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> { Some(0.5 * (lo + hi)) } -/// The distortion table as a one-plane `WarpRectilinear` relative to the active area `active` (centred on the -/// default crop `crop`, as the camera corrects), with knots `knot_spacing` px apart. `None` when the table is absent -/// or all zero, the zoom or the fit fails, or the fit is worse than 0.25 px at a 1440 px long edge at one of the fit -/// samples within the crop (a padded active area, as in lossless files, reaches radii that are never shown). -pub(crate) fn distortion_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option<Opcode> { +/// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B, or G alone (applied to +/// all three) when the CA table is absent or a colour plane's fit fails. `None` when the distortion table is absent or +/// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is +/// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in +/// lossless files, reaches radii that are never shown). +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option<Opcode> { let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; let (aw, ah) = (active.width as f64, active.height as f64); if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { @@ -158,15 +170,32 @@ pub(crate) fn distortion_opcode(t: &LensTables, knot_spacing: f64, active: Rect, let m = max_radius(cx, cy, aw - 1.0, ah - 1.0); let max_fit_error = FIT_ERROR_PX_AT_1440 * (cw.max(ch) / 1440.0) / m; let shown = (cw / 2.0).hypot(ch / 2.0) / m; - let [k0, k1, k2, k3] = fit_radial_warp(&|u| source(u * m / z) / m, max_fit_error, shown)?; - Some(Opcode::WarpRectilinear { planes: vec![[k0, k1, k2, k3, 0.0, 0.0]], center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) + // output radius (px) → green source radius + let green = |r: f64| source(r / z); + let plane = |map: &dyn Fn(f64) -> f64| { + let [k0, k1, k2, k3] = fit_radial_warp(&|u| map(u * m) / m, max_fit_error, shown)?; + Some([k0, k1, k2, k3, 0.0, 0.0]) + }; + let g = plane(&green)?; + // red and blue: relative to the green source radius, at that radius + let colour = |c: &[f64]| { + plane(&|r| { + let s = green(r); + s * (1.0 + interpolate(c, knot_spacing, s) / CA_UNIT) + }) + }; + // a NaN fails the comparison: refused too + let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); + let rgb = t.ca.as_ref().filter(|(red, blue)| plausible(red) && plausible(blue)); + let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); + Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) } /// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose /// tables are not verified. pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec<Opcode> { let Some(spacing) = knot_spacing(model) else { return Vec::new() }; - distortion_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() + warp_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() } #[cfg(test)] @@ -181,6 +210,11 @@ mod tests { 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42, 56, 60, 54, 32, ]; + /// The CA table of corpus `arw6-sony-ilce7rm6-ff-land-hq.arw` (the same scene; same distortion table). + const FF_HQ_CA: [i16; 33] = [ + 32, 242, 234, 222, 208, 192, 172, 152, 128, 106, 82, 62, 42, 26, 12, 4, 2, -270, -254, -234, -208, -180, -146, -112, -72, -38, -4, 22, 44, + 58, 64, 56, 36, + ]; /// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding. const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583]; const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0]; @@ -193,6 +227,14 @@ mod tests { const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 }; const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 }; + /// The G planes of the examples on their ARW6 geometry, and the R and B planes with each file's CA table + /// (docs/sony-ilce7rm6-lens-corrections.md). + const FF_G: [f64; 4] = [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548]; + const FF_HQ_R: [f64; 4] = [0.9715153129, 0.02236266369, 0.0007060224765, 0.005666341563]; + const FF_HQ_B: [f64; 4] = [0.97137946, 0.02277016389, 0.0003830082563, 0.005727820598]; + const APSC_G: [f64; 4] = [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]; + const APSC_R: [f64; 4] = [1.016503991, -0.06206460153, 0.0278447625, -0.005644118171]; + const APSC_B: [f64; 4] = [1.016155702, -0.06162477627, 0.02753315326, -0.005554915901]; fn knots() -> f64 { knot_spacing("ILCE-7RM6").unwrap() @@ -223,16 +265,18 @@ mod tests { table[1..=table[0] as usize].iter().map(|&v| v as f64).collect() } - /// The warp's one plane and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md). - fn assert_warp(op: Option<Opcode>, center: [f64; 2], k: [f64; 4]) { + /// The warp's planes and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md); returns the planes. + fn assert_warp(op: Option<Opcode>, center: [f64; 2], k: &[[f64; 4]]) -> Vec<[f64; 6]> { let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") }; - assert_eq!(planes.len(), 1); + assert_eq!(planes.len(), k.len()); assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}"); - let p = planes[0]; - for (i, want) in k.iter().enumerate() { - assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + for (p, k) in planes.iter().zip(k) { + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + } + assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); } - assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); + planes } #[test] @@ -281,7 +325,7 @@ mod tests { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); let t = read_tables(&ifd); assert!(t.distortion.is_none(), "{what}"); - assert!(distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); } // not an integer array, or absent @@ -309,7 +353,7 @@ mod tests { ("not finite", vec![0.0, f64::NAN, 20.0]), ("infinite", vec![0.0, f64::INFINITY, 20.0]), ] { - assert!(distortion_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); } // the same through the IFD, as 64-bit values for v in [i64::MAX / 4, i64::MIN, i64::MAX] { @@ -326,25 +370,88 @@ mod tests { ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), ] { - assert!(distortion_opcode(&ff, knots, active, crop).is_none(), "{what}"); + assert!(warp_opcode(&ff, knots, active, crop).is_none(), "{what}"); } } #[test] fn ff_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&FF_DISTORTION)); - assert_warp( - distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP), - [0.4996005991, 0.5], - [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548], - ); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); } #[test] fn apsc_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&APSC_DISTORTION)); - let op = distortion_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); - assert_warp(op, [0.4975724473, 0.5], [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); + } + + #[test] + fn ca_table_shifts_red_and_blue_planes() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + assert_ne!(planes[0][0], planes[1][0]); + // G is the distortion plane alone + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP); + assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); + } + + #[test] + fn apsc_ca_planes_match_the_measured_warp() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + } + + #[test] + fn ca_without_distortion_is_not_applied() { + let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); + let t = read_tables(&ifd); + assert!(t.ca.is_some() && t.distortion.is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty()); + // an all-zero distortion table counts as absent + let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP).is_none()); + } + + #[test] + fn rejected_ca_keeps_the_single_green_plane() { + let green = + assert_warp(warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + let ok = values(&FF_HQ_CA); + let huge = vec![(i64::MAX / 4) as f64; 16]; + for (what, red, blue) in [ + ("huge red", huge.clone(), ok[16..].to_vec()), + ("huge blue", ok[..16].to_vec(), huge), + ("most negative blue", ok[..16].to_vec(), vec![i64::MIN as f64; 16]), + ("not finite red", vec![0.0, f64::NAN, 20.0], ok[16..].to_vec()), + ("one knot", vec![100.0], vec![100.0]), + ] { + let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP) else { panic!("{what}: no warp") }; + assert_eq!(planes, green, "{what}"); + } + // through the IFD: an odd count, or values that overflow the fit + let mut odd = FF_HQ_CA.to_vec(); + odd[0] = 31; + let mut huge_ifd = vec![i64::MAX / 4; 33]; + huge_ifd[0] = 32; + // a constant 32767 (the SShort maximum) fits as well as G does and would shift red ~59 px at the full-res + // corner: refused as implausible (bound 2·10⁻³ relative = 6720) + let mut max = vec![32767; 33]; + max[0] = 32; + for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); + let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); + assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes == &green), "{what}: {ops:?}"); + } + // values up to the bound are kept + let mut at_bound = vec![6720; 33]; + at_bound[0] = 32; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); + assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 3), "{ops:?}"); } /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well @@ -354,7 +461,7 @@ mod tests { fn padded_active_area_gates_only_the_displayed_radii() { let strong: Vec<f64> = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; - let op = distortion_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); } @@ -365,7 +472,7 @@ mod tests { assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); - assert_eq!(ops, distortion_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::<Vec<_>>()); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::<Vec<_>>()); assert_eq!(ops.len(), 1); } } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 5512a1169..ae4b0892d 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -158,8 +158,8 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, /// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64 /// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when -/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples with -/// `u ≤ gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. +/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples up to the first +/// one at or beyond `gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f64, gate_until: f64) -> Option<[f64; 4]> { let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) .map(|i| { @@ -187,6 +187,10 @@ pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f } let k = solve4(a, rhs)?; let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>(); + // rounded up to a sample, so the band between the last sample inside and `gate_until` itself is checked too; + // capped at 1 (`min` also turns a NaN into 1: every sample is gated) + let n = DISTORTION_SAMPLES as f64; + let gate_until = (gate_until * n).ceil().min(n) / n; let worst = samples.iter().filter(|&&(u, _)| u <= gate_until).map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); (worst <= max_fit_error).then_some(k) } @@ -306,5 +310,12 @@ mod tests { let step = |u: f64| if u > 0.99 { 1.1 } else { u }; assert!(fit_radial_warp(&step, 0.01, 1.0).is_none()); assert_eq!(fit_radial_warp(&step, 0.01, 0.9), fit_radial_warp(&step, 1.0, 1.0), "gated only where shown"); + // the first sample at or beyond `gate_until` is gated too (the band up to it is shown): 0.023 up to + // u = 62/64, 0.032 at 63/64 + assert!(fit_radial_warp(&step, 0.03, 62.0 / 64.0).is_some(), "a sample exactly at the bound"); + assert!(fit_radial_warp(&step, 0.03, 0.98).is_none(), "63/64 is the next sample beyond 0.98"); + // capped at 1, and a NaN gates every sample (fails closed) + assert_eq!(fit_radial_warp(&step, 1.0, f64::INFINITY), fit_radial_warp(&step, 1.0, 1.0)); + assert!(fit_radial_warp(&step, 0.01, f64::NAN).is_none(), "a NaN gate"); } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 60c81fd1f..c32857100 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,14 +626,14 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } -/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a `WarpRectilinear` in `OpcodeList3` -/// (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 ILCE-7RM6 photos, evaluated in f64 for each -/// file's decoded active area (the padded lossless tiles) and default crop. Both files carry distortion correction -/// off in camera; the table is applied anyway. +/// Sony ILCE-7RM6: the distortion and lateral CA tables in the raw IFD (`0x7037`, `0x7035`) become a three-plane +/// (R, G, B) `WarpRectilinear` in `OpcodeList3` (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 +/// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and +/// default crop. Both files carry distortion correction off in camera; the table is applied anyway. #[test] fn corpus_sony_lens_tables_become_opcodes() { let dir = corpus_root().join("raw"); - // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) + // (file, active area, default crop x, y, width, height, opcode centre, R, G, B planes k0..k3) let cases = [ // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) @@ -642,7 +642,11 @@ fn corpus_sony_lens_tables_become_opcodes() { (10240, 7168), (12, 8, 9984, 6656), [0.488670768629749, 0.4653969582810102], - [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], ), // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) ( @@ -650,7 +654,11 @@ fn corpus_sony_lens_tables_become_opcodes() { (6656, 4608), (16, 10, 6528, 4352), [0.49278737791134486, 0.4743868026915563], - [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], ), ]; let mut seen = 0; @@ -679,10 +687,12 @@ fn corpus_sony_lens_tables_become_opcodes() { }) .collect(); let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; - assert_eq!(planes.len(), 1, "{name}: one plane (G)"); + assert_eq!(planes.len(), 3, "{name}: R, G, B planes"); assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); - for (i, want) in k.iter().enumerate() { - assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); + for (plane, (p, k)) in ["R", "G", "B"].iter().zip(planes.iter().zip(k)) { + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "{name}: {plane} k{i} {}, want {want}", p[i]); + } } seen += 1; } From d982a073c940fb9cf8ccfdd67e47d1639ac4b71d Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Sat, 10 Oct 2026 19:55:51 +0200 Subject: [PATCH 098/315] LR-EDIT-OPTICS-PROFILE: Sony embedded corrections on by default for ARW An ILCE-7RM6 ARW with Sony's distortion table now imports with Enable Lens Corrections on: Photo::camera_defaults already turns the correction on for any file whose probe yields embedded lens data, and the ARW decoder emits it since the previous commit. No new default rule is needed, so RW2 and DNG keep theirs (on when the file carries a correction) and stored settings are never rewritten. Tests pin it: a synthetic ARW imports with the correction on (another body's tables are not read: off), RW2 / DNG defaults are unchanged, and an ARW catalogued without lens data or switched off by the user keeps its settings across a library reopen. The Optics panel's hint drops "(DNG lens opcodes only)" in every locale. Co-Authored-By: Claude <noreply@anthropic.com> --- crates/engine/src/files.rs | 108 ++++++++++++++++++++++++++++ crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/panels/edit.rs | 4 +- 11 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..3256ee61c 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -2296,4 +2296,112 @@ mod tests { assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); let _ = std::fs::remove_dir_all(&dir); } + + /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs` + /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). + fn sony_arw(model: &str) -> Vec<u8> { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens + /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. + #[test] + fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's + /// opcodes), off when it doesn't. + #[test] + fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } + } + + /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read + /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the + /// correction off keeps it off. + #[test] + fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..ea37b0403 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -945,7 +945,7 @@ "No edits yet.": "Noch keine Bearbeitungen.", "No exposure information": "Keine Belichtungsinformationen", "No filter presets yet": "Noch keine Filtervorgaben", - "No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).", + "No lens data embedded in this file.": "Keine Objektivdaten in dieser Datei eingebettet.", "No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.", "No matching commands": "Keine passenden Befehle", "No matching photos": "Keine passenden Fotos", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..0df2ebecb 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -920,7 +920,7 @@ "No edits yet.": "Aún no hay ediciones.", "No exposure information": "Sin información de exposición", "No filter presets yet": "Aún no hay ajustes preestablecidos de filtro", - "No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).", + "No lens data embedded in this file.": "Este archivo no incluye datos de objetivo.", "No masks yet. Choose a mask type above.": "Aún no hay máscaras. Elegir un tipo de máscara arriba.", "No matching commands": "Ningún comando coincide", "No matching photos": "No hay fotos coincidentes", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..2035f615c 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -897,7 +897,7 @@ "No edits yet.": "Aucune retouche pour l'instant.", "No exposure information": "Aucune information d'exposition", "No filter presets yet": "Aucun préréglage de filtre pour l'instant", - "No lens data embedded in this file (DNG lens opcodes only).": "Aucune donnée d'objectif intégrée à ce fichier (opcodes d'objectif DNG uniquement).", + "No lens data embedded in this file.": "Aucune donnée d'objectif intégrée à ce fichier.", "No masks yet. Choose a mask type above.": "Aucun masque pour l'instant. Choisissez un type de masque ci-dessus.", "No matching commands": "Aucune commande correspondante", "No matching photos": "Aucune photo correspondante", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..41f5d9004 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -949,7 +949,7 @@ "No edits yet.": "まだ編集していません", "No exposure information": "露出情報がありません", "No filter presets yet": "フィルタープリセットはまだありません", - "No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)", + "No lens data embedded in this file.": "このファイルにレンズ情報は含まれていません", "No masks yet. Choose a mask type above.": "マスクはありません。上から種類を選択してください", "No matching photos": "一致する写真がありません", "No metadata found": "メタデータがありません", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..ea90aee8f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -909,7 +909,7 @@ "No edits yet.": "Ainda sem edições.", "No exposure information": "Sem informações de exposição", "No filter presets yet": "Ainda não há predefinições de filtro", - "No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).", + "No lens data embedded in this file.": "Nenhum dado de lente incorporado neste arquivo.", "No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.", "No matching commands": "Nenhum comando corresponde", "No matching photos": "Nenhuma foto correspondente", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..8107b2d0a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -752,7 +752,7 @@ "No edits yet.": "Правок пока нет.", "No exposure information": "Нет данных об экспозиции", "No filter presets yet": "Пресетов фильтров пока нет", - "No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).", + "No lens data embedded in this file.": "В этом файле нет данных об объективе.", "No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.", "No matching commands": "Подходящих команд нет", "No matching photos": "Нет подходящих фото", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..6fa4c2e46 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1086,7 +1086,7 @@ "No edits yet.": "Змін поки немає.", "No exposure information": "Немає інформації про експозицію", "No filter presets yet": "Наборів налаштувань фільтрів поки немає", - "No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).", + "No lens data embedded in this file.": "У цьому файлі немає даних об’єктива.", "No masks yet. Choose a mask type above.": "Масок поки немає. Виберіть тип маски вище.", "No matching photos": "Немає відповідних фото", "No metadata found": "Метаданих не знайдено", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..8c68cb7e3 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -971,7 +971,7 @@ "No edits yet.": "尚未编辑。", "No exposure information": "无曝光信息", "No filter presets yet": "暂无筛选器预设", - "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "No lens data embedded in this file.": "此文件中未嵌入镜头数据。", "No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。", "No matching photos": "没有匹配的照片", "No metadata found": "未找到元数据", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..6f3cf4389 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -971,7 +971,7 @@ "No edits yet.": "尚未編輯。", "No exposure information": "沒有曝光資訊", "No filter presets yet": "尚無篩選預設集", - "No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。", + "No lens data embedded in this file.": "此檔案未內嵌鏡頭資料。", "No masks yet. Choose a mask type above.": "尚無遮色片。請在上方選擇遮色片類型。", "No matching photos": "沒有符合的照片", "No metadata found": "找不到中繼資料", diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 76a54171f..bce9b1c2d 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -367,9 +367,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } if lp && !has_lens { ui.label( - egui::RichText::new(crate::i18n::tr("No lens data embedded in this file (DNG lens opcodes only).")) - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), + egui::RichText::new(crate::i18n::tr("No lens data embedded in this file.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim), ); } }); From 33c362a7e4fd1331e83a8a7dc775831008da56fd Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Sat, 10 Oct 2026 22:35:34 +0200 Subject: [PATCH 099/315] LR-EDIT-OPTICS-PROFILE: Sony lens corrections verified on the ILCE-7RM6; docs and tracker docs/sony-ilce7rm6-lens-corrections.md records the three Sony tables, the measured conventions with their residuals, the conversion to WarpRectilinear and its fit gate, the model allowlist and how to extend it, the measurement method, the verification and the limits. Verified locally on 54 ILCE-7RM6 photos with four lenses. The corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints of the 33 measurable photos shot with in-camera correction on (up to 17.3 px uncorrected). The 13 controls show the model's own shift. Every fit passes the 0.25 px gate (margin 0.075). Lateral CA stays within 0.3 px, but applied after demosaic it raises the render's corner CA (rms 0.032 -> 0.052 px); the doc and tracker say so. Its "Two conventions" section sets these conventions next to the ILCE-7RM4A's (vendor::arw_lens, docs/sony-lens-corrections.md, #498): on the 34 measurable ILCE-7RM6 photos that formula gives 1.429 / 0.319 px (max / rms) against 0.596 / 0.143, failing only on the 24-70 GM II and fitting the 100 mm and 200-600 better; no single unit fits every lens under either, and what would settle it (Sony On/Off exports of a wide zoom, more lenses). docs/sony-lens-corrections.md gains a pointer to it. Tracker rows LR-EDIT-OPTICS-PROFILE (one note for both bodies) and LR-IMP-CAMERA-COVERAGE in docs/parity-checklist.md, gap 8 in docs/gaps.md, the lens-profile section of docs/raw-parity.md (lens score unchanged), whats-new (lossless ARWs now; Compressed / Compressed HQ once the ARW 6.0 decoder, #625, lands) and the colour-fit follow-up note are updated. Co-Authored-By: Claude <noreply@anthropic.com> --- docs/camera-preview-colour.md | 2 +- docs/gaps.md | 3 +- docs/parity-checklist.md | 4 +- docs/raw-parity.md | 4 +- docs/sony-ilce7rm6-lens-corrections.md | 301 +++++++++++++++++++++++++ docs/sony-lens-corrections.md | 5 + docs/whats-new.md | 12 + 7 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 docs/sony-ilce7rm6-lens-corrections.md diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..3b4e076d9 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -17,7 +17,7 @@ Matrix-only and matrix + table looks are each completed (tone curve, then chroma The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged. -One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. +One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections ([Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md)), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. **A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9). diff --git a/docs/gaps.md b/docs/gaps.md index 39e6c6486..2a80452ac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony in-file distortion on two bodies (ILCE-7RM4A; ILCE-7RM6, also lateral CA), each with its own conventions; other Sony bodies' tables not applied until verified | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | @@ -89,6 +89,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Compressed ORF entry links the two Olympus patents that describe the method | | 2026-10-11 | minor | Gap 14: `crs:HDREditMode` is read from XMP (#663); the headroom limit and SDR rendition still aren't | | 2026-10-11 | minor | Gaps 2 and 10 shrink: EOS R5 Mark II CR3 decodes and is verified (fourth CR3 body, Canon 12 verified, 235 models, ~42% on a verified body); CR3 gain +6.5 → +6.2 points | +| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion and lateral CA added next to the ILCE-7RM4A's (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..b511764da 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Sony lens corrections are per model too: the distortion table is applied only on the ILCE-7RM4A (verified against Sony's exports, `docs/sony-lens-corrections.md`) and the ILCE-7RM6 (verified against the camera JPEG on 54 photos: 41 shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), `docs/sony-ilce7rm6-lens-corrections.md`), with different conventions; each further body needs the same check. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion and lateral CA tables (`0x7037`, `0x7035`) as one three-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's CA unit is provisional and, applied after demosaic, raises the render's corner CA (rms 0.032 → 0.052 px, within the 0.3 px budget); its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index e3fdf5fcf..d07753b6e 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -7,6 +7,7 @@ does not decode, or decodes with the wrong colour, cannot switch. This document model. The numbers feed [target-app-parity.md](target-app-parity.md); individual shortfalls are ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), [raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), +[sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md), [camera-preview-colour.md](camera-preview-colour.md), [denoise.md](denoise.md). ## Headline @@ -219,7 +220,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table too (provisional unit, applied after demosaic), not its raw-IFD vignetting table | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -281,5 +282,6 @@ Beyond decoding: | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | +| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion and lateral CA next to the ILCE-7RM4A's distortion; lens score unchanged (~10%: one more body) | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/sony-ilce7rm6-lens-corrections.md b/docs/sony-ilce7rm6-lens-corrections.md new file mode 100644 index 000000000..64963fcac --- /dev/null +++ b/docs/sony-ilce7rm6-lens-corrections.md @@ -0,0 +1,301 @@ +# Sony ARW lens corrections + +Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral +chromatic aberration (CA). LightCraft reads them and turns distortion and lateral CA into a DNG `WarpRectilinear` +opcode, so they reach the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp +and Panasonic's RW2 distortion data. Vignetting is read but not applied (below). The conversion is verified on the +**ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions ([Two conventions](#two-conventions)), +and other Sony bodies' tables give no correction until they are verified. + +The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). +Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the +photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an +external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider +scales the warp (all three colour planes) as for DNG and RW2. + +## The tables + +ExifTool's names (EXIF table, `SubIFD` group); each table is a count-prefixed array of signed 16-bit values +(`SSHORT`, 17 / 17 / 33 values), and each has an in-camera on/off tag: + +| Table | Tag | Flag tag | Count (full frame / APS-C crop mode) | Flag values seen | +|---|---|---|---|---| +| Distortion (`DistortionCorrParams`) | `0x7037` | `0x7036` | 16 / 11 | 0 (off) or 1 (on) | +| Vignetting (`VignettingCorrParams`) | `0x7032` | `0x7031` | 16 / 11 | 2 on all 54 photos | +| Lateral CA (`ChromaticAberrationCorrParams`) | `0x7035` | `0x7034` | 32 / 22 (red, then blue) | 1 on all 54 photos | + +The first value is the knot count; only the first `count` values are used. Full-frame files carry 16 / 16 / 32 knots; +APS-C crop-mode files 11 / 11 / 22, padded to the same array length (distortion with its last value repeated, +vignetting and CA with zeros). The CA table holds red's `count / 2` knots, then blue's. The corrections are built +whatever the flags say: the flag only records what the camera did to its own JPEG. Tables with a count outside +2–16 (CA: an odd count or outside 4–32), a count that doesn't fit the array, or non-integer values are ignored. + +## Measured conventions + +None of this is documented; it was measured against the camera's own JPEGs (method below). Residuals are in px at a +1440 px long edge. + +- **Knots and centre.** Knot `i` sits at sensor radius `i · s` from the default-crop centre, with + `s = hypot(9984, 6656) / 30` = 399.98 px: the full-frame default crop's half diagonal divided by 15, so knot 15 is + the full-frame crop corner. The spacing is fixed on the sensor: APS-C files use the same pixel spacing (verified on + CA, rms 0.004–0.010 px against 0.011–0.014 px for `i/15` of the APS-C half diagonal; `i/10` of it, 392 px, fits as + well). Values are interpolated linearly between + knots; beyond the last knot the last segment is extended (it matters only in the outermost ~0.4 knot spacing at + the corners of a zoomed-out barrel correction). The centre is the default-crop centre: the measured translation + between our render and the camera JPEG has median 0.013 px, max 0.216 px. +- **Distortion** is relative and indexed at the corrected radius, in units of 1/15000: a corrected radius `r` + reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive values: pincushion; negative: barrel). The camera + then zooms so the output fills the default crop: output radius = `z` · corrected radius, with `z` chosen so that + the largest `S(ρ / z) / ρ` over the crop's border radii `ρ` is 1 (binding at the corner for pincushion, at the + short-edge midpoint for barrel). `z` was 0.971–1.035 on these photos. Without the zoom every hypothesis fails + (24.6 px); with it, the worst residual over the 34 measurable corrected photos was 0.596 px (rms 0.143; corners and + edge midpoints 0.570 px), within the 0.75 px budget for a unit between about 14700 and 15350. A global + least-squares fit gives 15000.7. +- **Lateral CA** is relative to the green source radius, at that radius: red's source radius is + `g · (1 + c_R(g) / 3.36·10⁶)`, with `g` the green source radius (after the distortion correction above) and `c_R` + the red half on the same knots; blue likewise with the second half. Positive values: that colour's image is larger + than green's. **The unit is provisional.** The measurement is borderline: on the 15 photos with CA above 0.15 px + that a single unit explains, the best (minimax) unit 2.26·10⁶ leaves 0.305 px against a 0.3 px budget; per-photo + best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one + scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 + cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. +- **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: + over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 + (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % + for `v / 2¹⁵`, +63 % for `v / 2¹³`). Regressing the measured corner EV on the predicted one gives slope 0.009 ± + 0.063. No model (`1 + v/U`, `U/(U − v)`, `2^(v/U)`) at any unit from 2¹³ to 2¹⁸ reaches 3 %; the identity is the + best of all. The raw data isn't pre-compensated either: the read noise of three dark high-ISO frames is flat from + centre to corner, where a digital gain from these tables would raise it by 60–80 %. So the table probably describes + the lens's natural falloff, but this camera's JPEGs don't apply it, and nothing shows its unit or model. Options: + (a) skip it, so renders match the camera JPEG (the current ruling); (b) apply it with an unverified model for a + Lightroom-like look; (c) shoot flat fields (a white wall at several apertures, one lens) and measure the model. + +## Conversion to `WarpRectilinear` + +`crates/raw/src/vendor/arw_lens_rm6.rs` builds one `WarpRectilinear` in `OpcodeList3`, centred on the default-crop centre +(DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): + +1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is + `G(r) = S(r / z)`. Red and blue: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. +2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with + DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). +3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop + shows: the samples up to the first one at or beyond the crop corner. The lossless files' padded active area + (10240 × 7168 for a 9984 × 6656 crop) reaches radii that are never shown (`u` up to 0.925 is shown there). + Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most + 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm + example in the tests. +4. Planes `[R, G, B]`; when the CA table is absent or malformed, a CA value is implausible (|c| above 2·10⁻³ of the + radius, i.e. 6720; the largest value in the 54 photos' tables is 1724) or a colour plane fails the gate, G alone is applied to all + three channels. No opcode when the distortion table is absent or all zero (CA alone is never applied: it is + relative to green), the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo + uncorrected rather than wrongly warped. + +The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with +the photo (portrait files are covered) and is carried by DNG export, as RW2's is. + +## Which bodies get it + +Only bodies listed in `FULL_FRAME_CROP` (`arw_lens_rm6.rs`) get an opcode: today `ILCE-7RM6` (9984 × 6656). The unit and +the knot spacing are verified on that body alone; the spacing is defined by its full-frame crop, and a body with +another sensor size or another unit would be warped wrongly. Other bodies get no correction from this conversion; the +ILCE-7RM4A gets its own, with other conventions (below). To add a body: collect photos of it shot with distortion +correction on (several lenses and focal lengths), repeat the camera-JPEG registration below against the uncorrected +render, confirm the unit, the knot spacing and the zoom rule (or measure the body's own), then add its full-frame +default crop to the table with a test. + +## Two conventions + +A second Sony decoder, `crates/raw/src/vendor/arw_lens.rs` +([Sony embedded distortion corrections](sony-lens-corrections.md), PR #498), reads the same `0x7037` table on the +**ILCE-7RM4A**, validated against Sony Imaging Edge exports (distortion On/Off pairs at 200 and 600 mm with the +FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventions differ from the ones above +(`r_o` = output radius about the crop centre, `v` = the table, linear between knots): + +| | ILCE-7RM6 (this page, `arw_lens_rm6.rs`) | ILCE-7RM4A (`arw_lens.rs`) | +|---|---|---| +| Knots | `i · 399.98` px, fixed on the sensor; last segment extended | `i / 15` of the crop's half diagonal; radius clamped to 1 | +| Table indexed at | the corrected radius `r_o / z` | the output radius `r_o` | +| Unit | 15000 | 16384 | +| Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | +| Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | +| Tables | distortion (16 knots; 11 in APS-C) and lateral CA; vignetting read, not applied | distortion, 16 entries only | + +`arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as +Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's +conversion, an ILCE-7RM4A the other, every other body neither. + +**Cross-check.** The ILCE-7RM4A formula, scored on the 34 measurable corrected ILCE-7RM6 photos exactly as in +Measured conventions (radial bins, px at 1440, max / rms; "c" = the worst corner or edge-midpoint point): + +| Variant | Unit | All photos | Corners max / median | 24–70 GM II (23) | 100 mm Macro (8) | 200–600 G (3) | +|---|---|---|---|---|---|---| +| **A**: this page | 15000 | **0.596 / 0.143** | 0.570 / 0.099 | 0.596 / 0.100, c 0.474 | 0.361 / 0.135, c 0.336 | 0.588 / 0.325, c 0.570 | +| **B1**: the ILCE-7RM4A formula | 16384 | **1.429 / 0.319** | 1.913 / 0.204 | 1.429 / 0.385, c 1.913 | 0.289 / 0.049, c 0.234 | 0.264 / 0.115, c 0.320 | +| **B2**: B1 at its best single unit | 15680 | 0.940 / 0.223 | 1.202 / 0.098 | 0.940 / 0.215, c 1.202 | 0.327 / 0.100, c 0.278 | 0.683 / 0.428, c 0.637 | +| **B3**: A's indexing and zoom at 16384 | 16384 | 1.450 / 0.455 | 1.845 / 0.350 | 1.450 / 0.539, c 1.845 | 0.284 / 0.062, c 0.238 | 0.654 / 0.329, c 0.586 | + +- B1 misses the 0.75 px budget on these photos, but only on the 24–70 GM II: at 24–28 mm it under-corrects the barrel + in the outer half (up to 1.43 px at 0.7–0.85 of the half diagonal), at 45–70 mm the pincushion by a nearly uniform + 0.2–0.4 px. On the 100 mm and the 200–600 it fits better than A, and on the 200–600, the one lens both validations + share, its unit agrees with this body's (best unit under its indexing 16524–16602). So the data does not show the + two bodies differing. +- The zoom rules agree (within 0.0035 on every photo); the unit and the table index make the difference. Under A's + indexing the 24–70 GM II's barrel and pincushion ends want the same unit (14749 / 14860, 0.7 % apart); under B's + they need 14792 / 15705 (6 % apart). +- APS-C: B refuses 11-entry tables. Its knots read literally on the APS-C crop (`i / 15` of its half diagonal, + 261.5 px) fit the APS-C RawDB files' lateral CA 4 × worse on 3 of 6 photos than the sensor-fixed 400 px; `i / 10` + (392 px) can't be told from 400 px. + +**Open question: no single unit fits every lens under either convention.** The 200–600 wants a 5–7 % larger unit (a +weaker correction) than the 24–70 GM II's pincushion end with similar table values (+409 … +561 against +406 … +454 +at the corner), under both indexings and every knot spacing tried (0.94–1.12 ×: the ratio of the two lenses' best +units stays 1.053–1.073). A's 15000 is the compromise that keeps every photo within 0.6 px; 16384 is the 200–600's +value. That rests on 3 measurable 200–600 photos (594–600 mm). The ILCE-7RM4A's own wide-angle evidence is its +weakest: its On/Off pairs are all 200–600, and its 24 mm check (LightCraft export against Sony's TIFF) is median 0.35 / +95th percentile 1.04 px at 2376 px. On its `WIDE` unit-test table the two formulas differ by up to 2.49 px at 1440 at +the corner (computed from the formulas, not measured). + +**What would settle it:** Sony Imaging Edge On/Off exports of the 24–70 GM II (or any wide zoom's wide end) on either +body, which would show whether the wide-end disagreement is the body or the formula; and more lenses, and more +200–600 photos, on the ILCE-7RM6, to tell a per-lens unit from a convention. Until then each body keeps the +conventions it was validated with. + +## Method + +Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the +[dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: +full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` +fetches one of them, the APS-C lossless file, as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, for the corpus test +`corpus_sony_lens_tables_become_opcodes` (its full-frame lossless case runs only where that file is present); the ARW +6.0 decoder (#625) pins one Compressed and one Compressed HQ file. +Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS +(9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it +off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local +(the gitignored `plan/arw6/lens/`), not in the repository; the steps are described so they can be redone. + +- **Oracle:** the camera JPEG inside each ARW (`JpgFromRaw`, 9984 × 6656 / 6528 × 4352, stored unrotated, turned to + the photo's orientation). Tag values were read with ExifTool used as a tool. Clean room: no lens-correction code of + any other project and no Adobe data was read. +- **Distortion (camera-JPEG block registration).** Our render at half the JPEG size and the JPEG (2 × 2 box-reduced) + are both reduced to a quarter of the JPEG size; on the green channel, every 40 px a 64 px JPEG block is located in + the render by normalised cross-correlation with parabolic sub-pixel refinement. Pass 1 searches coarse-to-fine + (±28 px at 1/16 size, then ±6 px); pass 2 (used for every number) searches ±6 px around pass 1's smooth radial fit + (data-derived, needed for a macro of a periodic screen). Blocks with low contrast (std < 2), NCC < 0.8 or a + tangential displacement above 4 px are dropped. A robust fit of `p_render = g(r) · p_jpeg + t` (`g` an even + 4-term polynomial) gives the translation `t`; the measured radius ratio is the median of 32 radial bins (≥ 10 + blocks each). A photo is measurable with ≥ 300 blocks and ≥ 15 bins reaching 0.7 of the half diagonal: 34 of the + 41 corrected photos (two black frames, a near-black night frame, two low-detail frames and two whose blocks reach + only 0.54 of the half diagonal are not). 108 hypotheses (radius norm, knot placement, relative or absolute, table + indexed at the corrected or the source radius, zoom rule), each with its best unit, were compared. +- **Lateral CA (raw CFA).** The demosaiced render shows only 25–60 % of the lens's CA and the JPEG's 4:2:2 chroma + and noise reduction hide it, so CA was measured on the raw: from LightCraft's uncompressed CFA DNG export, the + red (blue) plane against green interpolated at the same sites (4-neighbour mean), square-root encoded, 2 × 2 + binned; 40 px blocks every 24 px, ±3 px NCC search, radial component, median per 16 radial bins. +- **Vignetting (exposure brackets).** The JPEG is resampled into the render's geometry; per pixel and channel, the + exposure at which eight renders bracketed −0.3 … +1.8 EV equal the JPEG value is interpolated (smooth areas, JPEG + values 12–240, monotone brackets). That cancels the fitted tone curve, since exposure is a scene-linear gain before + it. Medians in 16 annuli give the JPEG's falloff relative to our uncorrected render. + +## Verification + +Every photo rendered with Enable Lens Corrections on and off at half the JPEG size (50 of the 54 are Compressed or +Compressed HQ ARW 6.0 files, so the renders used a local build of this code merged with the ARW6 decoder), then the same block +registration (passes 1 and 2) against the camera JPEG, and R and B against G inside each render (80 px blocks every +128 full-resolution px, ±4 px search; per corner the median radial shift of the 12 nearest blocks). px at a 1440 px +long edge; aggregates only. + +**Distortion, photos corrected in camera** (our render with the correction on vs the camera JPEG; budget 0.75 px). +"Corners + midpoints" is the 2-D displacement at the four corners and four edge midpoints, global translation +included; "bins" the radial residual over 32 radial bins. A photo counts as measurable as in the method above. + +| Lens | Photos (measurable) | Focal lengths | Corners + midpoints max / rms | Bins max / rms | Uncorrected, corners (per-photo max) | +|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 26 (23) | 24–70 mm | 0.553 / 0.125 | 0.503 / 0.066 | 1.1–17.3 | +| FE 100 mm F2.8 Macro GM OSS | 9 (7) | 100 mm | 0.409 / 0.111 | 0.317 / 0.097 | 0.6–4.8 | +| FE 200–600 mm F5.6–6.3 G OSS | 6 (3) | 594–600 mm | 0.679 / 0.321 | 0.498 / 0.274 | 7.5–10.8 | +| **All** | **41 (33)** | | **0.679** / 0.150 (206 points) | 0.503 / 0.109 | 0.6–17.3 | + +No photo is over budget; the global translation is at most 0.056 px. The 8 not measurable are black, near-black or +low-detail frames, or frames whose blocks don't reach 0.7 of the half diagonal. That is one more than in the +measurement (33 measurable against 34): one 100 mm photo keeps only 255 of the 300 blocks needed when it is matched on +the corrected render, as block coverage differs slightly between the two renders. The 200–600 mm keeps the systematic +residual the measurement found for it (its own best unit is 15659, see Limits). + +**Controls, correction off in camera** (13 photos: the 12 RawDB files, full frame and APS-C, and one private 24–70 mm +at 58 mm). Their JPEG keeps the lens's distortion, so our corrected render must differ from it by the model's own shift: +it does, by up to 10.5 px (full frame) and 11.4 px (APS-C), and matches that shift within 0.639 px at the corners and +midpoints (bins 0.259 px; full-frame RawDB files ≤ 0.218 px, APS-C ≤ 0.639 px). This checks that the pipeline renders +the model (also for APS-C, barrel, zoom 0.984), not that the APS-C model is right. + +**Portrait photos:** 8 corrected (orientation 6 and 8) within 0.232 px, 6 controls within 0.369 px of the model's +shift: the warp turns with the photo. + +**Lateral CA in the render** (radial R-vs-G and B-vs-G shift, median of the 12 blocks nearest each corner; 43 photos +with enough detail at all four corners; budget 0.3 px): + +| Lens | Photos | Off: max R / B | Off: rms | On: max R / B | On: rms | Camera JPEG: max / rms | +|---|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 23 | 0.089 / 0.071 | 0.028 | 0.219 / 0.148 | 0.069 | 0.150 / 0.036 | +| FE 16–35 mm F2.8 GM II | 12 | 0.125 / 0.054 | 0.041 | 0.030 / 0.051 | 0.020 | 0.058 / 0.016 | +| FE 100 mm F2.8 Macro GM OSS | 6 | 0.020 / 0.050 | 0.013 | 0.061 / 0.157 | 0.044 | 0.021 / 0.004 | +| FE 200–600 mm F5.6–6.3 G OSS | 2 | 0.033 / 0.101 | 0.032 | 0.146 / 0.075 | 0.070 | 0.113 / 0.039 | +| **All** | **43** | 0.125 / 0.101 | 0.032 | **0.219** / 0.157 | 0.052 | 0.150 / 0.028 | + +Within budget everywhere, but **the correction makes the render's own CA larger on average** (rms 0.032 → 0.052 px; +smaller only for the 16–35 mm). The reason is where it runs: over the radial bins beyond 0.3 of the half diagonal +(418 bins), our demosaiced render shows only 0.29 × the lens CA measured on the raw CFA (correlation 0.85), while the +warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On the raw data the table halves the CA (rms +0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the +same bins). All of this is far below one pixel at 1440 px. + +**Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin +0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), +the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 +bound. So every photo gets the three-plane warp: the corrected and uncorrected renders differ on all 54. + +## Performance + +`LIGHTCRAFT_PROFILE=1` renders of the full-frame lossless file and the full-frame Compressed HQ file (corpus +`arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other +builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: + +| Render | Off | Single-plane warp (manual Distortion 1) | On (three planes) | +|---|---|---|---| +| Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | +| Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | +| 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | + +The three colour planes cost about 2.5–3× a single-plane warp, as for an RW2 or DNG with three planes (the same +path). The stage cache keys the resampled source by the source, framing and output size, so tone, colour and +exposure drags don't re-run it. + +## Limits + +- **ILCE-7RM6 only.** Other Sony bodies record the same tags; their knot spacing (defined here by the full-frame crop), + unit and zoom rule are unverified, so they get no correction from this conversion (the ILCE-7RM4A's distortion has + its own: [Two conventions](#two-conventions)). +- **APS-C distortion is unverified in camera:** no APS-C photo with distortion correction on was available; the APS-C + knot spacing is verified on CA only, and the APS-C zoom follows the full-frame rule. +- **Aspect-ratio crops** (in-camera 4:3, 16:9, 1:1) were not measured; the correction uses the crop the file records. +- **One distortion unit for every lens.** Fitted per lens the unit is 14826 (24–70 mm, 23 photos, very consistent), + 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic + residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not + captured; per-lens units would need per-lens verification. +- **Lateral CA unit provisional** (above), and the CA warp runs after demosaic, as for RW2 and DNG. Our demosaic + already hides about 70 % of the lens CA, so the full table overshoots in the render: within budget, but the rms + corner CA rises from 0.032 to 0.052 px (Verification). Correcting CA before demosaic would be the principled fix; + scaling the colour planes down or dropping them are the cheap alternatives. +- **Vignetting not applied** (above). +- **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens + correction. +- **Existing imports** need Photo → Reload from Disk to pick up the lens data. +- The camera-JPEG colour fit still matches its pairs without the correction (docs/camera-preview-colour.md). + +## Sources + +- [ExifTool EXIF tag names](https://exiftool.org/TagNames/EXIF.html): the names and types of tags `0x7031`–`0x7037` + (ExifTool 13.59 was used to read the tag values). Their meaning, units and conventions were measured as above. +- [dnglab RawDB, Sony ILCE-7RM6](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6): the 12 CC0 samples (data only; + dnglab's code was not consulted). diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 375f773ab..dbc8090c7 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,5 +1,10 @@ # Sony embedded distortion corrections +The **ILCE-7RM6** (A7R VI) has its own conversion, with other conventions measured against its camera JPEGs: +[Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md). On ILCE-7RM6 photos the formula below misses the 24–70 GM II by +up to 1.43 px at 1440 px while fitting the 100 mm and 200–600 better, and no single unit fits every lens under either +convention; that page's *Two conventions* section has the comparison and what would settle it. + LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, even when they contain a table with the same layout. diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..57e4e1c52 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -102,6 +102,18 @@ - Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a magnified preview isn't taken for the photo when checking focus at 1:1. +### Lens corrections +- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion and lateral chromatic aberration from the tables the + camera records in each file, under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 + photos shot with the camera's correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the + camera's JPEG within 0.68 px at 1440 px at the corners, where it was up to 17 px off before. The camera's vignetting + table is read but not applied: its own JPEGs don't apply it either. This reaches lossless ARWs now; Compressed and + Compressed HQ ARWs (ARW 6.0) get it once their decoder (#625) lands, and until then open from their embedded JPEG. + The A7R IVA (ILCE-7RM4A) has its own distortion correction, checked against Sony's exports; other Sony bodies record + the same tables, but they stay uncorrected until they are checked the same way. Photos imported earlier get the + correction with Photo ▸ Reload from Disk (when still at their import settings). See + `docs/sony-ilce7rm6-lens-corrections.md`. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a From ac84cd31ee9475f75107c3aa5f30700e0332e198 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:14:24 +0200 Subject: [PATCH 100/315] LR-VIEW-FOCUSPOINT: show the camera's focus point (Sony) and zoom to it (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the autofocus point from Sony maker notes (FocusLocation 0x2027, FocusFrameSize 0x2037, FocusMode 0x201b, AFAreaModeSetting 0x201c, AFTracking 0x2021 and the area mode used from the enciphered 0x9402 block), for the ILCE-1 and ILCE-7SM3, whose layout was checked against ExifTool's output on 333 real ARWs. Manual focus, centre-only Multi reports and unchecked bodies give no point. The box is stored on the upright photo as Meta.focus (omitted when unknown; no catalog format change), filled at import and by Reload from Disk. View > Show Focus Point (Alt+F) draws it in Detail, Compare and Survey through rotation and crop; View > Zoom to Focus Point (on by default) makes Toggle Zoom centre on it; the Info panel shows "AF-C · Wide · Animal Eye Tracking". photo.inspect returns meta.focus. --- crates/catalog/src/model.rs | 5 + crates/catalog/src/tests.rs | 22 + crates/engine/src/cmd/convert.rs | 25 ++ crates/engine/src/demo.rs | 10 + crates/engine/src/files.rs | 1 + crates/meta/src/exif.rs | 8 +- crates/meta/src/focus.rs | 559 ++++++++++++++++++++++++ crates/meta/src/lib.rs | 8 + crates/meta/src/xmp.rs | 1 + crates/ui-egui/locales/de.json | 20 +- crates/ui-egui/locales/es.json | 20 +- crates/ui-egui/locales/fr.json | 20 +- crates/ui-egui/locales/ja.json | 20 +- crates/ui-egui/locales/pt-br.json | 20 +- crates/ui-egui/locales/ru.json | 20 +- crates/ui-egui/locales/uk.json | 20 +- crates/ui-egui/locales/zh-hans.json | 20 +- crates/ui-egui/locales/zh-hant.json | 20 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 4 + crates/ui-egui/src/menus.rs | 25 +- crates/ui-egui/src/panels/compare.rs | 3 + crates/ui-egui/src/panels/detail.rs | 60 +++ crates/ui-egui/src/panels/right.rs | 19 + crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_focus_point.rs | 158 +++++++ docs/control-protocol.md | 5 + docs/focus-points.md | 79 ++++ docs/mcp.md | 2 +- docs/parity-checklist.md | 12 +- docs/whats-new.md | 10 + 31 files changed, 1186 insertions(+), 19 deletions(-) create mode 100644 crates/meta/src/focus.rs create mode 100644 crates/ui-egui/src/tests_focus_point.rs create mode 100644 docs/focus-points.md diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..f7b832e3a 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -199,6 +199,11 @@ pub struct Meta { /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec<lightcraft_meta::Region>, + /// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony + /// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never + /// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option<lightcraft_meta::FocusPoint>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..42dc515f1 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -391,6 +391,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() { assert_eq!(back, with); } +/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a +/// catalog from before it existed reads with none, and writes the same), kept exactly when known. +#[test] +fn focus_point_is_not_serialized_when_unknown_and_round_trips() { + let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("focus").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.focus.is_none()); + let mut with = m.clone(); + with.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: None, + }); + let json = serde_json::to_string(&with).unwrap(); + assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}"); + let back: Meta = serde_json::from_str(&json).unwrap(); + assert_eq!(back, with); +} + // ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> { diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..53c6a10f9 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -234,6 +234,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P m.aperture = m.aperture.or(src.aperture); m.iso = m.iso.or(src.iso); m.gps = m.gps.or(src.gps); + // the camera's focus point: photos imported before it was read get it here + if m.focus.is_none() { + m.focus = src.focus.clone(); + } let mut ops = Vec::new(); if m != before { ops.push(Op::SetMeta { id, meta: Box::new(m) }); @@ -421,6 +425,27 @@ mod tests { assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + /// Reload from Disk gives a photo imported before focus points were read its camera focus point, + /// and never replaces one it has. + #[test] + fn reload_fills_a_missing_focus_point() { + let fp = |x: f64| lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 }, + mode: Some("AF-C".into()), + area: None, + tracking: None, + }; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00"); + ph.captured = Some("2026-10-04T08:16:11".into()); + let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() }; + info.meta.focus = Some(fp(0.3)); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!(meta.focus, Some(fp(0.3))); + ph.meta.focus = Some(fp(0.1)); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept"); + } + fn lens() -> lightcraft_develop::EmbeddedLens { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6a89dda88..ab5976115 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -30,6 +30,16 @@ pub fn load(s: &mut Session) { creator: "LightCraft Demo".into(), ..Default::default() }; + // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so + // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + if sc.name == "Cosmos bloom" { + p.meta.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, + mode: Some("AF-S".into()), + area: Some("Flexible Spot".into()), + tracking: None, + }); + } p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8]; p.flag = match sc.id % 6 { 1 => Flag::Pick, diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..2ec364b5a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -40,6 +40,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) { // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), + focus: m.focus.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..6e175b379 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -16,8 +16,12 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> { - let tiff = Tiff::parse(strip_exif_header(bytes))?; - Ok(from_tiff(&tiff)) + let data = strip_exif_header(bytes); + let tiff = Tiff::parse(data)?; + let mut m = from_tiff(&tiff); + // the maker note's values are offsets into the stream, which `from_tiff` doesn't have + m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation); + Ok(m) } fn text(ifd: Option<&Ifd>, tag: u16) -> Option<String> { diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs new file mode 100644 index 000000000..92801f8a7 --- /dev/null +++ b/crates/meta/src/focus.rs @@ -0,0 +1,559 @@ +//! Where the camera focused: the autofocus point a camera records in its maker notes, as a box on the +//! upright photo ([`FocusPoint`]). Sony only for now. +//! +//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool +//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! +//! | Tag | Layout (confirmed) | Use | +//! |---|---|---| +//! | `0x2027` FocusLocation | 4 × int16u: the width and height of the frame it is measured in, then the focus centre x, y in pixels of that frame (the sensor's landscape frame: a portrait shot still says 8640 × 5760) | the point | +//! | `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height of the frame the camera showed, then 2 bytes that are zero when there is no frame (manual focus) | the box size | +//! | `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | manual = no point | +//! | `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | shown | +//! | `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | shown | +//! | `0x9402` (enciphered block) byte 0x16, 0x17 | FocusMode (low 7 bits) and the AF area mode actually used: 0 Multi, 1 Center, 2 Spot, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual | shown; read only when its focus mode agrees with `0x201b` | +//! +//! The `0x94xx` blocks are enciphered byte by byte (the documentation calls it "a simple substitution +//! cipher"). On the sample files each plain byte `p < 249` is stored as `p³ mod 249` and bytes 249–255 +//! as themselves (e.g. a stored `0x20` reads as AFAreaMode 20, stored `0x77` as `0x83`, FocusMode AF-C +//! with its top bit set); the map is a bijection on 0..249 (cubing is invertible mod 3 and mod 83), so +//! it is undone by a table of its inverse. +//! +//! **No real point** (nothing is stored, so nothing is drawn and Z zooms as before): +//! - a body the layout hasn't been confirmed on ([`SONY_MODELS`]), a missing or malformed tag, a zero +//! frame size, or a centre outside its frame; +//! - manual focus (`0x201b` = 0, or the block's area mode says Manual): the camera still records where +//! the AF frame was parked, not where anything was in focus (on the samples these are off-centre); +//! - the exact frame centre while the area used was Multi (Wide with nothing tracked): on the samples +//! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or +//! Flexible Spot frame is a real choice and is kept. + +use crate::{Orientation, Rect}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use serde::{Deserialize, Serialize}; + +/// Where the camera focused, on the upright photo. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FocusPoint { + /// The focus frame, normalized to the photo's full, EXIF-oriented frame (y down, 0..1 on both + /// axes, clipped to it) — the frame face regions use, so it follows Rotate, flips and crops. + pub rect: Rect, + /// Focus mode as the camera names it (`AF-C`, `AF-S`, `DMF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option<String>, + /// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub area: Option<String>, + /// What the camera followed, when it says (`Animal Eye Tracking`, `Face tracking`, `Lock On AF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracking: Option<String>, +} + +impl FocusPoint { + /// The box's centre (normalized, upright). + pub fn center(&self) -> (f64, f64) { + ((self.rect.x0 + self.rect.x1) / 2.0, (self.rect.y0 + self.rect.y1) / 2.0) + } + /// The words that describe it, in order (mode, area, tracking), for "AF-C · Wide · Animal Eye Tracking". + pub fn words(&self) -> Vec<&str> { + [&self.mode, &self.area, &self.tracking].into_iter().filter_map(|s| s.as_deref()).filter(|s| !s.is_empty()).collect() + } + /// The box clipped to the frame and checked: `None` when it is not a usable box (non-finite, + /// empty, or entirely outside the photo). + pub fn sanitized(mut self) -> Option<FocusPoint> { + let r = self.rect; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (r.x0.min(r.x1).clamp(0.0, 1.0), r.x0.max(r.x1).clamp(0.0, 1.0)); + let (y0, y1) = (r.y0.min(r.y1).clamp(0.0, 1.0), r.y0.max(r.y1).clamp(0.0, 1.0)); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + self.rect = Rect { x0, y0, x1, y1 }; + Some(self) + } +} + +/// Sony bodies whose focus tags have been checked against real files. Another body may store the same +/// tags differently (or not mean the same by them): it shows no focus point until its files are checked. +pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; + +const FOCUS_MODE: u16 = 0x201b; +const AF_AREA_SETTING: u16 = 0x201c; +const AF_TRACKING: u16 = 0x2021; +const FOCUS_LOCATION: u16 = 0x2027; +const FOCUS_FRAME_SIZE: u16 = 0x2037; +const TAG_9402: u16 = 0x9402; + +/// Side of the box drawn when the camera gave a point but no frame size, as a fraction of the frame's +/// short side (about the size of the α1's smallest frames). +const DEFAULT_SIDE: f64 = 0.03; + +/// The focus point a file's maker note records, or `None` (see the module notes for when). +/// `data` is the TIFF stream the maker note's offsets refer to (a whole raw, or an Exif block). +pub fn read(data: &[u8], tiff: &Tiff, make: Option<&str>, model: Option<&str>, orientation: Option<Orientation>) -> Option<FocusPoint> { + let make = make?; + if !make.to_ascii_uppercase().starts_with("SONY") || !SONY_MODELS.contains(&model?.trim()) { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, make)?; + // ARWs from these bodies carry a header-less IFD; their JPEGs the `SONY DSC` header + if !matches!(mn.kind, makernote::MakerNoteKind::Sony | makernote::MakerNoteKind::Plain) { + return None; + } + sony(&mn.ifd, mn.order, orientation.unwrap_or_default()) +} + +fn byte(ifd: &Ifd, tag: u16) -> Option<u8> { + ifd.value(tag)?.get_u64(0).and_then(|v| u8::try_from(v).ok()) +} + +/// Decode the Sony tags of a parsed maker-note IFD. +pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Option<FocusPoint> { + let loc = ifd.value(FOCUS_LOCATION)?; + let [w, h, x, y] = [0, 1, 2, 3].map(|i| loc.get_u64(i)); + let (w, h, x, y) = (w? as f64, h? as f64, x? as f64, y? as f64); + if w < 1.0 || h < 1.0 || x > w || y > h { + return None; + } + let mode = byte(ifd, FOCUS_MODE); + if mode == Some(0) { + return None; + } + // the enciphered block: its focus mode must agree with the plain one, else its layout is not the one we know + let block = ifd.bytes(TAG_9402).and_then(|b| { + let (m, area) = (decipher(*b.get(0x16)?) & 0x7f, decipher(*b.get(0x17)?)); + (Some(m) == mode).then_some(area) + }); + if block == Some(255) { + return None; + } + let setting = byte(ifd, AF_AREA_SETTING); + let tracking = byte(ifd, AF_TRACKING); + let wide_untracked = match block { + Some(a) => a == 0, + None => setting == Some(0) && tracking.is_none_or(|t| t == 0), + }; + if wide_untracked && x * 2.0 == w && y * 2.0 == h { + return None; + } + // frame size: two int16u in the note's byte order, then a non-zero marker when there is a frame + let size = ifd.bytes(FOCUS_FRAME_SIZE).and_then(|b| { + let (fw, fh) = (order.read_u16(b, 0)? as f64, order.read_u16(b, 2)? as f64); + let shown = b.get(4..6).is_none_or(|m| m.iter().any(|&v| v != 0)); + (shown && fw >= 1.0 && fh >= 1.0).then_some((fw, fh)) + }); + let (fw, fh) = size.unwrap_or_else(|| { + let side = w.min(h) * DEFAULT_SIDE; + (side, side) + }); + let sensor = Rect { x0: (x - fw / 2.0) / w, y0: (y - fh / 2.0) / h, x1: (x + fw / 2.0) / w, y1: (y + fh / 2.0) / h }; + let name = |v: Option<u8>, f: fn(u8) -> Option<&'static str>| v.and_then(f).map(str::to_string); + let area = name(setting, area_setting_name); + let used = name(block, area_used_name); + // what it followed: the area mode used when it says more than the setting (Animal Eye Tracking on + // Wide), else the tracking setting when it is on + let tracking = used.filter(|u| Some(u) != area.as_ref() && u != "Multi").or_else(|| name(tracking, tracking_name).filter(|t| t != "Off")); + FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() +} + +/// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. +fn decipher(b: u8) -> u8 { + if b >= 249 { + return b; + } + (0..249u32).find(|p| (p * p * p) % 249 == u32::from(b)).and_then(|p| u8::try_from(p).ok()).unwrap_or(b) +} + +fn focus_mode_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Manual", + 2 => "AF-S", + 3 => "AF-C", + 4 => "AF-A", + 6 => "DMF", + 7 => "AF-D", + _ => return None, + }) +} + +fn area_setting_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Wide", + 1 | 9 => "Center", + 3 | 4 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + _ => return None, + }) +} + +fn tracking_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Off", + 1 => "Face tracking", + 2 => "Lock On AF", + _ => return None, + }) +} + +fn area_used_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Multi", + 1 => "Center", + 2 => "Spot", + 3 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + 14 => "Tracking", + 15 => "Face Tracking", + 20 => "Animal Eye Tracking", + 21 => "Human Eye Tracking", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + + fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } + } + + fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } + } + + /// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. + fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } + } + + fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); + } + + #[test] + fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } + } + + #[test] + fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); + } + + #[test] + fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); + } + + #[test] + fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); + } + + #[test] + fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } + } + + #[test] + fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); + } + + #[test] + fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); + } + + #[test] + fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in [ + Value::Undefined(vec![0, 0, 0, 0, 0, 0]), + Value::Undefined(vec![1]), + Value::Short(vec![5]), + Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), + ] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); + } + + #[test] + fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + } + + /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. + fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec<u8> { + let order = ByteOrder::Little; + // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, + // which we only know after writing; write once to learn where the note lands, then rebuild + let build = |note_at: u64| -> Vec<u8> { + let mut note = b"SONY DSC \0\0\0".to_vec(); + let ifd_len = 2 + note_entries.len() * 12 + 4; + let mut tail: Vec<u8> = Vec::new(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = lightcraft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap() + }; + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let file = build(at); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + file + } + + fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() + } + + #[test] + fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); + } + + #[test] + fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } + } + + /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) + /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=<dir>` is the photo folder the paths below are relative + /// to; skipped without it. No user file is part of the repository. + #[test] + fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ( + "2023/11-november/17th/_DSC6954.ARW", + 1, + Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + ( + "2023/11-november/17th/_DSC6894.ARW", + 1, + Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ( + "2020/10-october/31st/DSC00065.ARW", + 8, + Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), + ), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31a35cc65..abf6fd868 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -15,6 +15,7 @@ mod container; pub mod cr3; mod datetime; mod exif; +pub mod focus; mod gpx; mod iptc; pub mod tags; @@ -24,6 +25,7 @@ mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; +pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use lightcraft_geom::{Orientation, Rect}; @@ -142,6 +144,9 @@ pub struct Metadata { pub label: Option<String>, /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. pub regions: Vec<Region>, + /// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option<FocusPoint>, } macro_rules! fill { @@ -205,6 +210,9 @@ impl Metadata { if self.regions.is_empty() { self.regions = other.regions.clone(); } + if self.focus.is_none() { + self.focus = other.focus.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..8c3b75263 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -686,6 +686,7 @@ mod tests { // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. regions: Vec::new(), + focus: None, make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..db5349830 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,23 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Show Focus Point": "Fokuspunkt anzeigen", + "Zoom to Focus Point": "Auf Fokuspunkt zoomen", + "Focus": "Fokus", + "Focus point": "Fokuspunkt", + "Wide": "Breit", + "Center": "Mitte", + "Flexible Spot": "Flexibler Spot", + "Zone": "Feld", + "Expanded Flexible Spot": "Erweiterter flexibler Spot", + "Custom AF Area": "Benutzerdefinierter AF-Bereich", + "Face tracking": "Gesichtsverfolgung", + "Lock On AF": "Lock-on-AF", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Tracking", + "Face Tracking": "Gesichtsverfolgung", + "Animal Eye Tracking": "Tieraugen-Tracking", + "Human Eye Tracking": "Augen-Tracking (Mensch)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..e94a5b377 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,23 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Show Focus Point": "Mostrar punto de enfoque", + "Zoom to Focus Point": "Zoom al punto de enfoque", + "Focus": "Enfoque", + "Focus point": "Punto de enfoque", + "Wide": "Amplio", + "Center": "Centro", + "Flexible Spot": "Punto flexible", + "Zone": "Zona", + "Expanded Flexible Spot": "Punto flexible ampliado", + "Custom AF Area": "Área AF personalizada", + "Face tracking": "Seguimiento de caras", + "Lock On AF": "AF de bloqueo", + "Multi": "Multi", + "Spot": "Punto", + "Tracking": "Seguimiento", + "Face Tracking": "Seguimiento de caras", + "Animal Eye Tracking": "Seguimiento de ojos de animales", + "Human Eye Tracking": "Seguimiento de ojos humanos" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..89b1d309b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,23 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Show Focus Point": "Afficher le point de mise au point", + "Zoom to Focus Point": "Zoomer sur le point de mise au point", + "Focus": "Mise au point", + "Focus point": "Point de mise au point", + "Wide": "Large", + "Center": "Centre", + "Flexible Spot": "Spot flexible", + "Zone": "Zone", + "Expanded Flexible Spot": "Spot flexible étendu", + "Custom AF Area": "Zone AF personnalisée", + "Face tracking": "Suivi des visages", + "Lock On AF": "AF verrouillé", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Suivi", + "Face Tracking": "Suivi des visages", + "Animal Eye Tracking": "Suivi des yeux d’animaux", + "Human Eye Tracking": "Suivi des yeux humains" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..bec052af7 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,23 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Show Focus Point": "フォーカスポイントを表示", + "Zoom to Focus Point": "フォーカスポイントにズーム", + "Focus": "フォーカス", + "Focus point": "フォーカスポイント", + "Wide": "ワイド", + "Center": "中央", + "Flexible Spot": "フレキシブルスポット", + "Zone": "ゾーン", + "Expanded Flexible Spot": "拡張フレキシブルスポット", + "Custom AF Area": "カスタムAFエリア", + "Face tracking": "顔追尾", + "Lock On AF": "ロックオンAF", + "Multi": "マルチ", + "Spot": "スポット", + "Tracking": "トラッキング", + "Face Tracking": "顔トラッキング", + "Animal Eye Tracking": "動物瞳トラッキング", + "Human Eye Tracking": "人物瞳トラッキング" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..e64080fa3 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,23 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Show Focus Point": "Mostrar ponto de foco", + "Zoom to Focus Point": "Zoom no ponto de foco", + "Focus": "Foco", + "Focus point": "Ponto de foco", + "Wide": "Amplo", + "Center": "Centro", + "Flexible Spot": "Ponto flexível", + "Zone": "Zona", + "Expanded Flexible Spot": "Ponto flexível expandido", + "Custom AF Area": "Área de AF personalizada", + "Face tracking": "Rastreamento de rosto", + "Lock On AF": "AF com travamento", + "Multi": "Multi", + "Spot": "Ponto", + "Tracking": "Rastreamento", + "Face Tracking": "Rastreamento de rosto", + "Animal Eye Tracking": "Rastreamento de olhos de animais", + "Human Eye Tracking": "Rastreamento de olhos humanos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..7e1de22df 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,23 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Show Focus Point": "Показать точку фокусировки", + "Zoom to Focus Point": "Увеличить по точке фокусировки", + "Focus": "Фокус", + "Focus point": "Точка фокусировки", + "Wide": "Широкая", + "Center": "Центр", + "Flexible Spot": "Гибкая точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Расширенная гибкая точка", + "Custom AF Area": "Своя зона АФ", + "Face tracking": "Отслеживание лиц", + "Lock On AF": "АФ с захватом", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Слежение", + "Face Tracking": "Отслеживание лиц", + "Animal Eye Tracking": "Слежение за глазами животных", + "Human Eye Tracking": "Слежение за глазами людей" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5f884dfb2 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,23 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Show Focus Point": "Показати точку фокусування", + "Zoom to Focus Point": "Збільшити до точки фокусування", + "Focus": "Фокус", + "Focus point": "Точка фокусування", + "Wide": "Широка", + "Center": "Центр", + "Flexible Spot": "Гнучка точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Розширена гнучка точка", + "Custom AF Area": "Власна зона АФ", + "Face tracking": "Відстеження облич", + "Lock On AF": "АФ із захопленням", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Стеження", + "Face Tracking": "Відстеження облич", + "Animal Eye Tracking": "Стеження за очима тварин", + "Human Eye Tracking": "Стеження за очима людей" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..b5e340a5c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,23 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Show Focus Point": "显示对焦点", + "Zoom to Focus Point": "缩放至对焦点", + "Focus": "对焦", + "Focus point": "对焦点", + "Wide": "广域", + "Center": "中间", + "Flexible Spot": "自由点", + "Zone": "区", + "Expanded Flexible Spot": "扩展自由点", + "Custom AF Area": "自定义AF区域", + "Face tracking": "人脸追踪", + "Lock On AF": "锁定AF", + "Multi": "多重", + "Spot": "点", + "Tracking": "追踪", + "Face Tracking": "人脸追踪", + "Animal Eye Tracking": "动物眼部追踪", + "Human Eye Tracking": "人眼追踪" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..af33e1f94 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,23 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Show Focus Point": "顯示對焦點", + "Zoom to Focus Point": "縮放至對焦點", + "Focus": "對焦", + "Focus point": "對焦點", + "Wide": "廣域", + "Center": "中間", + "Flexible Spot": "自由點", + "Zone": "區", + "Expanded Flexible Spot": "擴充自由點", + "Custom AF Area": "自訂AF區域", + "Face tracking": "臉部追蹤", + "Lock On AF": "鎖定AF", + "Multi": "多重", + "Spot": "點", + "Tracking": "追蹤", + "Face Tracking": "臉部追蹤", + "Animal Eye Tracking": "動物眼部追蹤", + "Human Eye Tracking": "人眼追蹤" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..bfd72a9c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ mod tests_dialogs; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_focus_point; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..43703ed9c 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -132,6 +132,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", + "view.focusPoint", + "view.zoomToFocus", "view.filmstrip", "view.histogram", "view.navigator", @@ -315,6 +317,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), "view.faceBoxes" => Some(u.face_boxes), + "view.focusPoint" => Some(u.settings.focus_point), + "view.zoomToFocus" => Some(u.settings.zoom_to_focus), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..3e9b71c1f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -65,6 +65,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.people", "People", None, "View"), ("view.person", "Show Person", None, ""), ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"), + ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -486,6 +488,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "view.compare" => crate::panels::compare::enter_compare(app), + "view.focusPoint" => { + // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. + app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); + Ok(json!({"on": app.ui.settings.focus_point})) + } + "view.zoomToFocus" => { + // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. + app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); + Ok(json!({"on": app.ui.settings.zoom_to_focus})) + } "view.faceBoxes" => { app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); Ok(json!({"show": app.ui.face_boxes})) @@ -707,9 +719,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "view.zoomToggle" => { - // the same ratio a click on the photo zooms to - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus + // point when the photo has one (View ▸ Zoom to Focus Point) + let zoom_in = app.ui.zoom == Zoom::Fit; + app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; app.ui.zoom_anim = true; + if zoom_in + && app.ui.settings.zoom_to_focus + && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) + { + app.ui.pan = pan; + return Some(Ok(json!({"focus": [pan.0, pan.1]}))); + } Ok(Value::Null) } "view.clickZoom" => { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4c2855718..048852c69 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -254,6 +254,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if photo.flag == Flag::Reject { p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); } + if app.ui.settings.focus_point { + super::detail::draw_focus_point(&p, display, &photo, &format!("compare:focusPoint:{}", id.0)); + } let active = app.session.selection.active == Some(id); if active { p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 66f9c7997..cb2a31dea 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -236,6 +236,63 @@ pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { [px(w), px(h)] } +/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the +/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. +pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option<lightcraft_geom::Rect> { + let f = photo.meta.focus.as_ref()?; + let d = &photo.develop; + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); + let [w, h] = output_px(&frame); + let to_out = frame.out_to_norm(w, h).inverse()?; + // stored on the upright photo: the user's Rotate / Flip on top, then the crop + let r = d.orientation.map_norm_rect(f.rect); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { + let q = to_out.apply(c); + (q.x / w as f64, q.y / h as f64) + }); + let xs = corners.map(|c| c.0); + let ys = corners.map(|c| c.1); + let out = lightcraft_geom::Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { + return None; + } + // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) + let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); + ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) +} + +/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. +pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { + let r = focus_box(app.session.catalog.photo(id)?)?; + Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner +/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. +/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:<id>`). +pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { + let Some(r) = focus_box(photo) else { return }; + let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); + let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); + let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); + let arm = (b.width().min(b.height()) * 0.3).max(5.0); + let green = Color32::from_rgb(90, 230, 110); + for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { + let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; + p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); + p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); + } + let c = b.center(); + p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); + p.circle_filled(c, 1.5, green); + register(p.ctx(), key, b); +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -722,6 +779,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { None => {} } } + if app.ui.settings.focus_point && !crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); + } // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..e0b032385 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -760,9 +760,28 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { } }); } + // where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the + // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits + if let Some(text) = focus_text(m.focus.as_ref()) { + ui.add_space(3.0); + let r = ui.horizontal_wrapped(|ui| { + ui.label(dim(crate::i18n::tr("Focus"))); + ui.add_space(12.0); + ui.label(egui::RichText::new(text).size(12.0).color(t.text)); + }); + register(ui.ctx(), "info:focus", r.response.rect); + } }); } +/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye +/// Tracking"), or `None` when the photo has no focus point. +pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option<String> { + let f = f?; + let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); + Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) +} + fn human_size(bytes: u64) -> String { match bytes { b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..46349e866 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -275,6 +275,11 @@ pub struct AppSettings { /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, + /// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`). + pub focus_point: bool, + /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point + /// when the photo has one (`view.zoomToFocus`). + pub zoom_to_focus: bool, } impl Default for AppSettings { @@ -300,6 +305,8 @@ impl Default for AppSettings { light_theme: LightTheme::Silver, favorite_themes: Vec::new(), display_profile: String::new(), + focus_point: false, + zoom_to_focus: true, } } } diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs new file mode 100644 index 000000000..fa9ae60a0 --- /dev/null +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -0,0 +1,158 @@ +//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the +//! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel +//! names the focus mode. + +use std::sync::Arc; +use std::time::Duration; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Zoom; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn focus(x0: f64, y0: f64, x1: f64, y1: f64) -> FocusPoint { + FocusPoint { + rect: lightcraft_geom::Rect { x0, y0, x1, y1 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: Some("Animal Eye Tracking".into()), + } +} + +/// The demo library in Detail with the Info panel, its first photo active and given `f` as its focus point. +fn detail_with_focus(f: Option<FocusPoint>) -> (Headless, PhotoId) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = *h.app.session.visible_cloned().first().expect("a demo photo"); + let mut meta = h.app.session.catalog.photo(id).expect("photo").meta.clone(); + meta.focus = f; + h.app.session.catalog.apply(Op::SetMeta { id, meta: Box::new(meta) }).expect("set meta"); + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "info"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + (h, id) +} + +fn widget(h: &Headless, id: &str) -> Option<egui::Rect> { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, cmd: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": cmd, "params": params}), T); + assert_eq!(r["ok"], true, "{cmd}: {r}"); + h.settle(SETTLE); + r["result"].clone() +} + +/// Where `(x, y)` (fractions of the drawn photo) is on screen. +fn on_image(h: &Headless, x: f32, y: f32) -> egui::Pos2 { + let img = h.app.image_rect.expect("the photo is drawn"); + egui::pos2(img.left() + x * img.width(), img.top() + y * img.height()) +} + +#[test] +fn the_overlay_draws_on_the_focus_point_and_follows_rotation() { + let (mut h, _) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + assert!(widget(&h, "canvas:focusPoint").is_none(), "off by default"); + // the Info panel names how the camera focused + assert!(widget(&h, "info:focus").is_some(), "no Focus row in the Info panel"); + assert_eq!(run(&mut h, "view.focusPoint", json!({}))["on"], true); + let b = widget(&h, "canvas:focusPoint").expect("the focus box is drawn"); + let want = on_image(&h, 0.23, 0.33); + assert!((b.center() - want).length() < 1.5, "box at {:?}, want {want:?}", b.center()); + let img = h.app.image_rect.unwrap(); + assert!((b.width() - 0.06 * img.width()).abs() < 1.5, "{b:?} in {img:?}"); + // Rotate Right turns the photo 90° clockwise: the upright point (x, y) is drawn at (1 − y, x) + run(&mut h, "photo.rotateRight", json!({})); + let b = widget(&h, "canvas:focusPoint").expect("still drawn after rotating"); + let want = on_image(&h, 0.67, 0.23); + assert!((b.center() - want).length() < 1.5, "rotated box at {:?}, want {want:?}", b.center()); + // and the command turns it off again + assert_eq!(run(&mut h, "view.focusPoint", json!({"on": false}))["on"], false); + assert!(widget(&h, "canvas:focusPoint").is_none()); +} + +#[test] +fn toggle_zoom_centres_on_the_focus_point() { + let (mut h, _) = detail_with_focus(Some(focus(0.70, 0.20, 0.74, 0.26))); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + let r = h.request("ui.key", json!({"key": "z"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{:?}", h.app.ui.zoom); + let (u, v) = h.app.ui.pan; + assert!((u - 0.72).abs() < 1e-3 && (v - 0.23).abs() < 1e-3, "pan {u} {v}"); + // the zoomed view is centred on it (the canvas centre shows the focus point) + let canvas = h.app.canvas_rect.expect("canvas"); + let at = on_image(&h, 0.72, 0.23); + let img = h.app.image_rect.unwrap(); + // (unless the photo's edge stops the pan: here the point is well inside, so it is centred) + assert!(img.width() > canvas.width(), "zoomed in"); + assert!((at.x - canvas.center().x).abs() < 3.0, "{at:?} vs {:?}", canvas.center()); + // Z again: back to Fit; with Zoom to Focus Point off, Z keeps the current pan + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(run(&mut h, "view.zoomToFocus", json!({}))["on"], false); + h.app.ui.pan = (0.5, 0.5); + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); +} + +#[test] +fn without_a_focus_point_zoom_and_info_are_unchanged() { + let (mut h, _) = detail_with_focus(None); + run(&mut h, "view.focusPoint", json!({"on": true})); + assert!(widget(&h, "canvas:focusPoint").is_none()); + assert!(widget(&h, "info:focus").is_none()); + h.app.ui.pan = (0.4, 0.6); + run(&mut h, "view.zoomToggle", json!({})); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_))); + assert_eq!(h.app.ui.pan, (0.4, 0.6), "no focus point: Z zooms where it did before"); +} + +/// The box maps through the crop (and a point cropped away gives no box, so Z zooms as before). +#[test] +fn focus_box_follows_the_crop_and_drops_a_point_cropped_away() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 6000, 4000, "2026-10-13T00:00:00"); + p.meta.focus = Some(focus(0.70, 0.45, 0.80, 0.55)); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.70).abs() < 1e-9 && (b.y1 - 0.55).abs() < 1e-9, "{b:?}"); + // keep the right half: x 0.5..1 becomes 0..1 + let mut d = (*p.develop).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.5, y0: 0.0, x1: 1.0, y1: 1.0 }; + p.develop = Arc::new(d.clone()); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.40).abs() < 1e-6 && (b.x1 - 0.60).abs() < 1e-6, "{b:?}"); + // keep the left half: the point is gone + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }; + p.develop = Arc::new(d); + assert!(crate::panels::detail::focus_box(&p).is_none()); + // a zero-size photo or a garbage box never panics + let mut z = Photo::new(PhotoId(2), Source::Demo { scene: 1 }, "z.jpg", "JPEG", 0, 0, "2026-10-13T00:00:00"); + z.meta.focus = Some(focus(f64::NAN, 0.0, f64::INFINITY, 1.0)); + assert!(crate::panels::detail::focus_box(&z).is_none()); + z.meta.focus = Some(focus(0.4, 0.4, 0.6, 0.6)); + assert!(crate::panels::detail::focus_box(&z).is_some()); +} + +#[test] +fn compare_and_survey_draw_each_photos_focus_point() { + let (mut h, id) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo"); + run(&mut h, "view.focusPoint", json!({"on": true})); + run(&mut h, "library.select", json!({"ids": [id.0, other.0], "active": id.0})); + for view in ["view.compare", "view.survey"] { + run(&mut h, view, json!({})); + assert!(widget(&h, &format!("compare:focusPoint:{}", id.0)).is_some(), "{view}: no focus box"); + assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5ecf41b0d..185ad2678 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,6 +103,11 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in `photo.inspect`: `{rect: {x0, y0, +x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none). +`view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}` +(on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set). + The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. diff --git a/docs/focus-points.md b/docs/focus-points.md new file mode 100644 index 000000000..c6993948f --- /dev/null +++ b/docs/focus-points.md @@ -0,0 +1,79 @@ +# Focus points + +Most cameras record where they focused. LightCraft reads that point from the file, draws it over the photo and +zooms to it, so checking whether the subject is sharp takes one key while culling. Lightroom shows no focus points +(photographers add plug-ins for it); this goes beyond parity (tracker row `LR-VIEW-FOCUSPOINT`). + +## Using it + +- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in + Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by + default; the choice is saved with the app's settings. +- **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in + centred on the focus point instead of the last position. A click on the photo still zooms where you click, and a + photo without a focus point zooms as before. On by default because it changes nothing for photos without a point, + and for those with one the focus point is what a 1:1 check is for. In Compare the two panes share one pan, so Z + centres on the active photo's point; per-pane focus zoom is a follow-up. +- **Info panel**: a *Focus* row says how the camera focused, e.g. `AF-C · Wide · Animal Eye Tracking` (focus mode, + the AF area set on the camera, and what it tracked when the camera says). +- **Agents**: `photo.inspect` (and the MCP `lightcraft://photo/active` resource) returns it as `meta.focus`: + `{rect: {x0, y0, x1, y1}, mode?, area?, tracking?}`, the box normalized to the upright (EXIF-oriented) photo, y down. + Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it + zoomed to one. + +The point is read when a photo is imported. Photos imported earlier get it with **Photo ▸ Reload from Disk** +(`photo.reload`), which fills camera data the catalog lacks; it never replaces a point already stored. It is +read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read +unchanged (no catalog format change). + +## Cameras + +Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** +(`lightcraft_meta::focus::SONY_MODELS`). Other Sony bodies store the same tags, but a body is added only after its +files are checked, so an unchecked one shows nothing rather than a misleading box. Fujifilm, Canon, Nikon, OM System +and Panasonic record similar data and are later steps. + +### Sony layout and evidence + +Tag identities and value names come from the public ExifTool tag-name documentation (Sony MakerNotes table). No +third-party code was read or ported. Every field was confirmed on real files by comparing LightCraft's decoder with +ExifTool's output, run as a black box: 333 ARWs sampled across four years of a photographer's library (180 α1, +153 A7S III; 12 portrait or upside-down, 54 in APS-C crop or reduced-size modes), with no mismatch in the point, frame or names. + +| MakerNote tag | Layout | Notes | +|---|---|---| +| `0x2027` FocusLocation | 4 × int16u: frame width, frame height, focus x, focus y | in the sensor's landscape frame (a portrait shot still says 8640 × 5760); the frame is the image size (5616 × 3744 in α1 APS-C mode) | +| `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height, then 2 bytes that are zero when no frame was shown | zero in manual focus; one AF-C α1 file had no frame (a small box is drawn: 3 % of the short side) | +| `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | | +| `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | | +| `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | | +| `0x9402` bytes 0x16 / 0x17 | enciphered: focus mode (low 7 bits) and the AF area mode used (0 Multi, 2 Spot, 3 Flexible Spot, 11 Zone, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual…) | read only when its focus mode equals `0x201b`'s | + +The documentation says the `0x94xx` blocks use "a simple substitution cipher". On the files each byte `p < 249` is +stored as `p³ mod 249` (bytes 249–255 unchanged): ExifTool's decoded AFAreaMode 20 is stored as `0x20`, a decoded +`0x83` as `0x77`. Cubing is a bijection modulo 249 (= 3 × 83), so the inverse is a table lookup. + +On the α1 files the box sits on the subject: drawn on the embedded previews, it lands on the bird's eye in eye-AF +shots and follows spot, zone, wide and tracking shots, in portrait and landscape. + +### When there is no real point + +Nothing is stored (so nothing is drawn and Z zooms as before) when: + +- the body is not one of the checked ones, the tags are missing or malformed, the frame size is zero, or the centre + lies outside its frame; +- **manual focus** (`0x201b` = 0, or the block's area mode is Manual). The camera still records where the AF frame was + parked: 83 of the 91 sampled manual-focus files carry an off-centre location that says nothing about focus; +- the location is the **exact frame centre while the area used was Multi** (Wide with nothing tracked; without the + block, Wide with tracking off). The wide area reports the centre when it picked no particular spot; 7 of the 34 + sampled autofocus Multi files did. A centred Spot, Zone or Flexible Spot frame is a choice the photographer made, and + is kept (49 of the 111 sampled spot and zone files were centred, the camera's default position). + +DMF (AF, then manual touch-up) keeps its point. + +## Code + +- `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests, + including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=<photo folder>`). +- `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's + rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. diff --git a/docs/mcp.md b/docs/mcp.md index e09138cfb..d3d23e325 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -208,7 +208,7 @@ still reaches every command. |---|---| | `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | | `lightcraft://photos` | Photos in the current view (`catalog.query`) | -| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md)) | | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..45bdaac01 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-VIEW-FOCUSPOINT: camera focus point overlay and zoom (#1480); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -28,7 +28,7 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 174. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -130,6 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `crates/meta/src/focus.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import (Reload from Disk fills older photos) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | @@ -789,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it (#1480) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..7bf854d0e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,16 @@ ## 2026-10-13 +### Where the camera focused +- **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare + and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`). + Lightroom has no such view. +- **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key + (View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before. +- Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame + centre show nothing. Photos imported earlier get their point with Photo ▸ Reload from Disk. More cameras will + follow. Details: [focus-points.md](focus-points.md). + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From 8c64c467f5f0195cef61868235f10003268e33af Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Sat, 10 Oct 2026 23:17:31 +0200 Subject: [PATCH 101/315] LR-EDIT-OPTICS-PROFILE: Sony lateral CA planes off by default (the demosaic already hides most of it) Verified on 54 ILCE-7RM6 photos, the CA planes made the render worse: our demosaic already hides about 70 % of the lens CA (the render shows 0.29x the raw-CFA CA) while the post-demosaic warp moves R and B by 0.84x the table, so the corner R/G and B/G CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at 1440 px; only the 16-35 mm improved. warp_opcode takes a lateral_ca flag; lens_opcodes passes false, so a Sony ARW gets the one-plane distortion warp. The CA conversion stays live and tested (FF and APS-C examples, both lossless corpus files' tables). The follow-up is to warp the CFA colour planes before demosaic. The doc, tracker (docs/parity-checklist.md), docs/gaps.md, docs/raw-parity.md and whats-new say distortion applied, CA parsed and off by default, vignetting not applied; the one-plane default costs 24 -> 45 ms in the sample stage at 1920 px (three planes: 112 ms). Co-Authored-By: Claude <noreply@anthropic.com> --- crates/raw/src/vendor/arw.rs | 7 +- crates/raw/src/vendor/arw_lens_rm6.rs | 118 +++++++++++++++++++------ crates/raw/tests/corpus.rs | 50 ++++++----- docs/gaps.md | 4 +- docs/parity-checklist.md | 8 +- docs/raw-parity.md | 4 +- docs/sony-ilce7rm6-lens-corrections.md | 65 ++++++++------ docs/whats-new.md | 20 ++--- xtask/src/main.rs | 2 +- 9 files changed, 180 insertions(+), 98 deletions(-) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index d6715d49e..49543c4fe 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -875,7 +875,8 @@ mod tests { } /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as - /// one plane, or three with the CA table; another body's tables don't (see [`super::super::arw_lens_rm6`]). + /// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see + /// [`super::super::arw_lens_rm6`]). #[test] fn lens_tables_reach_opcode_list3() { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; @@ -904,10 +905,10 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() }; for mode in [Mode::Header, Mode::Full] { - for (ca, n) in [(false, 1), (true, 3)] { + for ca in [false, true] { let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3; let ops = list3("ILCE-7RM6"); - assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == n), "{mode:?} {ca}: {ops:?}"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?} {ca}: {ops:?}"); assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); } } diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs index 37d76fdd6..cc3a23e61 100644 --- a/crates/raw/src/vendor/arw_lens_rm6.rs +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -29,11 +29,13 @@ //! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median //! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. //! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` -//! the red (blue) half on the same knots, as the warp's R and B planes. The unit is **provisional**: the -//! measurement is borderline (0.399 px worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ -//! to 7.2·10⁶, about ±30 % scene bias), but it cuts the rms CA over 46 photos from 0.079 to 0.052 px. When a CA -//! value is implausible (beyond [`CA_MAX_RELATIVE`]) or a colour plane fails the fit gate above, both colour -//! planes are dropped and G alone is applied to all three channels. +//! the red (blue) half on the same knots. The unit is **provisional**: the measurement is borderline (0.399 px +//! worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ to 7.2·10⁶, about ±30 % scene bias), +//! but it cuts the rms raw-CFA CA over 46 photos from 0.079 to 0.052 px. The table is parsed and [`warp_opcode`] +//! converts it to the warp's R and B planes, but they are **not emitted by default**: [`lens_opcodes`] passes +//! `lateral_ca: false`, because applied after demosaic they made the render's CA worse (numbers there). To turn +//! them on in code, pass `true` there. When on, a CA value beyond [`CA_MAX_RELATIVE`] or a colour plane that fails +//! the fit gate above drops both colour planes, and G alone is applied to all three channels. //! //! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the //! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement @@ -61,7 +63,8 @@ const DISTORTION_UNIT: f64 = 15000.0; /// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). const CA_UNIT: f64 = 3.36e6; /// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame -/// corner; the measured tables stay below 790 / 3.36·10⁶ ≈ 2.4·10⁻⁴. A table beyond it is refused. +/// corner; the largest value in the 54 measured photos' tables is 1724 (FE 24–70 mm), 1724 / 3.36·10⁶ ≈ 5.1·10⁻⁴. +/// A table beyond it is refused. const CA_MAX_RELATIVE: f64 = 2e-3; /// Crop border radii scanned by the frame-filling zoom. const ZOOM_SCAN: usize = 4096; @@ -151,12 +154,13 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> { } /// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default -/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B, or G alone (applied to -/// all three) when the CA table is absent or a colour plane's fit fails. `None` when the distortion table is absent or +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B with `lateral_ca`, or G +/// alone (applied to all three) without it, or when the CA table is absent, implausible or a colour plane's fit +/// fails. `None` when the distortion table is absent or /// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is /// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in /// lossless files, reaches radii that are never shown). -pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option<Opcode> { +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option<Opcode> { let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; let (aw, ah) = (active.width as f64, active.height as f64); if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { @@ -186,16 +190,22 @@ pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: }; // a NaN fails the comparison: refused too let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); - let rgb = t.ca.as_ref().filter(|(red, blue)| plausible(red) && plausible(blue)); + let rgb = t.ca.as_ref().filter(|(red, blue)| lateral_ca && plausible(red) && plausible(blue)); let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) } /// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose /// tables are not verified. +/// +/// Distortion only: the lateral CA planes are off (`lateral_ca: false`). Our demosaic already hides about 70 % of the +/// lens CA (the render shows 0.29 × the CA measured on the raw CFA), while the warp, which runs after demosaic, moves +/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G +/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm +/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here. pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec<Opcode> { let Some(spacing) = knot_spacing(model) else { return Vec::new() }; - warp_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() + warp_opcode(&read_tables(raw), spacing, active, crop, false).into_iter().collect() } #[cfg(test)] @@ -222,6 +232,12 @@ mod tests { 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]; + /// The APS-C lossless corpus file `arw-sony-ilce7rm6-apsc-land-lossless.arw` (same scene; its tables differ slightly). + const APSC_LOSSLESS_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -511, -583, -583, -583, -583, -583, -583]; + const APSC_LOSSLESS_CA: [i16; 33] = [ + 22, 792, 766, 738, 706, 670, 630, 588, 548, 514, 488, 476, -436, -408, -376, -334, -290, -234, -178, -118, -62, -12, 30, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; /// ARW6 geometry (the decoder's raw image size and default crop). const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; @@ -325,7 +341,7 @@ mod tests { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); let t = read_tables(&ifd); assert!(t.distortion.is_none(), "{what}"); - assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); } // not an integer array, or absent @@ -353,7 +369,7 @@ mod tests { ("not finite", vec![0.0, f64::NAN, 20.0]), ("infinite", vec![0.0, f64::INFINITY, 20.0]), ] { - assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); } // the same through the IFD, as 64-bit values for v in [i64::MAX / 4, i64::MIN, i64::MAX] { @@ -370,37 +386,37 @@ mod tests { ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), ] { - assert!(warp_opcode(&ff, knots, active, crop).is_none(), "{what}"); + assert!(warp_opcode(&ff, knots, active, crop, false).is_none(), "{what}"); } } #[test] fn ff_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&FF_DISTORTION)); - assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false), [0.4996005991, 0.5], &[FF_G]); } #[test] fn apsc_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&APSC_DISTORTION)); - let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, false); assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); } #[test] fn ca_table_shifts_red_and_blue_planes() { let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); - let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); assert_ne!(planes[0][0], planes[1][0]); // G is the distortion plane alone - let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP); + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); } #[test] fn apsc_ca_planes_match_the_measured_warp() { let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); - assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, true), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); } #[test] @@ -408,17 +424,20 @@ mod tests { let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); let t = read_tables(&ifd); assert!(t.ca.is_some() && t.distortion.is_none()); - assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none()); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty()); // an all-zero distortion table counts as absent let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; - assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none()); } #[test] fn rejected_ca_keeps_the_single_green_plane() { - let green = - assert_warp(warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + let green = assert_warp( + warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false), + [0.4996005991, 0.5], + &[FF_G], + ); let ok = values(&FF_HQ_CA); let huge = vec![(i64::MAX / 4) as f64; 16]; for (what, red, blue) in [ @@ -429,7 +448,7 @@ mod tests { ("one knot", vec![100.0], vec![100.0]), ] { let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; - let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP) else { panic!("{what}: no warp") }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true) else { panic!("{what}: no warp") }; assert_eq!(planes, green, "{what}"); } // through the IFD: an odd count, or values that overflow the fit @@ -443,15 +462,56 @@ mod tests { max[0] = 32; for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); - let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); - assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes == &green), "{what}: {ops:?}"); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes == &green), "{what}: {op:?}"); } // values up to the bound are kept let mut at_bound = vec![6720; 33]; at_bound[0] = 32; let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{op:?}"); + } + + /// The default: a valid CA table is read and converts (above), but `lens_opcodes` emits the G plane alone. + #[test] + fn lens_opcodes_emit_the_green_plane_only() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]); + let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}"); let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); - assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 3), "{ops:?}"); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert_eq!(ops.first(), green.as_ref()); + } + + /// The corpus lossless files' own tables on their decoded geometry (padded active areas; the corpus test checks + /// the G plane from the files): R, G, B with the CA planes enabled. + #[test] + fn lossless_examples_ca_planes_match_the_measured_warp() { + let ff = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let ff_active = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + assert_warp( + warp_opcode(&ff, knots(), ff_active, FF_CROP, true), + [0.488670768629749, 0.4653969582810102], + &[ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], + ); + let apsc = read_tables(&sony_lens_ifd(&APSC_LOSSLESS_DISTORTION, &APSC_VIGNETTING, &APSC_LOSSLESS_CA, [0, 2, 1])); + let apsc_active = Rect { x: 0, y: 0, width: 6656, height: 4608 }; + assert_warp( + warp_opcode(&apsc, knots(), apsc_active, APSC_CROP, true), + [0.49278737791134486, 0.4743868026915563], + &[ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], + ); } /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well @@ -461,7 +521,7 @@ mod tests { fn padded_active_area_gates_only_the_displayed_radii() { let strong: Vec<f64> = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; - let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP, false); assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); } @@ -472,7 +532,7 @@ mod tests { assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); - assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::<Vec<_>>()); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::<Vec<_>>()); assert_eq!(ops.len(), 1); } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index c32857100..b4041178b 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,27 +626,24 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } -/// Sony ILCE-7RM6: the distortion and lateral CA tables in the raw IFD (`0x7037`, `0x7035`) become a three-plane -/// (R, G, B) `WarpRectilinear` in `OpcodeList3` (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 +/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a one-plane `WarpRectilinear` in +/// `OpcodeList3` (`vendor::arw_lens_rm6`; the lateral CA table is read but its planes are off by default, and their +/// numbers for these files are checked in `arw_lens_rm6`'s unit tests). Expected: the conversion recipe measured on 54 /// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and -/// default crop. Both files carry distortion correction off in camera; the table is applied anyway. +/// default crop. All four files carry distortion correction off in camera; the table is applied anyway. #[test] fn corpus_sony_lens_tables_become_opcodes() { let dir = corpus_root().join("raw"); - // (file, active area, default crop x, y, width, height, opcode centre, R, G, B planes k0..k3) + // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) let cases = [ - // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked - // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) + // not pinned in `cargo xtask corpus` (at most two pins per PR; the ARW 6.0 decoder pins the two Compressed files + // below): checked only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) ( "arw-sony-ilce7rm6-ff-land-lossless.arw", (10240, 7168), (12, 8, 9984, 6656), [0.488670768629749, 0.4653969582810102], - [ - [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], - [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], - [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], - ], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], ), // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) ( @@ -654,11 +651,24 @@ fn corpus_sony_lens_tables_become_opcodes() { (6656, 4608), (16, 10, 6528, 4352), [0.49278737791134486, 0.4743868026915563], - [ - [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], - [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], - [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], - ], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_FF_land_RAW-C.ARW`, CC0): the same scene as the full-frame + // lossless file, its table one step off at knot 12 (268, not 267), on the decoder's unpadded geometry + ( + "arw6-sony-ilce7rm6-ff-land-c.arw", + (10016, 6672), + (12, 8, 9984, 6656), + [0.49960059910134796, 0.5], + [0.9714600903266707, 0.02234210164294959, 0.001142578769532717, 0.005292120046651031], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_APSC_land_RAW-C-HD.ARW`, CC0) + ( + "arw6-sony-ilce7rm6-apsc-land-hq.arw", + (6592, 4372), + (16, 10, 6528, 4352), + [0.4975724472765893, 0.5], + [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468], ), ]; let mut seen = 0; @@ -687,12 +697,10 @@ fn corpus_sony_lens_tables_become_opcodes() { }) .collect(); let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; - assert_eq!(planes.len(), 3, "{name}: R, G, B planes"); + assert_eq!(planes.len(), 1, "{name}: one plane (G; lateral CA off by default)"); assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); - for (plane, (p, k)) in ["R", "G", "B"].iter().zip(planes.iter().zip(k)) { - for (i, want) in k.iter().enumerate() { - assert!((p[i] - want).abs() < 1e-6, "{name}: {plane} k{i} {}, want {want}", p[i]); - } + for (i, want) in k.iter().enumerate() { + assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); } seen += 1; } diff --git a/docs/gaps.md b/docs/gaps.md index 2a80452ac..4812de920 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony in-file distortion on two bodies (ILCE-7RM4A; ILCE-7RM6, also lateral CA), each with its own conventions; other Sony bodies' tables not applied until verified | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony in-file distortion on two bodies (ILCE-7RM4A, ILCE-7RM6), each with its own conventions; the ILCE-7RM6's lateral CA table is parsed but off by default (it overshoots after demosaic); other Sony bodies' tables not applied until verified | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | @@ -89,7 +89,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Compressed ORF entry links the two Olympus patents that describe the method | | 2026-10-11 | minor | Gap 14: `crs:HDREditMode` is read from XMP (#663); the headroom limit and SDR rendition still aren't | | 2026-10-11 | minor | Gaps 2 and 10 shrink: EOS R5 Mark II CR3 decodes and is verified (fourth CR3 body, Canon 12 verified, 235 models, ~42% on a verified body); CR3 gain +6.5 → +6.2 points | -| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion and lateral CA added next to the ILCE-7RM4A's (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | +| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b511764da..85b1039fc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 52 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 533 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 173. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion and lateral CA tables (`0x7037`, `0x7035`) as one three-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's CA unit is provisional and, applied after demosaic, raises the render's corner CA (rms 0.032 → 0.052 px, within the 0.3 px budget); its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion table as a one-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's lateral CA table (`0x7035`) is parsed and converts to red and blue planes but is off by default: applied after demosaic it raised the render's corner CA (rms 0.032 → 0.052 px, max 0.125 → 0.219 px), because our demosaic already hides about 70 % of the lens CA; correcting it before demosaic is the follow-up, and its unit is provisional; its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d07753b6e..614294086 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -220,7 +220,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table too (provisional unit, applied after demosaic), not its raw-IFD vignetting table | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table is parsed but off by default (it overshoots after demosaic), its raw-IFD vignetting table not applied | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -282,6 +282,6 @@ Beyond decoding: | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | -| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion and lateral CA next to the ILCE-7RM4A's distortion; lens score unchanged (~10%: one more body) | +| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/sony-ilce7rm6-lens-corrections.md b/docs/sony-ilce7rm6-lens-corrections.md index 64963fcac..d59723ac4 100644 --- a/docs/sony-ilce7rm6-lens-corrections.md +++ b/docs/sony-ilce7rm6-lens-corrections.md @@ -1,17 +1,18 @@ # Sony ARW lens corrections Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral -chromatic aberration (CA). LightCraft reads them and turns distortion and lateral CA into a DNG `WarpRectilinear` -opcode, so they reach the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp -and Panasonic's RW2 distortion data. Vignetting is read but not applied (below). The conversion is verified on the -**ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions ([Two conventions](#two-conventions)), -and other Sony bodies' tables give no correction until they are verified. +chromatic aberration (CA). LightCraft reads them and turns distortion into a DNG `WarpRectilinear` opcode, so it +reaches the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp and Panasonic's +RW2 distortion data. Lateral CA is parsed and converts to the warp's red and blue planes, but it is **off by default**: +applied after demosaic it made the render's CA worse (Verification). Vignetting is read but not applied (below). The +conversion is verified on the **ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions +([Two conventions](#two-conventions)), and other Sony bodies' tables give no correction until they are verified. The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider -scales the warp (all three colour planes) as for DNG and RW2. +scales the warp as for DNG and RW2. ## The tables @@ -59,7 +60,8 @@ None of this is documented; it was measured against the camera's own JPEGs (meth best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to - 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. **Not applied by default** + (Verification). - **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % @@ -77,7 +79,7 @@ None of this is documented; it was measured against the camera's own JPEGs (meth (DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): 1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is - `G(r) = S(r / z)`. Red and blue: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. + `G(r) = S(r / z)`. Red and blue, when the CA planes are on: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. 2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). 3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop @@ -86,11 +88,13 @@ None of this is documented; it was measured against the camera's own JPEGs (meth Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm example in the tests. -4. Planes `[R, G, B]`; when the CA table is absent or malformed, a CA value is implausible (|c| above 2·10⁻³ of the - radius, i.e. 6720; the largest value in the 54 photos' tables is 1724) or a colour plane fails the gate, G alone is applied to all - three channels. No opcode when the distortion table is absent or all zero (CA alone is never applied: it is - relative to green), the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo - uncorrected rather than wrongly warped. +4. **By default one plane, G**, applied to all three channels: `lens_opcodes` calls `warp_opcode(…, lateral_ca: + false)`. With `lateral_ca: true` (a code change) the planes are `[R, G, B]`, unless the CA table is absent or + malformed, a CA value is implausible (|c| above 2·10⁻³ of the radius, i.e. 6720; the largest value in the 54 + photos' tables is 1724) or a colour plane fails the gate: then G alone again. The G plane is the same either way. + No opcode when the distortion table is absent or all zero (CA alone is never applied: it is relative to green), + the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo uncorrected rather than + wrongly warped. The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with the photo (portrait files are covered) and is carried by DNG export, as RW2's is. @@ -120,7 +124,7 @@ FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventi | Unit | 15000 | 16384 | | Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | | Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | -| Tables | distortion (16 knots; 11 in APS-C) and lateral CA; vignetting read, not applied | distortion, 16 entries only | +| Tables | distortion (16 knots; 11 in APS-C); lateral CA parsed, off by default; vignetting read, not applied | distortion, 16 entries only | `arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's @@ -167,9 +171,10 @@ conventions it was validated with. Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the [dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` -fetches one of them, the APS-C lossless file, as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, for the corpus test -`corpus_sony_lens_tables_become_opcodes` (its full-frame lossless case runs only where that file is present); the ARW -6.0 decoder (#625) pins one Compressed and one Compressed HQ file. +fetches three of them: the APS-C lossless file as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, and the ARW 6.0 +decoder's full-frame Compressed and APS-C Compressed HQ files (`arw6-sony-ilce7rm6-ff-land-c.arw`, +`arw6-sony-ilce7rm6-apsc-land-hq.arw`). The corpus test `corpus_sony_lens_tables_become_opcodes` checks all three, and +the full-frame lossless file where it is present. Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS (9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local @@ -250,10 +255,18 @@ warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On th 0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the same bins). All of this is far below one pixel at 1440 px. +**Ruling: the lateral CA planes are off by default.** `lens_opcodes` emits the G plane alone; the conversion stays in +the code and its unit tests (`warp_opcode(…, lateral_ca: true)`), so it can be turned on once CA is corrected where +the table applies. **Follow-up:** warp the raw CFA's red and blue sites before demosaic (the table halves the raw CA, +rms 0.062 → 0.035 px), then turn the planes on and repeat this check. Optics ▸ Remove Chromatic Aberration +(`optics.remove_ca`, an automatic estimate from the image) remains available for visible fringes. The distortion +numbers above were measured with the three-plane warp, whose G plane is the one the default emits. + **Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin 0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 -bound. So every photo gets the three-plane warp: the corrected and uncorrected renders differ on all 54. +bound. So every photo passes: with the CA planes on (the verification renders) each got the three-plane warp, and the +corrected and uncorrected renders differ on all 54; by default each gets the one-plane distortion warp. ## Performance @@ -261,15 +274,16 @@ bound. So every photo gets the three-plane warp: the corrected and uncorrected r `arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: -| Render | Off | Single-plane warp (manual Distortion 1) | On (three planes) | +| Render | Off | One plane (the default; measured with manual Distortion 1) | Three planes (CA planes on) | |---|---|---|---| | Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | | Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | | 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | -The three colour planes cost about 2.5–3× a single-plane warp, as for an RW2 or DNG with three planes (the same -path). The stage cache keys the resampled source by the source, framing and output size, so tone, colour and -exposure drags don't re-run it. +The default one-plane warp takes the sample stage from 24 to 45 ms at 1920 px (inferred from the manual Distortion +run: the same single-plane path, one plane for all three channels). The three colour planes cost about 2.5–3× that, +as for an RW2 or DNG with three planes (the same path): the price of turning the CA planes on. The stage cache keys +the resampled source by the source, framing and output size, so tone, colour and exposure drags don't re-run it. ## Limits @@ -283,10 +297,9 @@ exposure drags don't re-run it. 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not captured; per-lens units would need per-lens verification. -- **Lateral CA unit provisional** (above), and the CA warp runs after demosaic, as for RW2 and DNG. Our demosaic - already hides about 70 % of the lens CA, so the full table overshoots in the render: within budget, but the rms - corner CA rises from 0.032 to 0.052 px (Verification). Correcting CA before demosaic would be the principled fix; - scaling the colour planes down or dropping them are the cheap alternatives. +- **Lateral CA off by default.** Its unit is provisional (above), and a warp after demosaic, as for RW2 and DNG, + overshoots: our demosaic already hides about 70 % of the lens CA, and with the planes on the rms corner CA rose + from 0.032 to 0.052 px (Verification). Correcting CA before demosaic is the follow-up. - **Vignetting not applied** (above). - **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens correction. diff --git a/docs/whats-new.md b/docs/whats-new.md index 57e4e1c52..b1efd9e3a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -103,16 +103,16 @@ magnified preview isn't taken for the photo when checking focus at 1:1. ### Lens corrections -- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion and lateral chromatic aberration from the tables the - camera records in each file, under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 - photos shot with the camera's correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the - camera's JPEG within 0.68 px at 1440 px at the corners, where it was up to 17 px off before. The camera's vignetting - table is read but not applied: its own JPEGs don't apply it either. This reaches lossless ARWs now; Compressed and - Compressed HQ ARWs (ARW 6.0) get it once their decoder (#625) lands, and until then open from their embedded JPEG. - The A7R IVA (ILCE-7RM4A) has its own distortion correction, checked against Sony's exports; other Sony bodies record - the same tables, but they stay uncorrected until they are checked the same way. Photos imported earlier get the - correction with Photo ▸ Reload from Disk (when still at their import settings). See - `docs/sony-ilce7rm6-lens-corrections.md`. +- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion from the table the camera records in each file, + under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 photos shot with the camera's + correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the camera's JPEG within 0.68 px at + 1440 px at the corners, where it was up to 17 px off before. The camera's lateral chromatic aberration table is read + too, but not applied by default: our demosaic already hides most of that colour fringing, and the table on top made + it slightly worse. The vignetting table is read but not applied: the camera's own JPEGs don't apply it either. This + covers lossless, Compressed and Compressed HQ (ARW 6.0) ARWs alike. The A7R IVA (ILCE-7RM4A) has its own distortion + correction, checked against Sony's exports; other Sony bodies record the same tables, but they stay uncorrected until + they are checked the same way. Photos imported earlier get the correction with Photo ▸ Reload from Disk (when still + at their import settings). See `docs/sony-ilce7rm6-lens-corrections.md`. ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 28fb3a38b..6c60526ad 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -449,7 +449,7 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", ), // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The - // full-frame lossless file is not pinned (at most two pins per PR; #625 pins two Compressed ARW 6.0 files). + // full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files. ( "arw-sony-ilce7rm6-apsc-land-lossless.arw", "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW", From 8d6070e224b0a29237b21fd0c1b44e34bff38a3d Mon Sep 17 00:00:00 2001 From: Hossamvs <Hossamvs> Date: Tue, 13 Oct 2026 15:00:24 +0300 Subject: [PATCH 102/315] LR-LIB-PEOPLE: rename a person across the library (#1444) People cards and the person page had no way to rename every face of one person as one undo step. Co-authored-by: Cursor <cursoragent@cursor.com> --- crates/engine/src/cmd/face_recognize.rs | 100 +++++++++++++- crates/engine/src/tests_face_recognize.rs | 95 ++++++++++++++ crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/src/lib.rs | 25 ++++ crates/ui-egui/src/panels/dialogs.rs | 103 ++++++++++++++- crates/ui-egui/src/panels/people.rs | 16 ++- crates/ui-egui/src/panels/person.rs | 5 + crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_rename_person.rs | 138 ++++++++++++++++++++ docs/faces.md | 9 +- docs/parity-checklist.md | 4 +- 19 files changed, 516 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/src/tests_rename_person.rs diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..26b8c75ce 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -1,10 +1,11 @@ //! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed //! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every //! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is -//! what teaches the suggestions). +//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two +//! people when asked. //! //! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is -//! catalog-only (no sidecar is written) and `faces.setName` is undoable. +//! catalog-only (no sidecar is written) and naming is undoable. use lightcraft_catalog::Op; use serde_json::{Value, json}; @@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({"id": id.0, "index": index, "name": name})) } +/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo +/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused +/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing. +fn rename_person(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "faces.renamePerson"; + let from = match p.get("from") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "missing `from`")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`from` must be text")), + None => return Err(bad(C, "missing `from`")), + }; + let to = match p.get("to") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "`to` must be a name")); + } + if t.chars().count() > 200 || t.chars().any(char::is_control) { + return Err(bad(C, "a name is at most 200 characters, without control characters")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`to` must be text")), + None => return Err(bad(C, "missing `to`")), + }; + let merge = super::bool_or(p, "merge", false); + let from_key = from.to_lowercase(); + let to_key = to.to_lowercase(); + let mut matched = 0usize; + let mut other = false; + let mut faces = 0usize; + let mut ops = Vec::new(); + let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect(); + for id in ids { + let Some(ph) = s.catalog.photo(id) else { continue }; + let mut indexes = Vec::new(); + for (i, r) in ph.meta.regions.iter().enumerate() { + if r.kind != lightcraft_meta::RegionKind::Face { + continue; + } + let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = n.to_lowercase(); + if key == from_key { + matched += 1; + if r.name.as_deref() != Some(to.as_str()) { + indexes.push(i); + } + } else if key == to_key { + other = true; + } + } + if indexes.is_empty() { + continue; + } + let mut meta = ph.meta.clone(); + for i in indexes { + if let Some(region) = meta.regions.get_mut(i) { + region.name = Some(to.clone()); + faces += 1; + } + } + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + if matched == 0 { + return Err(bad(C, format!("no person named {from}"))); + } + if faces == 0 { + return Err(bad(C, format!("{to} is already their name"))); + } + if other && !merge { + return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them"))); + } + let photos = ops.len(); + s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?; + s.skip_auto_write = true; + if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) { + s.filter.person = Some(to.clone()); + } + Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) +} + /// Most faces `faces.unnamed` lists. const MAX_UNNAMED: usize = 3000; @@ -1047,5 +1134,14 @@ pub fn specs() -> Vec<CommandSpec> { super::always, set_name ), + cmd!( + "faces.renamePerson", + "Rename Person", + [], + None, + "{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched", + super::always, + rename_person + ), ] } diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index aabcb03c3..9f80ff8b8 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -523,3 +523,98 @@ fn face_scan_row_follows_pending_photos() { assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done"); let _ = std::fs::remove_dir_all(&d); } + +fn face_names(s: &Session, id: PhotoId) -> Vec<Option<String>> { + s.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect() +} + +/// Every in-library spelling of a person becomes the exact new name, including a change of case only, +/// and a person filter follows it. One undo step puts the old names back. +#[test] +fn rename_person_rewrites_every_spelling_and_undoes() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, Some(" ANN ")), region(0.7, Some("Rex"))]); + let mut pet = region(0.2, Some("Ann")); + pet.kind = RegionKind::Pet; + set_regions(&mut s, b, vec![region(0.3, Some("ann")), pet]); + s.execute("library.filter", &json!({"person": "ANN"})).unwrap(); + let undo = s.undo.len(); + let r = s.execute("faces.renamePerson", &json!({"from": " ann ", "to": " Ada "})).unwrap(); + assert_eq!((r["faces"].clone(), r["photos"].clone(), r["to"].clone(), r["merged"].clone()), (json!(3), json!(2), json!("Ada"), json!(false))); + assert_eq!(face_names(&s, a), vec![Some("Ada".into()), Some("Ada".into()), Some("Rex".into())]); + assert_eq!(face_names(&s, b)[0], Some("Ada".into())); + assert_eq!(face_names(&s, b)[1], Some("Ann".into()), "a pet is not a person"); + assert_eq!(s.catalog.people().iter().find(|p| p.name.eq_ignore_ascii_case("ada")).map(|p| p.name.as_str()), Some("Ada")); + assert_eq!(s.filter.person.as_deref(), Some("Ada")); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Rename Person")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(face_names(&s, a), vec![Some("Ann".into()), Some(" ANN ".into()), Some("Rex".into())]); + assert_eq!(face_names(&s, b)[0], Some("ann".into())); +} + +/// Renaming onto someone else is refused until merge is asked for; then one undo step restores both people. +#[test] +fn rename_person_refuses_a_merge_until_it_is_asked_for() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); + set_regions(&mut s, b, vec![region(0.3, Some("Bea"))]); + let before = (face_names(&s, a), face_names(&s, b), s.undo.len()); + let err = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea"})).unwrap_err(); + assert!(err.to_string().contains("already another person"), "{err}"); + assert_eq!((face_names(&s, a), face_names(&s, b), s.undo.len()), before, "a refusal changes nothing"); + let r = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea", "merge": true})).unwrap(); + assert_eq!(r["merged"], true); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Merge People")); + let names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect(); + assert_eq!(names, vec!["Bea".to_string()]); + s.execute("edit.undo", &json!({})).unwrap(); + let mut names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect(); + names.sort(); + assert_eq!(names, vec!["Ann".to_string(), "Bea".to_string()]); +} + +/// More faces than `faces.person` lists are still renamed. A bad call, a missing person, or a name that +/// is already exact changes nothing, and deleted or only-browsed photos are not part of the person. +#[test] +fn rename_person_covers_more_than_the_person_query_and_rejects_bad_input() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); + let undo = s.undo.len(); + for p in [ + json!({}), + json!({"from": "Ann"}), + json!({"from": " ", "to": "Ada"}), + json!({"from": 1, "to": "Ada"}), + json!({"from": "Ann", "to": ""}), + json!({"from": "Ann", "to": 5}), + json!({"from": "Ann", "to": "a\u{7}b"}), + json!({"from": "Ann", "to": "x".repeat(201)}), + json!({"from": "Nobody", "to": "Ada"}), + json!({"from": "Ann", "to": "Ann"}), + ] { + let err = s.execute("faces.renamePerson", &p).unwrap_err(); + assert!(s.undo.len() == undo, "{p} changed the catalog: {err}"); + } + assert_eq!(face_names(&s, a), vec![Some("Ann".into())]); + let regions: Vec<_> = (0..5001).map(|i| region(0.1, Some(if i == 0 { "ANN" } else { "Ann" }))).collect(); + set_regions(&mut s, a, regions); + set_regions(&mut s, b, vec![region(0.2, Some("Ann"))]); + s.commit("delete", Op::SetDeleted { id: b, deleted: true }).unwrap(); + let listed = s.execute("faces.person", &json!({"name": "Ann"})).unwrap(); + assert_eq!((listed["total"].clone(), listed["confirmed"].as_array().unwrap().len()), (json!(5001), 5000)); + let r = s.execute("faces.renamePerson", &json!({"from": "ann", "to": "Ada"})).unwrap(); + assert_eq!((r["faces"].clone(), r["photos"].clone()), (json!(5001), json!(1)), "the deleted photo is left alone"); + assert!(s.catalog.photo(a).unwrap().meta.regions.iter().all(|r| r.name.as_deref() == Some("Ada"))); + assert_eq!(s.catalog.photo(b).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); + let local = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap(); + set_regions(&mut s, local, vec![region(0.1, Some("Ada"))]); + s.commit("browse", Op::SetLocal { id: local, local: true }).unwrap(); + let r = s.execute("faces.renamePerson", &json!({"from": "Ada", "to": "Bea"})).unwrap(); + assert_eq!(r["photos"], 1, "the browsed photo is not another person and is not renamed"); + assert_eq!(s.catalog.photo(a).unwrap().meta.regions[0].name.as_deref(), Some("Bea")); + assert_eq!(s.catalog.photo(local).unwrap().meta.regions[0].name.as_deref(), Some("Ada")); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..5378f425e 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..4ad62befa 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..e9e2572db 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..ccafc9e92 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e5f32f551..31858441e 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..741c33e4e 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..bc0b8d6f8 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..0ce93aa3a 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "Renames {n} face{} across the library. The files are not changed.": "在整个图库中重命名 {n} 张面孔。不会改动文件。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..8bad2999a 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..c6b4ad341 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -108,6 +108,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_rename_person; +#[cfg(test)] mod tests_scroll; #[cfg(test)] mod tests_switch_library; @@ -498,6 +500,22 @@ impl LightcraftApp { self } + /// The open person page, the way back to it, and the photo it was opened from follow a rename. + fn follow_renamed_person(&mut self, from: &str, to: &str) { + let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); + if self.ui.person_page.as_deref().is_some_and(same) { + self.ui.person_page = Some(to.to_string()); + } + if self.ui.last_person.as_deref().is_some_and(same) { + self.ui.last_person = Some(to.to_string()); + } + if let Some((name, photo)) = self.ui.person_from.clone() + && same(&name) + { + self.ui.person_from = Some((to.to_string(), photo)); + } + } + /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> { if let Some(result) = model_setup::intercept(self, id, ¶ms) { @@ -573,6 +591,13 @@ impl LightcraftApp { } } Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), + Ok(v) if id == "faces.renamePerson" => { + let to = v["to"].as_str().unwrap_or("").to_string(); + let from = params.get("from").and_then(Value::as_str).unwrap_or(""); + if !to.is_empty() { + self.follow_renamed_person(from, &to); + } + } Ok(_) => {} } r diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 08e3bd670..3cba502f5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -229,6 +229,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", + Dialog::RenamePerson { .. } => "Rename Person", Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag", Dialog::KeywordTag { .. } => "Edit Keyword Tag", Dialog::DeleteKeyword { .. } => "Delete Keyword", @@ -573,6 +574,36 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); } } + Dialog::RenamePerson { from, to, merge } => { + let submitted = dialog_field(ui, "field:personName", to, "Name"); + let plan = person_rename_plan(&app.session.catalog, from, to); + if !plan.needs_merge { + *merge = false; + } + if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { + confirm = true; + } + if let Some(why) = plan.block { + ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); + } else { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames {n} face{} across the library. The files are not changed.", + if plan.faces == 1 { "" } else { "s" }, + n = plan.faces + )) + .color(t.text_dim), + ); + } + if plan.needs_merge { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim())) + .color(t.caution), + ); + let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); + crate::widgets::register(ui.ctx(), "check:personMerge", r.rect); + } + } Dialog::RenameKeyword { from, to } => { let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); if dialog_field(ui, "field:keywordName", to, "New name") { @@ -1246,18 +1277,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", Dialog::SamModel { .. } => "Download", + Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", _ if informational => "Close", _ => "OK", }; // installing waits for the licence to be accepted // (and Synchronize waits for the scan) - let can_confirm = informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) - // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); + let rename_blocked = match &dlg { + Dialog::RenamePerson { from, to, merge } => { + let plan = person_rename_plan(&app.session.catalog, from, to); + plan.block.is_some() || (plan.needs_merge && !*merge) + } + _ => false, + }; + let can_confirm = !rename_blocked + && (informational + || !matches!( + &dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ) + // smart-album rules that can't mean anything wait to be fixed + && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); @@ -1575,6 +1615,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso } Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), Dialog::KeywordTag { editing, name, @@ -1908,6 +1949,54 @@ fn return_confirms(dlg: &Dialog) -> bool { matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) } +struct PersonRenamePlan { + faces: usize, + needs_merge: bool, + /// Why Rename stays disabled. `None` when the typed name can be applied. + block: Option<&'static str>, +} + +/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. +fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { + let to_trim = to.trim(); + if to_trim.is_empty() { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; + } + if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; + } + let from_key = from.trim().to_lowercase(); + let to_key = to_trim.to_lowercase(); + let mut matched = 0usize; + let mut faces = 0usize; + let mut needs_merge = false; + if !from_key.is_empty() { + for photo in cat.photos().filter(|p| p.in_library()) { + for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + if key == from_key { + matched += 1; + if region.name.as_deref() != Some(to_trim) { + faces += 1; + } + } else if key == to_key { + needs_merge = true; + } + } + } + } + let block = if matched == 0 { + Some("No person by that name") + } else if faces == 0 { + Some("That is already their name") + } else { + None + }; + // a case-only change is the same person, not a merge + PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } +} + /// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens /// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc /// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..ec9514b24 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -181,11 +181,25 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text); let photos = crate::i18n::tr_format!("{n} photo{}", if person.count == 1 { "" } else { "s" }, n = person.count); p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim); - if resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)).clicked() { + let resp = resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)); + let full = person.name.clone(); + resp.context_menu(|ui| { + let r = ui.button(crate::i18n::tr("Rename Person…")); + register(ui.ctx(), "personMenu:rename", r.rect); + if r.clicked() { + open_rename(app, &full); + } + }); + if resp.clicked() { let _ = app.run("view.person", json!({"name": person.name})); } } +/// The Rename Person dialog, prefilled with the name the card shows. +pub(crate) fn open_rename(app: &mut LightcraftApp, name: &str) { + app.ui.dialog = Some(crate::state::Dialog::RenamePerson { from: name.to_string(), to: name.to_string(), merge: false }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..3439925ba 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -99,6 +99,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let _ = app.run("view.people", json!({})); } ui.label(RichText::new(&shown_name).font(t.semibold(15.0)).color(t.text)); + let rename = ui.button(crate::i18n::tr("Rename Person…")); + register(ui.ctx(), "person:rename", rename.rect); + if rename.clicked() { + super::people::open_rename(app, &shown_name); + } let count = if page.total == 1 { "1 face".to_string() } else { format!("{} faces", page.total) }; ui.label(RichText::new(count).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..f97054e77 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -825,6 +825,13 @@ pub enum Dialog { from: String, to: String, }, + /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person + /// `to` already names. + RenamePerson { + from: String, + to: String, + merge: bool, + }, /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. KeywordTag { /// The keyword being edited; `None`: a new one. diff --git a/crates/ui-egui/src/tests_rename_person.rs b/crates/ui-egui/src/tests_rename_person.rs new file mode 100644 index 000000000..9a69b1409 --- /dev/null +++ b/crates/ui-egui/src/tests_rename_person.rs @@ -0,0 +1,138 @@ +//! Rename Person from the People overview and from a person's page. + +use std::time::Duration; + +use lightcraft_catalog::Op; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn face(x: f64, name: &str) -> lightcraft_meta::Region { + lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some(name.to_string()), + description: None, + } +} + +fn name_faces(h: &mut Headless, id: lightcraft_catalog::PhotoId, regions: Vec<lightcraft_meta::Region>) { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + h.step(); +} + +fn right_click(h: &mut Headless, id: &str) { + let c = widget(h, id).center(); + let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn open_people(h: &mut Headless) { + let r = h.request("engine.execute", json!({"command": "view.people"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(T); + h.step(); +} + +fn stored(h: &Headless, id: lightcraft_catalog::PhotoId) -> Vec<Option<String>> { + h.app.session.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect() +} + +/// Both entry points open the dialog. Cancel and an empty name change nothing; a real rename updates +/// the person page, the way back to it, and a person filter. +#[test] +fn rename_person_from_the_card_and_the_page() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]); + name_faces(&mut h, ids[1], vec![face(0.3, "Jane Doe")]); + open_people(&mut h); + right_click(&mut h, "person:Jane Doe"); + click(&mut h, "personMenu:rename"); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::RenamePerson { from, to, .. }) if from == "Jane Doe" && to == "Jane Doe")); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); + + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T); + h.step(); + click(&mut h, "person:rename"); + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + to.clear(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "an empty name does not rename"); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; + *to = "Ada Lovelace".into(); + h.app.ui.last_person = Some("Jane Doe".into()); + h.app.ui.person_from = Some(("Jane Doe".into(), ids[0].0)); + h.app.session.execute("library.filter", &json!({"person": "jane doe"})).unwrap(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert!(stored(&h, ids[0]).iter().all(|n| n.as_deref() == Some("Ada Lovelace"))); + assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_page.as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.last_person.as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_from.as_ref().map(|(n, _)| n.as_str()), Some("Ada Lovelace")); + assert_eq!(h.app.session.filter.person.as_deref(), Some("Ada Lovelace")); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); +} + +/// Renaming onto someone else waits for Combine, then one undo step restores both people. +#[test] +fn rename_person_asks_before_merging() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe")]); + name_faces(&mut h, ids[1], vec![face(0.3, "John Roe")]); + open_people(&mut h); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T); + h.step(); + click(&mut h, "person:rename"); + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + *to = "John Roe".into(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "a merge is not silent"); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + click(&mut h, "check:personMerge"); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("John Roe")); + assert_eq!(h.app.ui.person_page.as_deref(), Some("John Roe")); + let names: Vec<String> = h.app.session.catalog.people().into_iter().map(|p| p.name).collect(); + assert_eq!(names, vec!["John Roe".to_string()]); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("John Roe")); +} diff --git a/docs/faces.md b/docs/faces.md index 5c8e54317..0d8b09550 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -268,6 +268,13 @@ To name a group: click faces to select them (Shift-click selects a range, **Sele name in the bar that appears (people already named complete as you type), press Enter: all of them are named at once, as one undo step. Selecting names nothing. +Right-click a person's card, or use **Rename Person…** on their page, to rename every face of that person in the library +at once (`faces.renamePerson`). The name is matched the way the cards are grouped (trimmed, ignoring capitalization) and +every matching face is stored with the exact new spelling, so a change of capitalization alone updates them all. The +dialog says how many faces that is. If the new name is already someone else, it refuses until **Combine with the existing +person** is ticked; that makes one person, and Undo separates them again. Cancelling changes nothing. A person filter, +the open person page and the way back to it follow the new name. Nothing is written back to the files. + The thumbnail-size slider in the bottom bar sizes the faces here too, with limits of their own. Every face is shown by the same kind of box: the detector's own box once the scan has looked at it (kept beside its embedding), so a loosely drawn box from another tool does not make one face look farther away than the next; before the scan has looked at a face it is shown @@ -296,7 +303,7 @@ suggestions it would make and how many were right. All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`, `faces.models.install {path, acknowledged: true, activate?}`, `faces.models.download {id, acknowledged: true}` (then `faces.models.downloads`, which also installs what has arrived, and `faces.models.downloadCancel {id}`), `faces.models.remove {id}`, `faces.models.select {id}`, -`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. +`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step), `faces.renamePerson {from, to, merge?}` (rename every in-library face of one person, or merge two when `merge` is true, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. ## Find Blemishes diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..ac9f742b0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -104,7 +104,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `cmd:faces.renamePerson`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back. Rename Person… (`cmd:faces.renamePerson`) on a card's menu and on the person page renames every in-library face of that person in one undo step, including a change of capitalization; renaming onto another person is refused until Combine is ticked, and Undo restores both | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | @@ -706,7 +706,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `cmd:faces.renamePerson`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject. Rename Person… (`cmd:faces.renamePerson`) renames every face of that person in one undo step, or merges two people after a warning; Undo restores both | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | From 4d240cf6163a2827df8332a5a455ea7e9c495dea Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Tue, 13 Oct 2026 14:10:21 +0200 Subject: [PATCH 103/315] LR-EDIT-EFFECTS-DEHAZE: raster::morph square min / max filters, Image::transpose Split out of #1286 (Dehaze V3) so it can be reviewed and land on its own. `min_filter` / `max_filter` are separable van Herk / Gil-Werman filters: three comparisons per pixel per pass whatever the radius, edges clamped, NaNs skipped like f32::min, a short buffer or empty plane returned unchanged. `Image::transpose` (row-parallel) does the column pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raster/src/lib.rs | 29 ++++++ crates/raster/src/morph.rs | 187 +++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 crates/raster/src/morph.rs diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 41ae75af8..08b30cfa7 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,12 +4,14 @@ //! `Plane = Image<f32>`). //! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel. //! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius). +//! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius). //! - [`Histogram`] of display-encoded RGB + luminance. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod blur; pub mod histogram; +pub mod morph; pub mod resample; pub use histogram::{HdrBins, Histogram}; @@ -98,6 +100,21 @@ impl<T: Copy + Default> Image<T> { pub fn is_empty(&self) -> bool { self.data.is_empty() } + /// The image mirrored about its main diagonal (`width` and `height` swap; row-parallel). A + /// buffer shorter than its size reads as `T::default()` where it runs out. + pub fn transpose(&self) -> Self + where + T: Send + Sync, + { + let (w, h) = (self.width, self.height); + let mut t = Image::<T>::new(h, w); + par_rows(&mut t.data, h, |x, row| { + for (y, v) in row.iter_mut().enumerate() { + *v = self.data.get(y * w + x).copied().unwrap_or_default(); + } + }); + t + } /// [`Self::map`] into the same buffer (no second image in memory). pub fn map_in_place(&mut self, f: impl Fn(T) -> T + Sync + Send) where @@ -258,6 +275,18 @@ mod tests { use super::*; use lightcraft_geom::Orientation; + #[test] + fn transpose_swaps_the_axes() { + let p = Plane::from_fn(5, 3, |x, y| (y * 10 + x) as f32); + let t = p.transpose(); + assert_eq!((t.width, t.height), (3, 5)); + assert!((0..3).all(|y| (0..5).all(|x| t.get(y, x) == p.get(x, y)))); + assert_eq!(t.transpose(), p); + // a buffer shorter than its size reads as zero where it runs out, never a panic + let short = Plane { width: 3, height: 2, data: vec![1.0; 4] }; + assert_eq!(short.transpose().data, [1.0, 1.0, 1.0, 0.0, 1.0, 0.0]); + } + #[test] fn crop_and_rotate() { let img = Image::<u32>::from_fn(4, 3, |x, y| (y * 10 + x) as u32); diff --git a/crates/raster/src/morph.rs b/crates/raster/src/morph.rs new file mode 100644 index 000000000..e428f91c2 --- /dev/null +++ b/crates/raster/src/morph.rs @@ -0,0 +1,187 @@ +//! Grey-scale morphology: square min (erosion) and max (dilation) filters. +//! +//! Separable van Herk / Gil-Werman filters: each 1-D pass splits the (edge-extended) line into +//! blocks of the window's width and keeps a running min from the left and from the right within +//! each block; any window then spans at most two blocks, so its min is the min of one suffix and +//! one prefix value. That is three comparisons per pixel per pass, whatever the radius. +//! +//! Edges are clamped (the edge pixel repeats), which for a min or max is the same as clipping the +//! window to the image. NaNs are skipped like [`f32::min`] does: a window yields NaN only when every +//! value in it is NaN. + +use crate::Plane; + +/// Square min filter (erosion) of radius `r`: each output pixel is the minimum over the +/// `(2r+1)²` window around it, clipped to the image. `r == 0` returns a copy. +pub fn min_filter(p: &Plane, r: usize) -> Plane { + filter(p, r, f32::min) +} + +/// Square max filter (dilation) of radius `r` (see [`min_filter`]). +pub fn max_filter(p: &Plane, r: usize) -> Plane { + filter(p, r, f32::max) +} + +fn filter(p: &Plane, r: usize, pick: fn(f32, f32) -> f32) -> Plane { + let (w, h) = (p.width, p.height); + if r == 0 || p.data.is_empty() || p.data.len() != w.saturating_mul(h) { + return p.clone(); + } + let mut out = p.clone(); + rows(&mut out, r, pick); + let mut t = out.transpose(); + rows(&mut t, r, pick); + t.transpose() +} + +/// [`line`] over every row of `p`, in place (row-parallel, one scratch per worker). +fn rows(p: &mut Plane, r: usize, pick: fn(f32, f32) -> f32) { + let w = p.width; + if w == 0 { + return; + } + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + p.data.par_chunks_mut(w).for_each_init(Scratch::default, |s, row| line(row, r, pick, s)); + } + #[cfg(not(feature = "parallel"))] + { + let mut s = Scratch::default(); + p.data.chunks_mut(w).for_each(|row| line(row, r, pick, &mut s)); + } +} + +#[derive(Default)] +struct Scratch { + ext: Vec<f32>, + pre: Vec<f32>, + suf: Vec<f32>, +} + +/// One 1-D pass over `row`, in place. +fn line(row: &mut [f32], r: usize, pick: fn(f32, f32) -> f32, s: &mut Scratch) { + let n = row.len(); + if n == 0 { + return; + } + // a radius past the line covers it all; capping keeps the scratch bounded + let r = r.min(n - 1); + if r == 0 { + return; + } + let k = 2 * r + 1; + // the line with `r` copies of each edge pixel on either side + s.ext.clear(); + s.ext.extend(std::iter::repeat_n(row[0], r)); + s.ext.extend_from_slice(row); + s.ext.extend(std::iter::repeat_n(row[n - 1], r)); + let m = s.ext.len(); + s.pre.clear(); + s.pre.resize(m, 0.0); + s.suf.clear(); + s.suf.resize(m, 0.0); + for (i, &v) in s.ext.iter().enumerate() { + s.pre[i] = if i % k == 0 { v } else { pick(s.pre[i - 1], v) }; + } + for i in (0..m).rev() { + let v = s.ext[i]; + s.suf[i] = if i + 1 == m || (i + 1) % k == 0 { v } else { pick(s.suf[i + 1], v) }; + } + // window [x, x + 2r] of `ext` is output x + for (x, o) in row.iter_mut().enumerate() { + *o = pick(s.suf[x], s.pre[x + 2 * r]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The definition: min (or max) over the clipped window. + fn naive(p: &Plane, r: usize, max: bool) -> Plane { + let (w, h) = (p.width, p.height); + let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) }; + Plane::from_fn(w, h, |x, y| { + let mut m = p.get(x, y); + for yy in y.saturating_sub(r)..=(y + r).min(h - 1) { + for xx in x.saturating_sub(r)..=(x + r).min(w - 1) { + m = pick(m, p.get(xx, yy)); + } + } + m + }) + } + + /// splitmix64-ish deterministic noise. + fn noise(w: usize, h: usize, seed: u64) -> Plane { + Plane::from_fn(w, h, |x, y| { + let mut z = seed.wrapping_add((y * w + x) as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z ^= z >> 31; + (z % 10_000) as f32 / 10_000.0 + }) + } + + #[test] + fn matches_the_naive_filter() { + for (w, h) in [(1, 1), (1, 9), (9, 1), (7, 5), (31, 17), (64, 40)] { + for r in [1, 2, 3, 5, 8, 20, 100] { + let p = noise(w, h, (w * 131 + h * 7 + r) as u64); + assert_eq!(min_filter(&p, r), naive(&p, r, false), "min {w}x{h} r={r}"); + assert_eq!(max_filter(&p, r), naive(&p, r, true), "max {w}x{h} r={r}"); + } + } + } + + #[test] + fn radius_zero_and_empty_are_copies() { + let p = noise(6, 4, 1); + assert_eq!(min_filter(&p, 0), p); + let e = Plane::new(0, 0); + assert_eq!(min_filter(&e, 3), e); + let e = Plane::new(5, 0); + assert_eq!(max_filter(&e, 3), e); + } + + #[test] + fn radius_past_the_image_is_the_global_extreme() { + let p = noise(13, 7, 3); + let lo = p.data.iter().copied().fold(f32::INFINITY, f32::min); + let hi = p.data.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!(min_filter(&p, 1_000_000).data.iter().all(|&v| v == lo)); + assert!(max_filter(&p, usize::MAX).data.iter().all(|&v| v == hi)); + } + + #[test] + fn a_dark_dot_grows_to_the_window() { + let mut p = Plane::filled(11, 11, 1.0); + p.set(5, 5, 0.0); + let q = min_filter(&p, 2); + for y in 0..11 { + for x in 0..11 { + let inside = (3..=7).contains(&x) && (3..=7).contains(&y); + assert_eq!(q.get(x, y), if inside { 0.0 } else { 1.0 }, "({x}, {y})"); + } + } + } + + #[test] + fn nans_are_skipped() { + let mut p = Plane::filled(5, 5, 0.5); + p.set(2, 2, f32::NAN); + p.set(0, 0, 0.25); + let q = min_filter(&p, 1); + assert_eq!(q.get(2, 2), 0.5); + assert_eq!(q.get(1, 1), 0.25); + let all = Plane::filled(3, 3, f32::NAN); + assert!(min_filter(&all, 1).data.iter().all(|v| v.is_nan())); + } + + #[test] + fn a_short_buffer_is_returned_unchanged() { + // a plane whose data doesn't fill its size must not panic + let p = Plane { width: 4, height: 4, data: vec![1.0; 3] }; + assert_eq!(min_filter(&p, 2), p); + } +} From d8f62a29b2f06f71a1f5fa20b36088def66d76eb Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Mon, 12 Oct 2026 15:11:27 +0200 Subject: [PATCH 104/315] LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections Window > Edit Sections > Solo Mode (view.soloSections {on?}) and a right-click on any section header turn it on: opening one section closes the others. Option-click on a header opens just that section once. Kept with the UI state (singlePanel). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panels/edit.rs | 18 ++++- crates/ui-egui/src/state.rs | 13 ++- crates/ui-egui/src/tests_solo_sections.rs | 96 +++++++++++++++++++++++ docs/parity-checklist.md | 14 ++-- 16 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tests_solo_sections.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..516d85101 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -529,6 +529,7 @@ "Filmstrip (/)": "Filmstreifen (/)", "Filter": "Filter", "Filter Bar": "Filterleiste", + "Solo Mode": "Solo-Modus", "Filter Presets": "Filtervorgaben", "Filter bar": "Filterleiste", "Filter fields": "Filterfelder", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..444791c77 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -511,6 +511,7 @@ "Filmstrip (/)": "Tira de película (/)", "Filter": "Filtro", "Filter Bar": "Barra de filtros", + "Solo Mode": "Modo solo", "Filter Presets": "Ajustes preestablecidos de filtro", "Filter bar": "Barra de filtros", "Filter fields": "Campos de filtro", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..1644721b1 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -494,6 +494,7 @@ "Filmstrip (/)": "Pellicule (/)", "Filter": "Filtre", "Filter Bar": "Barre de filtres", + "Solo Mode": "Mode solo", "Filter Presets": "Préréglages de filtre", "Filter bar": "Barre de filtres", "Filter fields": "Champs de filtre", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..42e9f15e7 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -709,6 +709,7 @@ "Filmstrip": "フィルムストリップ", "Filter": "フィルター", "Filter Bar": "フィルターバー", + "Solo Mode": "ソロモード", "Filter Presets": "フィルタープリセット", "Filter bar": "フィルターバー", "Filter fields": "項目を絞り込む", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..32b84bf99 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -502,6 +502,7 @@ "Filmstrip (/)": "Película (/)", "Filter": "Filtro", "Filter Bar": "Barra de filtros", + "Solo Mode": "Modo solo", "Filter Presets": "Predefinições de filtro", "Filter bar": "Barra de filtros", "Filter fields": "Campos de filtro", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..b8250c70d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -412,6 +412,7 @@ "Filmstrip (/)": "Лента (/)", "Filter": "Фильтр", "Filter Bar": "Панель фильтров", + "Solo Mode": "Режим соло", "Filter Presets": "Пресеты фильтров", "Filter bar": "Панель фильтров", "Filter fields": "Поля фильтра", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..960351baf 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -915,6 +915,7 @@ "Fill Edges": "Заповнити краї", "Filmstrip": "Стрічка", "Filter Bar": "Панель фільтрів", + "Solo Mode": "Режим соло", "Filter Presets": "Набори налаштувань фільтрів", "Filter bar": "Панель фільтрів", "Filter fields": "Поля фільтрів", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..b80b4f45b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -652,6 +652,7 @@ "Filmstrip (/)": "胶片窗格 (/)", "Filter": "过滤器", "Filter Bar": "过滤器栏", + "Solo Mode": "单独模式", "Filter Presets": "过滤器预设", "Filter bar": "过滤器栏", "Filter fields": "过滤字段", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..5de08731f 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -652,6 +652,7 @@ "Filmstrip (/)": "底片列(/)", "Filter": "篩選", "Filter Bar": "篩選列", + "Solo Mode": "單獨模式", "Filter Presets": "篩選預設集", "Filter bar": "篩選列", "Filter fields": "篩選欄位", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..59df7e913 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,8 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_solo_sections; +#[cfg(test)] mod tests_switch_library; #[cfg(test)] mod tests_sync; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 04eea2439..0e16f3e4e 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -328,6 +328,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), + "view.soloSections" => Some(u.single_panel), "view.navigator" => Some(u.navigator), "view.fullScreenPreview" => Some(u.fullscreen), "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..eda08c733 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -129,6 +129,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + // {on?}; no params toggles. Opening one Edit section closes the others. + ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), ("tool.brush", "Brush", Some("B"), "Window>Tools"), ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), @@ -953,6 +955,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } Ok(json!({"hidden": app.ui.hidden_locations})) } + "view.soloSections" => { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.single_panel); + app.ui.set_single_panel(on); + Ok(json!({"soloSections": app.ui.single_panel})) + } "view.filterBar" => { app.ui.filter_bar = !app.ui.filter_bar; if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 76a54171f..d4a7aa11f 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -531,8 +531,24 @@ fn section( if let Some(on) = toggled { let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); } else if resp.clicked() { - app.ui.toggle_section(id); + // ⌥-click opens just this section, as in Lightroom Classic + if ui.input(|i| i.modifiers.alt) { + app.ui.solo_section(id); + } else { + app.ui.toggle_section(id); + } } + resp.context_menu(|ui| { + let mut solo = app.ui.single_panel; + let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); + register(ui.ctx(), "sectionMenu:solo", item.rect); + if item.clicked() { + if let Err(e) = app.run("view.soloSections", json!({"on": solo})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); if open { body(app, ui, d); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..828e72041 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -523,7 +523,7 @@ pub struct UiState { pub open_sections: Vec<String>, /// Open flyouts (curve, mixer, grading…). pub open_flyouts: Vec<String>, - /// Single-panel mode: opening one section closes the others. + /// Solo Mode (`view.soloSections`): opening one Edit section closes the others. pub single_panel: bool, pub show_clipping: bool, pub histogram: bool, @@ -1177,6 +1177,17 @@ impl UiState { self.open_sections.push(id.to_string()); } } + /// Opens `id` and closes every other Edit section (⌥-click on a section header). + pub fn solo_section(&mut self, id: &str) { + self.open_sections = vec![id.to_string()]; + } + /// Turns Solo Mode on or off. Turning it on keeps only the section opened last. + pub fn set_single_panel(&mut self, on: bool) { + self.single_panel = on; + if on && let Some(last) = self.open_sections.pop() { + self.open_sections = vec![last]; + } + } /// Whether the sidebar section with this id is folded shut (an unknown id: no). pub fn sidebar_section_collapsed(&self, id: &str) -> bool { crate::sidebar::SidebarSection::from_id(id).is_some_and(|s| self.sidebar.is_collapsed(s)) diff --git a/crates/ui-egui/src/tests_solo_sections.rs b/crates/ui-egui/src/tests_solo_sections.rs new file mode 100644 index 000000000..35c74de48 --- /dev/null +++ b/crates/ui-egui/src/tests_solo_sections.rs @@ -0,0 +1,96 @@ +//! Headless tests of Solo Mode: opening one Edit section closes the others (Lightroom Classic's +//! panel Solo Mode), from the Window menu command, the section header's menu and ⌥-click. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// Detail view with the Edit panel open and Light (the default) expanded. +fn edit_open() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 1400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.app.ui.open_sections = vec!["light".into()]; + h.step(); + h.step(); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn click(h: &mut Headless, id: &str, button: Option<&str>) { + let params = match button { + Some(b) => json!({"id": id, "button": b}), + None => json!({"id": id}), + }; + let r = h.request("ui.clickWidget", params, T); + assert_eq!(r["ok"], true, "click {id}: {r}"); + h.step(); +} + +#[test] +fn solo_mode_closes_the_other_sections_when_one_opens() { + let mut h = edit_open(); + click(&mut h, "section:color", None); + assert_eq!(h.app.ui.open_sections, ["light", "color"], "off: sections open side by side"); + + assert_eq!(exec(&mut h, "view.soloSections", json!({})), json!({"soloSections": true})); + assert_eq!(h.app.ui.open_sections, ["color"], "turning it on keeps the section opened last"); + assert_eq!(crate::menubar::checked(&h.app, "view.soloSections"), Some(true)); + h.step(); + + click(&mut h, "section:detail", None); + assert_eq!(h.app.ui.open_sections, ["detail"]); + // the Window > Edit Sections shortcuts follow it too + exec(&mut h, "section.optics", json!({})); + assert_eq!(h.app.ui.open_sections, ["optics"]); + // closing the open one leaves none open + exec(&mut h, "section.optics", json!({})); + assert!(h.app.ui.open_sections.is_empty()); + + assert_eq!(exec(&mut h, "view.soloSections", json!({"on": false})), json!({"soloSections": false})); + exec(&mut h, "section.light", json!({})); + exec(&mut h, "section.color", json!({})); + assert_eq!(h.app.ui.open_sections, ["light", "color"]); +} + +#[test] +fn the_section_header_menu_turns_solo_mode_on() { + let mut h = edit_open(); + click(&mut h, "section:color", None); + click(&mut h, "section:effects", Some("right")); + click(&mut h, "sectionMenu:solo", None); + assert!(h.app.ui.single_panel, "the header's Solo Mode item turns it on"); + assert_eq!(h.app.ui.open_sections, ["color"]); +} + +#[test] +fn option_click_opens_just_that_section() { + let mut h = edit_open(); + click(&mut h, "section:color", None); + let r = h.request("ui.clickWidget", json!({"id": "section:detail", "alt": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.open_sections, ["detail"], "⌥-click closes the others"); + assert!(!h.app.ui.single_panel, "⌥-click doesn't turn Solo Mode on"); +} + +#[test] +fn solo_mode_survives_a_restart() { + let mut ui = crate::state::UiState::default(); + ui.set_single_panel(true); + let back: crate::state::UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); + assert!(back.single_panel); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..d20e7b358 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -31,12 +31,12 @@ cited here still exists. Formerly docs/parity.md. | C. Views & navigation (VIEW) | 15 | 1 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 410 | 52 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.3% of 174. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -222,6 +222,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | +| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on. `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | | LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | | LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | @@ -764,7 +765,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | | | | LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | | | | LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | | | -| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | | +| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | solo itself (⌥-click a header, Solo Mode) is done: see LR-EDIT-SECTION-SOLO | | KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | | no modules in LightCraft | | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | @@ -789,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 12015179eacbe6714c604b8175fe2ca63ebe2713 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Mon, 12 Oct 2026 17:49:54 +0200 Subject: [PATCH 105/315] LR-EDIT-SECTION-SOLO: section.<id> {solo?} and header clicks run the command Option-click goes through section.<id> {solo: true}, so agents can open just one section; view.soloSections and section.* refuse flags that are not true / false (bool_param); toggle_section reuses solo_section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/menus.rs | 22 +++++++----- crates/ui-egui/src/panels/edit.rs | 7 ++-- crates/ui-egui/src/state.rs | 13 +++---- crates/ui-egui/src/tests_solo_sections.rs | 43 ++++++++++++----------- docs/parity-checklist.md | 2 +- 5 files changed, 46 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eda08c733..1a41e4675 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -124,6 +124,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("panel.versions", "Versions", Some("Shift+V"), "Window"), ("panel.activity", "History", None, "Window"), ("panel.close", "Close Panel", None, ""), + // {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), @@ -955,11 +956,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } Ok(json!({"hidden": app.ui.hidden_locations})) } - "view.soloSections" => { - let on = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.single_panel); - app.ui.set_single_panel(on); - Ok(json!({"soloSections": app.ui.single_panel})) - } + "view.soloSections" => bool_param(id, p, "on").map(|on| { + app.ui.set_single_panel(on.unwrap_or(!app.ui.single_panel)); + json!({"soloSections": app.ui.single_panel}) + }), "view.filterBar" => { app.ui.filter_bar = !app.ui.filter_bar; if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { @@ -1040,14 +1040,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.presets = false; Ok(Value::Null) } - s if s.starts_with("section.") => { + s if s.starts_with("section.") => bool_param(s, p, "solo").map(|solo| { let sec = &s["section.".len()..]; if app.ui.right != RightPanel::Edit { app.ui.right = RightPanel::Edit; } - app.ui.toggle_section(sec); - Ok(Value::Null) - } + if solo == Some(true) { + app.ui.solo_section(sec); + } else { + app.ui.toggle_section(sec); + } + Value::Null + }), s if s.starts_with("tool.") => { let tool = &s["tool.".len()..]; match tool { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index d4a7aa11f..8418b39e5 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -532,10 +532,9 @@ fn section( let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); } else if resp.clicked() { // ⌥-click opens just this section, as in Lightroom Classic - if ui.input(|i| i.modifiers.alt) { - app.ui.solo_section(id); - } else { - app.ui.toggle_section(id); + let solo = ui.input(|i| i.modifiers.alt); + if let Err(e) = app.run(&format!("section.{id}"), json!({"solo": solo})) { + app.toast(ui.ctx(), e); } } resp.context_menu(|ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 828e72041..ae51deeeb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1170,22 +1170,23 @@ impl UiState { pub fn toggle_section(&mut self, id: &str) { if self.section_open(id) { self.open_sections.retain(|s| s != id); + } else if self.single_panel { + self.solo_section(id); } else { - if self.single_panel { - self.open_sections.clear(); - } self.open_sections.push(id.to_string()); } } /// Opens `id` and closes every other Edit section (⌥-click on a section header). pub fn solo_section(&mut self, id: &str) { - self.open_sections = vec![id.to_string()]; + self.open_sections.clear(); + self.open_sections.push(id.to_string()); } /// Turns Solo Mode on or off. Turning it on keeps only the section opened last. pub fn set_single_panel(&mut self, on: bool) { self.single_panel = on; - if on && let Some(last) = self.open_sections.pop() { - self.open_sections = vec![last]; + if on { + let keep = self.open_sections.len().saturating_sub(1); + self.open_sections.drain(..keep); } } /// Whether the sidebar section with this id is folded shut (an unknown id: no). diff --git a/crates/ui-egui/src/tests_solo_sections.rs b/crates/ui-egui/src/tests_solo_sections.rs index 35c74de48..cea569382 100644 --- a/crates/ui-egui/src/tests_solo_sections.rs +++ b/crates/ui-egui/src/tests_solo_sections.rs @@ -30,20 +30,17 @@ fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_jso r["result"].clone() } -fn click(h: &mut Headless, id: &str, button: Option<&str>) { - let params = match button { - Some(b) => json!({"id": id, "button": b}), - None => json!({"id": id}), - }; - let r = h.request("ui.clickWidget", params, T); - assert_eq!(r["ok"], true, "click {id}: {r}"); +/// A real click through the control channel: `{id, button?, alt?}` as `ui.clickWidget` takes them. +fn click(h: &mut Headless, params: serde_json::Value) { + let r = h.request("ui.clickWidget", params.clone(), T); + assert_eq!(r["ok"], true, "click {params}: {r}"); h.step(); } #[test] fn solo_mode_closes_the_other_sections_when_one_opens() { let mut h = edit_open(); - click(&mut h, "section:color", None); + click(&mut h, json!({"id": "section:color"})); assert_eq!(h.app.ui.open_sections, ["light", "color"], "off: sections open side by side"); assert_eq!(exec(&mut h, "view.soloSections", json!({})), json!({"soloSections": true})); @@ -51,7 +48,7 @@ fn solo_mode_closes_the_other_sections_when_one_opens() { assert_eq!(crate::menubar::checked(&h.app, "view.soloSections"), Some(true)); h.step(); - click(&mut h, "section:detail", None); + click(&mut h, json!({"id": "section:detail"})); assert_eq!(h.app.ui.open_sections, ["detail"]); // the Window > Edit Sections shortcuts follow it too exec(&mut h, "section.optics", json!({})); @@ -69,9 +66,9 @@ fn solo_mode_closes_the_other_sections_when_one_opens() { #[test] fn the_section_header_menu_turns_solo_mode_on() { let mut h = edit_open(); - click(&mut h, "section:color", None); - click(&mut h, "section:effects", Some("right")); - click(&mut h, "sectionMenu:solo", None); + click(&mut h, json!({"id": "section:color"})); + click(&mut h, json!({"id": "section:effects", "button": "right"})); + click(&mut h, json!({"id": "sectionMenu:solo"})); assert!(h.app.ui.single_panel, "the header's Solo Mode item turns it on"); assert_eq!(h.app.ui.open_sections, ["color"]); } @@ -79,18 +76,22 @@ fn the_section_header_menu_turns_solo_mode_on() { #[test] fn option_click_opens_just_that_section() { let mut h = edit_open(); - click(&mut h, "section:color", None); - let r = h.request("ui.clickWidget", json!({"id": "section:detail", "alt": true}), T); - assert_eq!(r["ok"], true, "{r}"); - h.step(); + click(&mut h, json!({"id": "section:color"})); + click(&mut h, json!({"id": "section:detail", "alt": true})); assert_eq!(h.app.ui.open_sections, ["detail"], "⌥-click closes the others"); assert!(!h.app.ui.single_panel, "⌥-click doesn't turn Solo Mode on"); + // agents do the same with the section's command (Calibration has no menu item but runs too) + exec(&mut h, "section.calibration", json!({"solo": true})); + assert_eq!(h.app.ui.open_sections, ["calibration"]); } #[test] -fn solo_mode_survives_a_restart() { - let mut ui = crate::state::UiState::default(); - ui.set_single_panel(true); - let back: crate::state::UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); - assert!(back.single_panel); +fn flags_that_are_not_true_or_false_are_refused() { + let mut h = edit_open(); + for (command, params) in [("view.soloSections", json!({"on": "yes"})), ("section.color", json!({"solo": 1}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], false, "{command} {params}: {r}"); + } + assert!(!h.app.ui.single_panel); + assert_eq!(h.app.ui.open_sections, ["light"]); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d20e7b358..1a9dc79b5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -222,7 +222,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | -| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on. `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | +| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.<id>` `{solo: true}`; header clicks run `section.<id>` too). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | | LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | | LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | From eeb2ca2f6ad4de231f2d9d03191582f8a19a68a5 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Mon, 12 Oct 2026 18:22:02 +0200 Subject: [PATCH 106/315] LR-EDIT-SECTION-SOLO: register section.calibration, refuse unknown sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calibration's header runs section.calibration, now a registered command (no menu item); section.<id> refuses an id the Edit panel hasn't got instead of closing the real sections for a phantom one. "Solo Mode" sits with the other So… keys in the catalogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/menus.rs | 9 +++++++-- crates/ui-egui/src/tests_solo_sections.rs | 8 +++++--- docs/parity-checklist.md | 2 +- 12 files changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 516d85101..16fa3050d 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -529,7 +529,6 @@ "Filmstrip (/)": "Filmstreifen (/)", "Filter": "Filter", "Filter Bar": "Filterleiste", - "Solo Mode": "Solo-Modus", "Filter Presets": "Filtervorgaben", "Filter bar": "Filterleiste", "Filter fields": "Filterfelder", @@ -1433,6 +1432,7 @@ "Soft Pastel": "Sanftes Pastell", "Soft Proofing": "Softproof", "Soft Skin": "Weiche Haut", + "Solo Mode": "Solo-Modus", "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony-ARWs von vor etwa 2017, RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7, A7 II, A7R II und verwandte", "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Verkleinerte verlustfreie ARW-Dateien der Sony ILCE-7M4 werden jetzt aus unterabgetasteten YCbCr-Kacheln in lineares RGB decodiert.", "Sort": "Sortieren", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 444791c77..cd4026b58 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -511,7 +511,6 @@ "Filmstrip (/)": "Tira de película (/)", "Filter": "Filtro", "Filter Bar": "Barra de filtros", - "Solo Mode": "Modo solo", "Filter Presets": "Ajustes preestablecidos de filtro", "Filter bar": "Barra de filtros", "Filter fields": "Campos de filtro", @@ -1400,6 +1399,7 @@ "Soft Pastel": "Pastel suave", "Soft Proofing": "Prueba en pantalla", "Soft Skin": "Piel suave", + "Solo Mode": "Modo solo", "Sort": "Ordenar", "Sort Albums by Name": "Ordenar álbumes por nombre", "Sort by": "Ordenar por", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 1644721b1..5ca3561de 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -494,7 +494,6 @@ "Filmstrip (/)": "Pellicule (/)", "Filter": "Filtre", "Filter Bar": "Barre de filtres", - "Solo Mode": "Mode solo", "Filter Presets": "Préréglages de filtre", "Filter bar": "Barre de filtres", "Filter fields": "Champs de filtre", @@ -1366,6 +1365,7 @@ "Soft Pastel": "Pastel doux", "Soft Proofing": "Épreuvage à l'écran", "Soft Skin": "Peau douce", + "Solo Mode": "Mode solo", "Sort": "Trier", "Sort by": "Trier par", "Source": "Source", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 42e9f15e7..10e5e5558 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -709,7 +709,6 @@ "Filmstrip": "フィルムストリップ", "Filter": "フィルター", "Filter Bar": "フィルターバー", - "Solo Mode": "ソロモード", "Filter Presets": "フィルタープリセット", "Filter bar": "フィルターバー", "Filter fields": "項目を絞り込む", @@ -1311,6 +1310,7 @@ "Soft Pastel": "ソフトパステル", "Soft Proofing": "ソフト校正", "Soft Skin": "ソフトスキン", + "Solo Mode": "ソロモード", "Sort": "並べ替え", "Sort by": "並べ替え", "Source": "読み込み元", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 32b84bf99..d2972d761 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -502,7 +502,6 @@ "Filmstrip (/)": "Película (/)", "Filter": "Filtro", "Filter Bar": "Barra de filtros", - "Solo Mode": "Modo solo", "Filter Presets": "Predefinições de filtro", "Filter bar": "Barra de filtros", "Filter fields": "Campos de filtro", @@ -1389,6 +1388,7 @@ "Soft Pastel": "Pastel suave", "Soft Proofing": "Prova suave", "Soft Skin": "Pele suave", + "Solo Mode": "Modo solo", "Sort": "Classificar", "Sort by": "Classificar por", "Source": "Origem", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b8250c70d..7447a1307 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -412,7 +412,6 @@ "Filmstrip (/)": "Лента (/)", "Filter": "Фильтр", "Filter Bar": "Панель фильтров", - "Solo Mode": "Режим соло", "Filter Presets": "Пресеты фильтров", "Filter bar": "Панель фильтров", "Filter fields": "Поля фильтра", @@ -1153,6 +1152,7 @@ "Soft Pastel": "Мягкий пастельный", "Soft Proofing": "Экранная цветопроба", "Soft Skin": "Мягкая кожа", + "Solo Mode": "Режим соло", "Sort": "Сортировка", "Sort by": "Сортировать по", "Source": "Источник", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 960351baf..80e172946 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -915,7 +915,6 @@ "Fill Edges": "Заповнити краї", "Filmstrip": "Стрічка", "Filter Bar": "Панель фільтрів", - "Solo Mode": "Режим соло", "Filter Presets": "Набори налаштувань фільтрів", "Filter bar": "Панель фільтрів", "Filter fields": "Поля фільтрів", @@ -1375,6 +1374,7 @@ "Soft Pastel": "М’яка пастель", "Soft Proofing": "Екранна кольоропроба", "Soft Skin": "М’яка шкіра", + "Solo Mode": "Режим соло", "Sort by": "Сортувати за", "Spherical": "Сферична", "Split Stack": "Розділити стос", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b80b4f45b..78e27b459 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -652,7 +652,6 @@ "Filmstrip (/)": "胶片窗格 (/)", "Filter": "过滤器", "Filter Bar": "过滤器栏", - "Solo Mode": "单独模式", "Filter Presets": "过滤器预设", "Filter bar": "过滤器栏", "Filter fields": "过滤字段", @@ -1374,6 +1373,7 @@ "Soft Pastel": "柔和粉彩", "Soft Proofing": "软打样", "Soft Skin": "柔和肤质", + "Solo Mode": "单独模式", "Sort": "排序", "Sort by": "排序方式", "Source": "来源", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 5de08731f..50ef6020a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -652,7 +652,6 @@ "Filmstrip (/)": "底片列(/)", "Filter": "篩選", "Filter Bar": "篩選列", - "Solo Mode": "單獨模式", "Filter Presets": "篩選預設集", "Filter bar": "篩選列", "Filter fields": "篩選欄位", @@ -1374,6 +1373,7 @@ "Soft Pastel": "柔和粉彩", "Soft Proofing": "軟式校樣", "Soft Skin": "柔和膚質", + "Solo Mode": "單獨模式", "Sort": "排序", "Sort by": "排序依據", "Source": "來源", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1a41e4675..46c4bac7a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -130,6 +130,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + ("section.calibration", "Calibration", None, ""), // {on?}; no params toggles. Opening one Edit section closes the others. ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), ("tool.brush", "Brush", Some("B"), "Window>Tools"), @@ -1040,7 +1041,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.presets = false; Ok(Value::Null) } - s if s.starts_with("section.") => bool_param(s, p, "solo").map(|solo| { + s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { + // only the Edit sections listed above: an unknown one would close the real ones + if !UI_COMMANDS.iter().any(|c| c.0 == s) { + return Err(format!("{s}: no such Edit section")); + } let sec = &s["section.".len()..]; if app.ui.right != RightPanel::Edit { app.ui.right = RightPanel::Edit; @@ -1050,7 +1055,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } else { app.ui.toggle_section(sec); } - Value::Null + Ok(Value::Null) }), s if s.starts_with("tool.") => { let tool = &s["tool.".len()..]; diff --git a/crates/ui-egui/src/tests_solo_sections.rs b/crates/ui-egui/src/tests_solo_sections.rs index cea569382..70d1a6aa6 100644 --- a/crates/ui-egui/src/tests_solo_sections.rs +++ b/crates/ui-egui/src/tests_solo_sections.rs @@ -80,15 +80,17 @@ fn option_click_opens_just_that_section() { click(&mut h, json!({"id": "section:detail", "alt": true})); assert_eq!(h.app.ui.open_sections, ["detail"], "⌥-click closes the others"); assert!(!h.app.ui.single_panel, "⌥-click doesn't turn Solo Mode on"); - // agents do the same with the section's command (Calibration has no menu item but runs too) + // agents do the same with the section's command (Calibration has one, though not in the menu) exec(&mut h, "section.calibration", json!({"solo": true})); assert_eq!(h.app.ui.open_sections, ["calibration"]); } #[test] -fn flags_that_are_not_true_or_false_are_refused() { +fn bad_flags_and_unknown_sections_are_refused() { let mut h = edit_open(); - for (command, params) in [("view.soloSections", json!({"on": "yes"})), ("section.color", json!({"solo": 1}))] { + for (command, params) in + [("view.soloSections", json!({"on": "yes"})), ("section.color", json!({"solo": 1})), ("section.typo", json!({"solo": true}))] + { let r = h.request("engine.execute", json!({"command": command, "params": params}), T); assert_eq!(r["ok"], false, "{command} {params}: {r}"); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1a9dc79b5..dea4434c9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -222,7 +222,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | -| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.<id>` `{solo: true}`; header clicks run `section.<id>` too). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | +| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.<id>` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.<id>` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | | LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | | LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | From 4dfd78a8a86582b612f55ab825d63e13db669fc1 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:46 +0300 Subject: [PATCH 107/315] Library grids: Home/End and Shift+Left/Right selection keys; Cmd+O and Shift+H In Photo Grid and Square Grid, Home and End make the first or last photo of the view the active, selected one, and Shift+Left / Shift+Right add the previous or next photo of the view to the selection. These keys wait while the full-screen preview covers the grid, in every other view, while a text field or a dialog has the keyboard, and when the user gave the key to a command. They are listed under Fixed keys in the keyboard shortcuts dialog. Open Library now has Cmd+O and Visualize HDR has Shift+H (Cmd+Shift+H stays the Histogram). --- crates/ui-egui/src/menus.rs | 4 +-- crates/ui-egui/src/panels/keymap.rs | 9 +++++++ crates/ui-egui/src/shortcuts.rs | 40 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..8bf3f0a8f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -87,7 +87,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), - ("view.visualizeHdr", "Visualize HDR", None, "View"), + ("view.visualizeHdr", "Visualize HDR", Some("Shift+H"), "View"), // in grids S expands/collapses stacks (the engine command it shadows) ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), @@ -196,7 +196,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.newLibrary", "New Library…", None, "File"), - ("app.openLibrary", "Open Library…", None, "File"), + ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), ("app.recentLibraries", "Recent Libraries", None, ""), ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index a844b7b24..1bda17758 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -151,6 +151,15 @@ fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, nee for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] { fixed.push((sc.to_string(), label)); } + // grid navigation (Photo Grid and Square Grid): fixed keys that belong to no single command + for (sc, label) in [ + ("Home", "Grid: first photo of the view"), + ("End", "Grid: last photo of the view"), + ("Shift+Left", "Grid: add the previous photo to the selection"), + ("Shift+Right", "Grid: add the next photo to the selection"), + ] { + fixed.push((sc.to_string(), label)); + } let fixed: Vec<_> = fixed .into_iter() .filter(|(sc, label)| { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1a21ab2fc..aa21af98e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -349,6 +349,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); let keymap = &app.ui.settings.keymap; let grid = library_grid(app); + // The grid navigation keys act on the photos in view, so they wait while the full-screen preview + // covers the grid: a key must not move the selection onto photos nobody can see. + let grid_navigation = grid && !app.ui.fullscreen; // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); // an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back, @@ -391,6 +394,20 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { fire.push(format!("label:{label}")); } } + if grid_navigation { + // Home / End: the first / last photo of the view + for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { + if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(id.to_string()); + } + } + // Shift+← / Shift+→: the previous / next photo of the view joins the selection + for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { + if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { + fire.push(format!("grid:extend:{step}")); + } + } + } }); use crate::panels::compare; // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or @@ -448,6 +465,29 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { // S in a grid: expand / collapse the stack (Lightroom's Library binding) let _ = app.run("stack.toggle", json!({})); + } else if f == "grid:first" || f == "grid:last" { + // Home / End in a grid: make the first / last photo of the view the active, selected one + let vis = app.session.visible_cloned(); + let edge = if f == "grid:first" { vis.first() } else { vis.last() }; + if let Some(id) = edge { + let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); + } + } else if let Some(step) = f.strip_prefix("grid:extend:") { + // Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and + // becomes the active one. At either end of the view the key does nothing. + let step = step.parse::<i64>().unwrap_or(0); + let vis = app.session.visible_cloned(); + let neighbour = app + .session + .active() + .and_then(|active| vis.iter().position(|p| *p == active)) + .and_then(|pos| i64::try_from(pos).ok()) + .and_then(|pos| pos.checked_add(step)) + .and_then(|pos| usize::try_from(pos).ok()) + .and_then(|pos| vis.get(pos)); + if let Some(id) = neighbour { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); + } } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { let d = if f == "library.next" { 1 } else { -1 }; let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; From 68e9d61aa662d467cef76ff5eeaec71636eea565 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:51 +0300 Subject: [PATCH 108/315] Tests for the grid navigation keys, Cmd+O and Shift+H Cover Home/End and Shift+Left/Right in both grids (ends of the view, the loupe, the user's own binding winning), Cmd+O asking for a folder once, Shift+H toggling the HDR view, and the new keys staying out of the way while a text field has the keyboard, while the full-screen preview covers the grid, and while a dialog is open. --- crates/ui-egui/src/tests_keymap.rs | 233 +++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 1b04a46bd..45c3ba7de 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -237,3 +237,236 @@ fn opening_numeric_release_keeps_keys_in_the_editor() { } } } + +/// Press `key` with the named modifiers held (`cmd`, `shift`, `alt`, `ctrl`). +fn press(h: &mut Headless, key: &str, mods: &[&str]) { + let mut p = json!({ "key": key }); + for m in mods { + p[*m] = json!(true); + } + let r = h.request("ui.key", p, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +fn view(h: &mut Headless, p: serde_json::Value) { + let r = h.request("ui.set", p, T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Make `id` the only selected, active photo. +fn select(h: &mut Headless, id: lightcraft_catalog::PhotoId) { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// The demo library in Photo Grid with its middle photo selected: the photos in view, and that one. +fn grid_demo() -> (Headless, Vec<lightcraft_catalog::PhotoId>, lightcraft_catalog::PhotoId) { + let mut h = demo(); + view(&mut h, json!({ "view": "photoGrid" })); + let vis = h.app.session.visible_cloned(); + assert!(vis.len() > 2, "{vis:?}"); + let middle = vis[vis.len() / 2]; + select(&mut h, middle); + (h, vis, middle) +} + +/// Count how often the folder picker is asked for; every ask is cancelled. +fn count_folder_picks(h: &mut Headless) -> std::sync::Arc<std::sync::atomic::AtomicUsize> { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = calls.clone(); + h.app.services.pick_folder = Some(Box::new(move || { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + calls +} + +/// The declared keys of the two commands these tests bind. +#[test] +fn open_library_and_visualize_hdr_keys_are_declared() { + use crate::shortcuts::{Keymap, parse, shortcut_of}; + + let keymap = Keymap::new(); + for (id, want) in [("view.visualizeHdr", "Shift+H"), ("app.openLibrary", "Cmd+O")] { + assert_eq!(shortcut_of(&keymap, id), Some(want), "{id}"); + assert!(parse(want).is_some(), "{want} does not parse"); + } +} + +/// Opening a library uses the same folder picker as File > Open Library. Cancelling it +/// leaves the current photo and library intact. +#[test] +fn cmd_o_opens_the_library_picker_once() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + let active = h.app.session.active(); + let photos = h.app.session.catalog.len(); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "one picker for Cmd+O"); + assert_eq!(h.app.session.active(), active); + assert_eq!(h.app.session.catalog.len(), photos); +} + +/// Shift+H toggles View > Visualize HDR; Cmd+Shift+H (Histogram) is untouched. +#[test] +fn shift_h_toggles_the_hdr_view() { + let mut h = demo(); + assert!(!h.app.ui.hdr_visualize); + let histogram = h.app.ui.histogram; + press(&mut h, "H", &["shift"]); + assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); + assert_eq!(h.app.ui.histogram, histogram, "Shift+H leaves the Histogram unchanged"); + press(&mut h, "H", &["shift"]); + assert!(!h.app.ui.hdr_visualize, "Shift+H turns it back off"); +} + +/// Home / End make the first / last photo of the view the active one, only in the library grids, +/// and yield to a key the user gave to another command. +#[test] +fn home_and_end_select_the_ends_of_the_grid() { + let (mut h, vis, _) = grid_demo(); + let (first, last) = (vis[0], *vis.last().unwrap()); + + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "Home goes to the first photo"); + assert_eq!(h.app.session.selection.ids, vec![first], "and selects only it"); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), Some(last), "End goes to the last photo"); + assert_eq!(h.app.session.selection.ids, vec![last]); + + view(&mut h, json!({ "view": "squareGrid" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "the Square Grid has the keys too"); + + select(&mut h, last); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(last), "Home is a grid key"); + + view(&mut h, json!({ "view": "photoGrid" })); + let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Home"})).unwrap(); + assert_eq!(r["shortcut"], "Home", "{r}"); + press(&mut h, "Home", &[]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Home binding fires"); + assert_eq!(h.app.session.active(), Some(last), "the grid key stayed out of the way"); +} + +/// Shift+Left / Shift+Right in a grid add the previous / next photo of the view to the selection and +/// make it active, stop at either end, stay out of the loupe and yield to the user's keymap. +#[test] +fn shift_arrows_extend_the_grid_selection() { + let (mut h, vis, middle) = grid_demo(); + let at = vis.iter().position(|p| *p == middle).unwrap(); + let (previous, next) = (vis[at - 1], vis[at + 1]); + + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next], "Shift+Right adds the next photo"); + assert_eq!(h.app.session.active(), Some(next), "the added photo becomes active"); + // Shift+Left steps back onto a photo that is already selected: the selection stays, the active moves + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next]); + assert_eq!(h.app.session.active(), Some(middle)); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next, previous], "Shift+Left adds the previous photo"); + assert_eq!(h.app.session.active(), Some(previous)); + + // the ends of the view do nothing + select(&mut h, vis[0]); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![vis[0]], "Shift+Left on the first photo is a no-op"); + let last = *vis.last().unwrap(); + select(&mut h, last); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![last], "Shift+Right on the last photo is a no-op"); + + // outside a grid it is not the extension key + select(&mut h, middle); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle], "the loupe has no grid extension key"); + + // a key the user gave to a command wins + view(&mut h, json!({ "view": "photoGrid" })); + h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+Right"})).unwrap(); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Shift+Right binding fires"); + assert_eq!(h.app.session.selection.ids, vec![middle], "the grid key stayed out of the way"); +} + +/// A text field with the keyboard focus keeps every one of these keys. +#[test] +fn navigation_open_library_and_hdr_keys_wait_for_a_text_field() { + let (mut h, vis, middle) = grid_demo(); + let calls = count_folder_picks(&mut h); + let picks = || calls.load(std::sync::atomic::Ordering::SeqCst); + let keys: [(&str, &[&str]); 6] = [("Home", &[]), ("End", &[]), ("Right", &["shift"]), ("Left", &["shift"]), ("O", &["cmd"]), ("H", &["shift"])]; + + // with no text field focused, each of them acts + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), vis.last().copied()); + select(&mut h, middle); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids.len(), 2, "Shift+Right extends"); + press(&mut h, "O", &["cmd"]); + assert_eq!(picks(), 1, "Cmd+O asks for a folder"); + press(&mut h, "H", &["shift"]); + assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); + press(&mut h, "H", &["shift"]); + assert!(!h.app.ui.hdr_visualize); + select(&mut h, middle); + + // with the search box focused, none does + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the search box has the keyboard"); + let before = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + for (key, mods) in keys { + press(&mut h, key, mods); + let after = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + assert_eq!(after, before, "{mods:?}+{key} reached the shortcuts while the search box had the focus"); + } + assert!(h.app.ui.dialog.is_none()); +} + +/// Home / End and Shift+Left / Shift+Right belong to the grid: while the full-screen preview covers it +/// they leave the selection alone. +#[test] +fn grid_navigation_keys_do_nothing_in_the_full_screen_preview() { + let (mut h, vis, middle) = grid_demo(); + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the grid is the view behind the preview"); + for (key, mods) in [("Home", &[][..]), ("End", &[][..]), ("Right", &["shift"][..]), ("Left", &["shift"][..])] { + press(&mut h, key, mods); + assert_eq!(h.app.session.active(), Some(middle), "{mods:?}+{key} moved the active photo in the full-screen preview"); + assert_eq!(h.app.session.selection.ids, vec![middle], "{mods:?}+{key} changed the selection in the full-screen preview"); + } + // back in the grid the keys work again + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(!h.app.ui.fullscreen); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); +} + +/// A dialog owns the keyboard: Cmd+O does not ask for a folder behind it. +#[test] +fn cmd_o_does_not_fire_while_a_dialog_is_open() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0, "Cmd+O reached the shortcuts behind a dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + // with the dialog closed the same key asks for a folder + h.app.ui.dialog = None; + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} From 939d422ff6d6ec63b98533800b0e7e5ba904365b Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:51 +0300 Subject: [PATCH 109/315] Docs: grid navigation keys, Cmd+O and Shift+H Describe the new keys in the library shortcuts page, update the rows they touch in the parity checklist and the UI parity page, and stop listing F6 and F7 as missing in the small-shortcuts gap now that they are bound. --- docs/gaps.md | 5 +++-- docs/library-shortcuts.md | 8 ++++++++ docs/parity-checklist.md | 9 +++++---- docs/ui-parity.md | 5 ++++- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 39e6c6486..0a6e8885c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (gap 10: Leica Q3 verified, 236 models; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (gap 28: F6 and F7 are bound, so panel keys no longer lists them; gap 10: Leica Q3 verified, 236 models; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -45,7 +45,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 25 | **File writes**: lossy DNG; AVIF import; Render to DNG as a catalog command (the export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [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) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | -| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -75,6 +75,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Gap 28: F6 (filmstrip) and F7 (left panel) are bound, so the entry no longer lists them as missing; hours unchanged | | 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 | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 414e9166b..e17658544 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -29,6 +29,14 @@ step. AutoWrite uses the normal rating metadata path. In Detail, including Maski remapping and disabling the default brush bindings take precedence over their grid bracket actions. The separate Decrease Rating / Increase Rating commands can also be assigned in Help → Keyboard Shortcuts. +`Home` and `End` make the first or last photo of the view the active, selected one. `Shift` + `←` / +`→` add the previous or next photo of the view to the selection and make the added photo active; the +key never removes a photo from the selection, and at either end of the view it does nothing. These +four keys work in Photo Grid and Square Grid only. They wait in Detail, Compare and Survey, in the +full-screen preview (which covers the grid), while a text field or a dialog has the keyboard, and +when you gave the same key to a command in Help → Keyboard Shortcuts, where they are listed under +Fixed keys. + Grid actions apply to all selected photos and support Undo / Redo. In Compare and Survey, culling actions apply to the active photo. Auto Advance moves forward after an action; holding Shift with Auto Advance on still advances only once. The last photo stays selected. Text fields keep their diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..a9e78cbe9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEYC-COMPARE: Home / End and ⇧← / ⇧→ in the two library grids; LRC-LIB-CATALOG-SWITCH: ⌘O Open Library; LR-HDR-VISUALIZE: ⇧H; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -365,7 +365,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) | | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | -| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`), `crates/ui-egui/src/tests_keymap.rs` (`shift_h_toggles_the_hdr_view`) | grey below SDR white, four colour bands by stops above it; ⇧H toggles it (⌘⇧H stays the Histogram panel), and a text field with the focus keeps the key | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | | LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | @@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | -| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | +| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | @@ -769,7 +769,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | -| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | +| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks, thumbnail-size keys | | KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | @@ -803,6 +803,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) | | 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | | 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | +| 2026-10-13 | minor | KEYC-COMPARE: Home / End select the first / last photo of the view and ⇧← / ⇧→ add the previous / next photo to the selection in Photo Grid and Square Grid (not in the loupe, the full-screen preview, a text field or a dialog); LRC-LIB-CATALOG-SWITCH: ⌘O runs Open Library…; LR-HDR-VISUALIZE: ⇧H toggles Visualize HDR. Statuses unchanged (KEYC-COMPARE stays 🟡: no thumbnail-size keys) | | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..69a76937d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (library navigation keys: Home / End and Shift+Left / Right in the grids; Cmd+O Open Library; Shift+H Visualize HDR) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -67,6 +67,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. +**Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… and `Shift+H` toggles View → Visualize HDR (`Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. + **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). @@ -105,6 +107,7 @@ left panel, canvas, right panel, which is sensible but not tuned). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist | From f86459db58d0cd04d458009c0d4f3e3a67e118d5 Mon Sep 17 00:00:00 2001 From: nevicia2010-hub <259836477+nevicia2010-hub@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:29:16 +0200 Subject: [PATCH 110/315] LR-MASK: fix range handle drags and color sampling settings --- crates/pipeline/src/lib.rs | 8 +++- crates/pipeline/src/tests.rs | 55 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/masking.rs | 13 ++++++- crates/ui-egui/src/tests_masking.rs | 25 +++++++++++++ docs/parity-checklist.md | 7 ++-- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index d94d3a2fc..aa8e5d910 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -618,7 +618,11 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende /// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares /// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image. pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> { - let plan = plan(src, info, s, req); + // Pick from the same settings as the render: disabled panels contribute nothing, and an + // SDR preview of an HDR edit includes the SDR rendition's exposure offset. + let effective = s.effective(); + let rendition = settings_for(&effective, req); + let plan = plan(src, info, &rendition, req); let mut img = plan.frame.sample(src, plan.w, plan.h); lin_cpu(&mut img, info, &plan); let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p); @@ -626,7 +630,7 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 { return None; } - let gain = (s.light.exposure as f32).exp2(); + let gain = (plan.settings.light.exposure as f32).exp2(); let mut acc = [0f64; 3]; let mut n = 0.0; for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) { diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 2398feece..d407939d4 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -272,6 +272,61 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() { assert!(d <= 2, "max difference {d}"); } +#[test] +fn color_range_sampling_respects_disabled_sections() { + use crate::{MaskView, Overlay, color_range_sample}; + let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]); + let info = SourceInfo::default(); + let req = RenderRequest::fit(32, 24); + let point = lightcraft_geom::Point::new(0.5, 0.5); + let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &req, point).unwrap(); + for section in ["light", "color"] { + let mut s = DevelopSettings::default(); + if section == "light" { + s.light.exposure = 3.0; + } else { + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = 12000.0; + s.wb.tint = 100.0; + } + assert_ne!(color_range_sample(&src, &info, &s, &req, point).unwrap(), plain, "enabled {section}"); + s.set_section_enabled(section, false); + let sample = color_range_sample(&src, &info, &s, &req, point).unwrap(); + assert_eq!(sample, plain, "disabled {section} must not change the picked colour"); + s.masks.push(lightcraft_develop::Mask { + id: 1, + components: vec![lightcraft_develop::MaskComponent { + name: None, + op: lightcraft_develop::MaskOp::Add, + invert: false, + shape: lightcraft_develop::MaskShape::ColorRange { samples: vec![sample], refine: 0.0 }, + }], + ..Default::default() + }); + let overlay = RenderRequest { overlay: Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }, ..req }; + let img = render(&src, &info, &s, &overlay).image; + assert_eq!(img.data[12 * 32 + 16], [255; 4], "the sampled colour is selected with {section} off"); + } +} + +#[test] +fn color_range_sampling_uses_the_requested_hdr_rendition() { + use crate::{OutputDepth, color_range_sample}; + let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]); + let info = SourceInfo::default(); + let point = lightcraft_geom::Point::new(0.5, 0.5); + let mut s = DevelopSettings::default(); + s.hdr.enabled = true; + s.hdr.sdr_brightness = 100.0; + let req = RenderRequest::fit(32, 24); + let expected = color_range_sample(&src, &info, &s.sdr_rendition(), &req, point).unwrap(); + assert_eq!(color_range_sample(&src, &info, &s, &req, point).unwrap(), expected, "SDR preview brightness affects the picked colour"); + let hdr = RenderRequest { depth: OutputDepth::F32Hdr, ..req }; + let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &hdr, point).unwrap(); + assert_eq!(color_range_sample(&src, &info, &s, &hdr, point).unwrap(), plain, "HDR does not use the SDR offset"); + assert_ne!(plain, expected); +} + /// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color /// and Detail, which the pipeline doesn't switch off where it applies them. #[test] diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 534a207ca..78ac49cd5 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -537,18 +537,27 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape p.circle_filled(pos2(x(v), rect.center().y), 5.0, egui::Color32::WHITE); p.circle_stroke(pos2(x(v), rect.center().y), 5.0, Stroke::new(1.0, egui::Color32::from_gray(40))); } - // drag the nearer handle; one undo step per drag + // Choose at the press position, then keep that handle for the entire drag. Testing + // proximity on every frame can move the opposite endpoint after a fast/crossing drag. + let handle_id = resp.id.with("luminance-handle"); + let nearer_low = |px: f32| (px - x(lo)).abs() <= (px - x(hi)).abs(); if resp.drag_started() { + if let Some(origin) = ui.input(|i| i.pointer.press_origin()) { + ui.data_mut(|d| d.insert_temp(handle_id, nearer_low(origin.x))); + } let _ = app.run("develop.beginInteraction", json!({"label": "Luminance Range"})); } if (resp.dragged() || resp.clicked()) && let Some(pos) = resp.interact_pointer_pos() { let v = (((pos.x - rect.left()) / w) as f64).clamp(0.0, 1.0); - let (nlo, nhi) = if (v - lo).abs() <= (v - hi).abs() { (v.min(hi - 0.01), hi) } else { (lo, v.max(lo + 0.01)) }; + let low = + if resp.dragged() { ui.data(|d| d.get_temp::<bool>(handle_id)).unwrap_or_else(|| nearer_low(pos.x)) } else { nearer_low(pos.x) }; + let (nlo, nhi) = if low { (v.min((hi - 0.01).max(0.0)), hi) } else { (lo, v.max((lo + 0.01).min(1.0))) }; update(app, MaskShape::LuminanceRange { lo: nlo, hi: nhi, lo_feather: *lo_feather, hi_feather: *hi_feather }); } if resp.drag_stopped() { + ui.data_mut(|d| d.remove::<bool>(handle_id)); let _ = app.run("develop.endInteraction", json!({})); } // Smoothness: both falloffs at once diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bf808b532..9c279c73d 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -832,6 +832,31 @@ fn g_toggles_grids_and_shift_g_starts_guided_upright() { h.settle(SETTLE); } +#[test] +fn luminance_range_drag_keeps_the_grabbed_handle() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "luminanceRange", "lo": 0.2, "hi": 0.8})); + h.settle(SETTLE); + let original = develop(&h); + for (start, dx, steps) in [(0.2, 180.0, 10), (0.8, -180.0, 10), (0.2, 120.0, 1), (0.8, -120.0, 1)] { + let undo0 = h.app.session.undo.len(); + let r = h.request("ui.dragWidget", json!({"id": "lumRange:0", "fx": start, "fy": 0.5, "dx": dx, "steps": steps}), T); + assert_eq!(r["ok"], true, "{r}"); + let d = develop(&h); + let MaskShape::LuminanceRange { lo, hi, .. } = &d.masks[0].components[0].shape else { panic!() }; + if start < 0.5 { + assert_eq!(*hi, 0.8, "dragging low must leave high fixed, including past high"); + assert!(*lo > 0.6 && *lo < *hi, "{lo}..{hi}"); + } else { + assert_eq!(*lo, 0.2, "dragging high must leave low fixed, including past low"); + assert!(*hi < 0.4 && *hi > *lo, "{lo}..{hi}"); + } + assert_eq!(h.app.session.undo.len(), undo0 + 1); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), original); + } +} + #[test] fn luminance_range_controls_and_map() { let mut h = detail("panel.masking"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..01f9b58dd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (mask range regressions: luminance drag handle identity and colour sampling with disabled sections / HDR SDR rendition; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -295,8 +295,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | -| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | -| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | +| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | +| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (the pressed endpoint stays selected through fast/crossing drags; one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | | LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent`, `cmd:tool.brush` | Add/Subtract > Brush creates a component with the chosen operation; Erase removes paint within that brush component | | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | patch | LR-MASK-LUMRANGE / LR-MASK-COLORRANGE: range drags keep the grabbed endpoint; colour samples use the render's enabled sections and HDR/SDR settings; regression coverage, status unchanged | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From e665aff1b3124e3dbdbe29a571388fc1e26aab26 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:34:54 +0200 Subject: [PATCH 111/315] docs: re-verify against 0.7.1 - All pages checked against the v0.7.1 tag and its release assets; verified: bumped to 0.7.1. - Install: 32-bit Windows (x86) installer and portable build; pre-0.6 upgrades keep their install folder. - Import: Recently Deleted photos are restored by default; progress shows in the activity stack. - Edit: HDR button next to B&W. - Export: JPEG XL Effort and Progressive, lossy JPEG XL colour space note, Add to Apple Photos on macOS. - Lightroom import: activity stack progress and stopping with its X. - Raw troubleshooting: Adobe RGB fix now released, monochrome picture style tone, EOS R5 Mark II C-RAW decodes, Leica DNG and dark scenes still open; drop "import again" (duplicates are skipped). - docs/user/CLAUDE.md is a regular file instead of a symlink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/user/CLAUDE.md | 2 +- docs/user/faq/free-and-private.md | 2 +- docs/user/faq/supported-files.md | 4 ++-- docs/user/getting-started/install.md | 10 ++++++---- docs/user/guides/cull-photos.md | 2 +- docs/user/guides/edit-a-photo.md | 6 +++++- docs/user/guides/export-photos.md | 9 +++++---- docs/user/guides/import-lightroom-catalog.md | 7 ++++--- docs/user/guides/import-photos.md | 6 +++--- docs/user/index.md | 2 +- docs/user/troubleshooting/get-help.md | 2 +- docs/user/troubleshooting/preview-only-raw.md | 6 ++++-- .../user/troubleshooting/raw-photos-look-dull.md | 16 +++++++++------- 13 files changed, 43 insertions(+), 31 deletions(-) mode change 120000 => 100644 docs/user/CLAUDE.md diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/docs/user/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 000000000..e7bcfb70e --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/free-and-private.md b/docs/user/faq/free-and-private.md index 28f7104d2..7eb64052e 100644 --- a/docs/user/faq/free-and-private.md +++ b/docs/user/faq/free-and-private.md @@ -2,7 +2,7 @@ title: Is LightCraft free, and does it need an account? description: LightCraft is free and open source, needs no account or subscription, and keeps your library on your computer. order: 10 -verified: 0.6.0 +verified: 0.7.1 --- Yes, it is free. LightCraft is open source under the MIT or Apache-2.0 licence, your choice. It needs no account, no cloud service and no subscription, and it sends no telemetry. diff --git a/docs/user/faq/supported-files.md b/docs/user/faq/supported-files.md index 65da107ff..143276f13 100644 --- a/docs/user/faq/supported-files.md +++ b/docs/user/faq/supported-files.md @@ -2,14 +2,14 @@ title: Which cameras and file types does LightCraft open? description: The raw and image formats LightCraft reads, what happens with raws it cannot decode yet, and how to open Lightroom catalogs. order: 20 -verified: 0.6.0 +verified: 0.7.1 --- LightCraft reads raw files from DNG, Canon CR2 and CR3, Sony ARW, Nikon NEF, Fujifilm RAF (including X-Trans), Panasonic RW2, Leica RWL, Pentax PEF and Olympus ORF cameras. It also opens JPEG, PNG, TIFF, WebP, PSD composites and JPEG XL. ## What if my camera is not decoded yet? -A raw that LightCraft cannot decode opens from the camera's embedded JPEG instead, marked **Preview only**. Compressed Olympus raws and some Canon CR3 variants work this way. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). +A raw that LightCraft cannot decode opens from the camera's embedded JPEG instead, marked **Preview only**. Compressed Olympus raws, some Canon CR3 variants and Nikon High Efficiency NEF files work this way. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). How closely the starting look matches your camera's own JPEG varies by model. See [Raw photos look dull or washed out](../troubleshooting/raw-photos-look-dull.md). The project's [raw support table](https://github.com/storytold/lightcraft/blob/main/docs/raw-parity.md) lists what is known per camera. diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md index 689d2c876..8327222cb 100644 --- a/docs/user/getting-started/install.md +++ b/docs/user/getting-started/install.md @@ -2,10 +2,10 @@ title: Install LightCraft description: Download LightCraft for Windows 11, macOS or Linux, install it, and open your photo library. order: 10 -verified: 0.6.0 +verified: 0.7.1 --- -Download the file for your system from the [latest release](https://github.com/storytold/lightcraft/releases/latest) and install it. In the file names, `<version>` stands for the version number, for example `0.6.0`. The [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft) also links the download. +Download the file for your system from the [latest release](https://github.com/storytold/lightcraft/releases/latest) and install it. In the file names, `<version>` stands for the version number, for example `0.7.1`. The [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft) also links the download. ## Windows 11 @@ -14,9 +14,11 @@ Download the file for your system from the [latest release](https://github.com/s 3. Run the file. The installer asks where to install. The default is `C:\Program Files\LightCraft`, and upgrades stay in the folder you chose. 4. At the end, choose the option to start LightCraft. -For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. +For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. For a 32-bit Windows, download `lightcraft-<version>-windows-x86.msi`. -To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. A portable build for `arm64` is on the release page too. +Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. If that folder was deleted, the upgrade installs to `C:\Program Files\LightCraft`. + +To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. ## macOS diff --git a/docs/user/guides/cull-photos.md b/docs/user/guides/cull-photos.md index 30d0b4759..ae76982e9 100644 --- a/docs/user/guides/cull-photos.md +++ b/docs/user/guides/cull-photos.md @@ -2,7 +2,7 @@ title: Rate, flag and label photos description: Sort a shoot with star ratings, pick and reject flags and colour labels, from the keyboard or the Photo menu. order: 20 -verified: 0.6.0 +verified: 0.7.1 --- Select photos in **Photo Grid** or **Square Grid**, then press a key: `1` to `5` for stars, `P` to pick, `X` to reject, `6` to `9` for a colour label. The same actions are in the **Photo** menu. diff --git a/docs/user/guides/edit-a-photo.md b/docs/user/guides/edit-a-photo.md index 57c67138c..126324159 100644 --- a/docs/user/guides/edit-a-photo.md +++ b/docs/user/guides/edit-a-photo.md @@ -2,7 +2,7 @@ title: Edit a photo description: Open a photo in the Edit panel, adjust Light, Color and Effects sliders, compare with the original, and undo or reset. order: 30 -verified: 0.6.0 +verified: 0.7.1 --- Double-click a photo in the grid, then press `E` to open the **Edit** panel. Drag a slider to change the photo. Your original file is never changed, because edits are stored in the library. @@ -21,6 +21,10 @@ Double-click a photo in the grid, then press `E` to open the **Edit** panel. Dra Click **B&W** at the top of the **Edit** panel to turn the photo monochrome. Click it again to return to colour. +## HDR + +Click **HDR**, next to **B&W**, to edit the photo in HDR, with highlights brighter than SDR white for HDR exports. LightCraft cannot show HDR on an HDR screen yet, so you edit without seeing the extra brightness. + ## Other edit tools Open them from the **Window** menu, or press the key shown next to each one: diff --git a/docs/user/guides/export-photos.md b/docs/user/guides/export-photos.md index 784deb7ba..71bca3241 100644 --- a/docs/user/guides/export-photos.md +++ b/docs/user/guides/export-photos.md @@ -2,7 +2,7 @@ title: Export photos description: Save edited photos as JPEG, PNG, TIFF and other files with the size, folder and file names you choose. order: 40 -verified: 0.6.0 +verified: 0.7.1 --- Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **OK**. Your originals are not changed. @@ -12,14 +12,15 @@ Select the photos, then choose **File** > **Export…**. Pick a format, a size a 1. Select one or more photos in the grid. 2. Choose **File** > **Export…**. The **Export** window shows how many photos it will export. 3. Set **Format**: **JPEG**, **PNG**, **TIFF**, **PSD**, **WebP**, **AVIF**, **JPEG XL**, **DNG** or **Original files**. **DNG** writes raw photos as DNG with the edits embedded. **Original files** copies the files unchanged, each with an XMP sidecar that holds its edits. -4. For **JPEG**, **AVIF** or lossy **JPEG XL**, set **Quality**. For **JPEG XL**, tick **Lossless** to keep every pixel exactly, at the cost of larger files. +4. For **JPEG**, **AVIF** or lossy **JPEG XL**, set **Quality**. For **JPEG XL**, tick **Lossless** to keep every pixel exactly, at the cost of larger files. **Effort** trades time for file size, and **Progressive** makes the file show a coarse version first while it loads. 5. Under the size options, tick **Full size**, or clear it and choose **Resize to** one of **Long Edge**, **Short Edge**, **Width**, **Height**, **Width × Height**, **Megapixels** or **Percentage**, then enter the value. -6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. +6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. Lossy **JPEG XL** writes **Adobe RGB** and **ProPhoto** as **Rec.2020**. Tick **Lossless** to keep them. 7. Optional: set **Sharpen** for **Screen**, **Matte** or **Glossy** output, set **Metadata** to **All**, **No camera**, **Copyright** or **None**, and tick **Remove location info** or **Watermark**. 8. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. 9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. 10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. -11. Click **OK**. +11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. +12. Click **OK**. The export shows its progress in the activity stack at the top left. If you did not choose a folder, the window stays open and shows an error, so your choices are not lost. diff --git a/docs/user/guides/import-lightroom-catalog.md b/docs/user/guides/import-lightroom-catalog.md index e06705d9b..df26df566 100644 --- a/docs/user/guides/import-lightroom-catalog.md +++ b/docs/user/guides/import-lightroom-catalog.md @@ -2,7 +2,7 @@ title: Move over from Lightroom description: Import a Lightroom Classic catalog into LightCraft, and learn what comes across and what does not. order: 50 -verified: 0.6.0 +verified: 0.7.1 --- Choose **File** > **Import Lightroom Catalog…** and pick your `.lrcat` file. LightCraft reads it directly. You do not need Lightroom installed. @@ -12,7 +12,7 @@ Choose **File** > **Import Lightroom Catalog…** and pick your `.lrcat` file. L 1. Open the library you want the photos in. To start a new one, use **File** > **New Library…**. 2. Choose **File** > **Import Lightroom Catalog…**. 3. Pick your Lightroom Classic catalog, the file that ends in `.lrcat`. You can also give **File** > **Open Library…** a Lightroom catalog folder. LightCraft then offers this import instead of making a library there. -4. Wait for the import to finish. It shows progress and runs in the background. A message reports how many photos were added. +4. Wait for the import to finish. It runs in the background, with a progress row in the activity stack at the top left. A message reports how many photos were added. If you change your mind, one **Edit** > **Undo** reverses the changes to the catalog. @@ -36,5 +36,6 @@ Original files stay where they are, and LightCraft refers to them in place. Phot - Before it changes anything, LightCraft tries to save your source settings, history and collections in a recovery archive in the library's `Interop` folder. This is best effort. If it fails, the photos are still imported with a warning. - Importing the same catalog again does not duplicate virtual copies or collections. By default, edits you already made in LightCraft are kept. -- A big catalog can take a long time. In one report, 38,000 photos took about 25 minutes from a USB SSD, and **Cancel** waited for the current work to finish. +- A big catalog can take a long time. In one report on an earlier version, 38,000 photos took about 25 minutes from a USB SSD. +- To stop the import, click ✕ on its row in the activity stack. This works while LightCraft reads the catalog. Once it starts adding the photos, it finishes. - The browser version cannot import a catalog. diff --git a/docs/user/guides/import-photos.md b/docs/user/guides/import-photos.md index 13e1b5eec..8130272ae 100644 --- a/docs/user/guides/import-photos.md +++ b/docs/user/guides/import-photos.md @@ -2,7 +2,7 @@ title: Import photos description: Bring photos from a folder or card into your LightCraft library, in place or as copies. order: 10 -verified: 0.6.0 +verified: 0.7.1 --- Use **File** > **Import Photos…** to pick files, or **File** > **Import from Folder…** to scan a whole folder. Both open the **Import Photos** window, where you choose what to bring in and how. @@ -11,7 +11,7 @@ Use **File** > **Import Photos…** to pick files, or **File** > **Import from F 1. Open the **File** menu and choose **Import Photos…** or **Import from Folder…**, then pick the files or the folder. 2. Look at the thumbnails. Every photo that can be imported has a checkbox. Click a photo to check or uncheck it. Shift-click sets a range. **Check All** and **Uncheck All** change every photo at once. -3. Photos already in your library are marked **Duplicate** or **In library** and start unchecked. A file LightCraft cannot read is marked **Unreadable**. +3. Photos already in your library are marked **Duplicate** or **In library** and start unchecked. A file LightCraft cannot read is marked **Unreadable**. A photo you deleted that is still in **Recently Deleted** is marked **Will be restored**. **In Recently Deleted** sets what happens to those: **Restore them** (the default) brings the photos back with their edits, ratings and albums, **Import as new** deletes the old record first, and **Leave them** skips them. 4. Under **Transfer**, choose how the files get into the library: - **Add in place** keeps the files where they are. The library only points to them. Nothing is copied or moved. - **Copy** copies the files into the library's `Originals` folder, or into a folder you pick with **Choose…** next to **Copy to**. @@ -20,7 +20,7 @@ Use **File** > **Import Photos…** to pick files, or **File** > **Import from F 6. Optional: set **Album** to add the photos to an existing album or a new one, **Preset** to apply a preset to every photo, and **Keywords** (separated by commas). 7. Click the button at the bottom. It shows the number of checked photos, for example **Import 24 Photos**. With **Move** it reads **Move 24 Photos**. -The import runs in the background, and you can keep working while it does. The whole import is one undo step. +The import runs in the background, with a progress row in the activity stack at the top left, and you can keep working while it does. The whole import is one undo step. ## Good to know diff --git a/docs/user/index.md b/docs/user/index.md index 08e631e72..a0655a0d3 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -1,7 +1,7 @@ --- title: LightCraft description: LightCraft is a free, open-source photo library and raw developer, an alternative to Adobe Lightroom. -verified: 0.6.0 +verified: 0.7.1 --- LightCraft is a photo library and raw developer that runs on your own computer. It is an open-source alternative to Adobe Lightroom, written in Rust, and part of the Craft App Suite from the ArtCraft team. diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md index 0eea58be9..963c58221 100644 --- a/docs/user/troubleshooting/get-help.md +++ b/docs/user/troubleshooting/get-help.md @@ -2,7 +2,7 @@ title: Get help with a problem description: What to include when you ask for help with a LightCraft bug, and where to post it. order: 30 -verified: 0.6.0 +verified: 0.7.1 --- Open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues), or ask in [Discord](https://discord.gg/artcraft). Include your version, your system, what you did, and what you expected. diff --git a/docs/user/troubleshooting/preview-only-raw.md b/docs/user/troubleshooting/preview-only-raw.md index af7720a37..b3eb4dab0 100644 --- a/docs/user/troubleshooting/preview-only-raw.md +++ b/docs/user/troubleshooting/preview-only-raw.md @@ -2,7 +2,7 @@ title: A raw photo opens as "Preview only" description: Why LightCraft shows a raw photo as Preview only, and how to make it decode after an update with Reload from Disk. order: 10 -verified: 0.6.0 +verified: 0.7.1 --- Select the photo and choose **Photo** > **Reload from Disk**. If your version can decode that raw now, LightCraft reads the sensor data again and the **Preview only** notice goes away. @@ -22,7 +22,9 @@ If **Preview only** stays, your version still cannot decode that raw variant. Up ## Why this happens -When LightCraft cannot decode a raw on import, it remembers that and shows the camera's embedded JPEG. It does not check again after an update on its own. **Reload from Disk** is the way to make it check, and importing the photos again works too. Compressed Olympus raws and some Canon CR3 variants are examples of files that use the embedded preview. +When LightCraft cannot decode a raw on import, it remembers that and shows the camera's embedded JPEG. It does not check again after an update on its own. **Reload from Disk** is the way to make it check. Compressed Olympus raws, some Canon CR3 variants and Nikon High Efficiency NEF files are examples of files that use the embedded preview. + +0.7.1 decodes Canon EOS R5 Mark II C-RAW files that earlier versions opened as **Preview only**. Photos imported before switch over after **Reload from Disk**. Photos shown from the preview keep the camera's picture style and white balance, because they are the camera's own JPEG. diff --git a/docs/user/troubleshooting/raw-photos-look-dull.md b/docs/user/troubleshooting/raw-photos-look-dull.md index 94c615394..c1f6570f5 100644 --- a/docs/user/troubleshooting/raw-photos-look-dull.md +++ b/docs/user/troubleshooting/raw-photos-look-dull.md @@ -2,10 +2,10 @@ title: Raw photos look dull or washed out description: What to do when a raw photo looks flat, grey or desaturated in LightCraft compared with the camera JPEG. order: 20 -verified: 0.6.0 +verified: 0.7.1 --- -Update to the latest LightCraft, then import the photo again. Colour handling for raw files changed a lot between 0.4.0, 0.5.0 and 0.6.0, and several flat-looking Sony and Fujifilm cases are fixed. Some Leica DNGs still look dull. +Update to the latest LightCraft, then select the photo and choose **Photo** > **Reload from Disk**. Colour handling for raw files changed a lot between 0.4.0 and 0.7.1, and several flat-looking Sony and Fujifilm cases are fixed. Some Leica DNGs and dark, muted scenes still look dull. ## Symptoms @@ -15,8 +15,8 @@ Update to the latest LightCraft, then import the photo again. Colour handling fo ## Fix -1. Check your version in **Help** > **About LightCraft** (on macOS, **LightCraft** > **About LightCraft**). If it is older than 0.6.0, install the [latest release](https://github.com/storytold/lightcraft/releases/latest). See [Install LightCraft](../getting-started/install.md). -2. Import the raw photo again, or select it and choose **Photo** > **Reload from Disk**. +1. Check your version in **Help** > **About LightCraft** (on macOS, **LightCraft** > **About LightCraft**). If it is older than 0.7.1, install the [latest release](https://github.com/storytold/lightcraft/releases/latest). See [Install LightCraft](../getting-started/install.md). +2. Select the raw photo and choose **Photo** > **Reload from Disk**. 3. If it still looks dull, adjust it by hand in the **Edit** panel. See [Edit a photo](../guides/edit-a-photo.md). 4. If you think the starting look is wrong for your camera, report it with the original raw file. See [Get help with a problem](get-help.md). @@ -24,10 +24,12 @@ Update to the latest LightCraft, then import the photo again. Colour handling fo LightCraft develops a raw from the sensor data and tries to match the look of the camera's own JPEG. How well it matches depends on the camera model. -- **Sony ARW:** on 0.5.0 and older, some Lossless Compressed M-size files had lifted blacks, and an A7 II file lost most of its colour. Both are fixed in 0.6.0. On the A7 II file, brighter midtones can still look grey-green in 0.6.0 ([issue 865](https://github.com/storytold/lightcraft/issues/865)). Some files still fall short on dark scenes and on in-camera Dynamic Range Optimizer shots. +- **Sony ARW:** on 0.5.0 and older, some Lossless Compressed M-size files had lifted blacks, and an A7 II file lost most of its colour. Both are fixed since 0.6.0. Some files still fall short on in-camera Dynamic Range Optimizer shots. +- **Dark, muted scenes:** LightCraft applies only part of the camera look to them, so they can start duller than the camera JPEG. This is not fixed in 0.7.1 ([issue 865](https://github.com/storytold/lightcraft/issues/865)). - **Fujifilm X-Pro1 and Sony A7 IV:** reported dull on 0.4.0 and fine on 0.6.0. -- **Leica DNG:** these files carry a colour matrix but no tone curve, so 0.6.0 shows them darker and flatter than the camera JPEG. This is not fixed in 0.6.0 ([pull request 807](https://github.com/storytold/lightcraft/pull/807)). -- **Adobe RGB:** if the camera was set to Adobe RGB, LightCraft read the embedded JPEG as sRGB and matched to a too-dull target. The fix came after 0.6.0 and is not in a release yet. +- **Leica DNG:** these files carry a colour matrix but no tone curve, so LightCraft shows them darker and flatter than the camera JPEG. This is not fixed in 0.7.1 ([pull request 807](https://github.com/storytold/lightcraft/pull/807)). +- **Adobe RGB:** if the camera was set to Adobe RGB, 0.6.0 and older read the embedded JPEG as sRGB and matched to a too-dull target. Fixed in 0.7.1: LightCraft reads the JPEG in the colour space the raw file states. +- **Black-and-white picture style:** a raw shot in a monochrome picture style started about 1.3 EV darker than the same camera's colour shots. Since 0.7.1, LightCraft takes the tone from the camera's black-and-white JPEG. ## A soft picture after zooming in From 6e20f823602bdd87726e831c529d53fd2b46ab24 Mon Sep 17 00:00:00 2001 From: squareSphere29 <18124696+squareSphere29@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:02:42 +0800 Subject: [PATCH 112/315] LR-LIB-STACK: deleting a stacked photo drops it from the stack so i/n badges update (fixes #1493) Soft-delete and remove-folder now unstack in the same undo step as Recently Deleted, so leftover members show 1/2 not 3/3. Restore-from-trash does not restack. Co-authored-by: Cursor <cursoragent@cursor.com> --- crates/engine/src/cmd/library.rs | 21 +++++++++++++++++---- crates/engine/src/tests_folders.rs | 15 +++++++++++++++ crates/engine/src/tests_import.rs | 2 +- crates/engine/src/tests_organize.rs | 23 +++++++++++++++++++++++ docs/parity-checklist.md | 6 +++--- 5 files changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..917600580 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -167,6 +167,19 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re Ok((kept, skipped)) } +/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep +/// showing `i/n` (issue #1493). +fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> { + let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); + let n = ops.len(); + if n == 0 { + return Ok(0); + } + ops.extend(s.catalog.remove_from_stacks_ops(ids)); + s.commit(label, Op::Batch { ops })?; + Ok(n) +} + /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> { let targets = s.targets(p); @@ -436,8 +449,7 @@ pub fn specs() -> Vec<CommandSpec> { if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); } - let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); - s.commit("Remove Folder from Library", Op::Batch { ops })?; + mark_deleted(s, &ids, "Remove Folder from Library")?; if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.filter.library_folder = None; } @@ -779,9 +791,10 @@ pub fn specs() -> Vec<CommandSpec> { // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let before = s.visible_cloned(); - let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; + let targets = s.targets(p); + let n = mark_deleted(s, &targets, "Delete")?; select_after_deletion(s, &before); - Ok(v) + Ok(json!({"changed": n})) }), cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { id, diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index 697c98087..34017fc97 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -106,6 +106,21 @@ fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_b assert_eq!(s.visible().len(), 3, "one undo step"); } +#[test] +fn removing_a_folder_drops_its_photos_from_stacks() { + let mut s = session(); + let trip: Vec<_> = + s.catalog.photos().filter(|p| matches!(&p.source, Source::File { path } if path.contains("/pics/trip/"))).map(|p| p.id).collect(); + assert_eq!(trip.len(), 2); + s.execute("library.select", &json!({"ids": [trip[0].0, trip[1].0], "active": trip[0].0})).unwrap(); + s.execute("stack.group", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(trip[0]).unwrap().photos.len(), 2); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert!(s.catalog.stack_of(trip[0]).is_none() && s.catalog.stack_of(trip[1]).is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(trip[0]).unwrap().photos.len(), 2); +} + #[test] fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() { let mut s = session(); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..44172b555 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1119,7 +1119,7 @@ fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.len(), 2, "one undo step brings both back"); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.len(), 2); - assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_some()); + assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_none(), "soft-delete already left the stack"); assert!(s.catalog.photos().all(|p| p.deleted), "back in Recently Deleted"); let _ = std::fs::remove_dir_all(&src); } diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 80d1ae546..8990acb4c 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -141,6 +141,29 @@ fn virtual_copies_are_independent_and_persist() { let _ = std::fs::remove_dir_all(&dir); } +/// Issue #1493: soft-delete must drop the copy from the stack so `1/2` does not linger, and +/// deleting `2/3` renumbers the rest to `1/2` `2/2`. Undo restores the stack. +#[test] +fn deleting_a_virtual_copy_drops_it_from_the_stack() { + let mut s = Session::with_demo(); + let src = s.visible_cloned()[0]; + s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); + let c1 = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); + let c2 = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap()["ids"][0].as_u64().unwrap()); + assert_eq!(s.catalog.stack_of(src).unwrap().photos, vec![src, c2, c1]); + s.execute("photo.delete", &json!({"ids": [c2.0]})).unwrap(); + let left = s.catalog.stack_of(src).expect("two members remain stacked"); + assert_eq!(left.photos, vec![src, c1]); + assert_eq!((left.position(src), left.position(c1), left.photos.len()), (Some(0), Some(1), 2), "1/2 and 2/2"); + assert!(s.catalog.photo(c2).unwrap().deleted); + s.execute("photo.delete", &json!({"ids": [c1.0]})).unwrap(); + assert!(s.catalog.stack_of(src).is_none(), "the original is unstacked once it is the only member"); + assert!(s.catalog.photo(c1).unwrap().deleted); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.catalog.photo(c1).unwrap().deleted); + assert_eq!(s.catalog.stack_of(src).unwrap().photos, vec![src, c1]); +} + #[test] fn stacks_group_collapse_and_replay() { let dir = temp_dir("stacks"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..38daac7ab 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-LIB-STACK / LR-LIB-VIRTUALCOPY: deleting a stacked photo or removing its folder drops it from the stack so `i/n` badges update (#1493); LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -105,7 +105,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | @@ -117,7 +117,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | -| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” | +| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `cmd:photo.delete`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; deleting a copy dissolves or renumbers the stack (#1493); no “Set Copy as Master” | ## C. Views & navigation (VIEW) From fdad5a4ad437583723d1a3c4bf22641a473ecfbc Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:06:06 +0200 Subject: [PATCH 113/315] LR-VIEW-FOCUSPOINT: read focus points for photos already in the library, header-only in the background (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos imported before focus points were read got one only through Reload from Disk, which hashes whole files on the UI thread. Now: - lightcraft_meta::focus::read_header reads only what the decoder looks at: IFD0 (Make, Model), the Exif IFD, the maker note's IFD and the focus tags' values (a JPEG: its segments up to the Exif one), walked round by round and decoded with the whole-file code from a buffer of the file's length holding just those ranges. Bounded rounds, bytes (2 MB) and size (1 GB). One 32 KB read per α1 / A7S III ARW; on 109 real files (83 with a point) it gives the whole-file read's point for every one. - A background job (crates/engine/src/focus_scan.rs) reads photos with no point from a supported body (by the stored camera; nothing else is opened), ARW or JPEG, one at a time in path order, with a "Reading focus points" activity row (Cancel::Flag). The UI's per-frame focus.pump gives it the face scan's pace (paused while dragging or typing) and starts it once per library opening. Commits every 200 photos or 3 s as system ops (no undo step, no sidecar write), only meta.focus, only on photos that still have none at the same path. "Looked, none" goes to focus-scanned.bin beside the library, headed by the decoder's tag so a decoder knowing more bodies starts afresh; unreadable files are retried at the next opening. - photo.readFocusPoints {ids?, wait?} (Photo > Read Focus Points) runs it on demand. Measured cold on a 5400 rpm USB drive: 200 α1 / A7S III ARWs in 1.9 s (~100 photos/s, 32 KB each), so 58,796 raws take about 10 minutes instead of re-reading 2.25 TB. --- crates/engine/src/activity.rs | 2 +- crates/engine/src/cmd/focus.rs | 50 +++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/faces_index.rs | 29 +- crates/engine/src/focus_scan.rs | 432 ++++++++++++++++++++++ crates/engine/src/lib.rs | 9 + crates/engine/src/tests_focus_scan.rs | 297 +++++++++++++++ crates/meta/src/focus.rs | 198 +++++++++- crates/meta/src/focus_read.rs | 465 ++++++++++++++++++++++++ crates/meta/src/lib.rs | 1 + crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/focus_scan.rs | 24 ++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/panels/faces.rs | 7 + crates/ui-egui/src/tests_focus_point.rs | 31 ++ docs/background-tasks.md | 3 +- docs/control-protocol.md | 3 + docs/focus-points.md | 44 ++- docs/mcp.md | 2 +- docs/parity-checklist.md | 4 +- docs/whats-new.md | 6 +- 30 files changed, 1621 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/focus.rs create mode 100644 crates/engine/src/focus_scan.rs create mode 100644 crates/engine/src/tests_focus_scan.rs create mode 100644 crates/meta/src/focus_read.rs create mode 100644 crates/ui-egui/src/focus_scan.rs diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 6fc618039..eb02ff8bf 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -73,7 +73,7 @@ impl Unit { pub struct TaskInfo { pub id: u64, /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, - /// `faces`, `denoise`, `findMissing`, `applePhotos`. + /// `faces`, `denoise`, `findMissing`, `applePhotos`, `focus`. pub kind: &'static str, /// English; the frontend translates it. pub label: String, diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs new file mode 100644 index 000000000..efe1d1b7f --- /dev/null +++ b/crates/engine/src/cmd/focus.rs @@ -0,0 +1,50 @@ +//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the +//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`. + +use serde_json::Value; + +use super::{CommandSpec, cmd}; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +fn read_points(s: &mut Session, p: &Value) -> Result<Value> { + crate::focus_scan::read_points(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn read_points(_: &mut Session, _: &Value) -> Result<Value> { + Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn pump(s: &mut Session, p: &Value) -> Result<Value> { + crate::focus_scan::pump(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn pump(_: &mut Session, _: &Value) -> Result<Value> { + Ok(serde_json::json!({"active": false})) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "photo.readFocusPoints", + "Read Focus Points", + ["Photo"], + None, + "{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)", + super::always, + read_points + ), + cmd!( + query "focus.pump", + "Read Focus Points in the Background", + [], + None, + "{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at", + super::always, + pump + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..47477d3c7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +mod focus; pub mod geocode; pub mod keywords; pub mod library; @@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(focus::specs()); v.extend(activity::specs()); v }) diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index fd4d9f2e7..1b5920999 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -222,15 +222,29 @@ const SCAN_MAX_FILE: u64 = 64 << 20; /// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft /// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to). -#[derive(Default)] +/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag). pub(crate) struct Scanned { set: std::collections::HashSet<u64>, unsaved: Vec<u64>, path: Option<PathBuf>, file_matches: bool, + magic: [u8; 4], + /// What made the record: a file made by anything else is started afresh. + tag: String, +} + +impl Default for Scanned { + fn default() -> Self { + Scanned::new(*SCAN_MAGIC, SCAN_TAG) + } } impl Scanned { + /// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`. + pub fn new(magic: [u8; 4], tag: &str) -> Scanned { + Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, magic, tag: tag.to_string() } + } + pub fn path(&self) -> Option<&Path> { self.path.as_deref() } @@ -265,12 +279,13 @@ impl Scanned { } } - fn header() -> Vec<u8> { + fn header(&self) -> Vec<u8> { let mut h = Vec::new(); - h.extend_from_slice(SCAN_MAGIC); + h.extend_from_slice(&self.magic); h.push(SCAN_VERSION); - h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes()); - h.extend_from_slice(SCAN_TAG.as_bytes()); + let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes()); + h.extend_from_slice(&(tag.len() as u16).to_le_bytes()); + h.extend_from_slice(tag); h } @@ -280,7 +295,7 @@ impl Scanned { return; } let Ok(bytes) = std::fs::read(path) else { return }; - let head = Self::header(); + let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; // a half-written last id (a crash while appending) is ignored @@ -302,7 +317,7 @@ impl Scanned { let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); f.write_all(&buf)?; } else { - let mut buf = Self::header(); + let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); let part = path.with_extension("part"); std::fs::write(&part, &buf)?; diff --git a/crates/engine/src/focus_scan.rs b/crates/engine/src/focus_scan.rs new file mode 100644 index 000000000..6be63e7eb --- /dev/null +++ b/crates/engine/src/focus_scan.rs @@ -0,0 +1,432 @@ +//! Reading the camera's focus point for photos already in the library (issue #1480). +//! +//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job +//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body +//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few +//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1 +//! ARW), one file at a time on one worker thread. +//! +//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping +//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only +//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path. +//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in +//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the +//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are +//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded: +//! it is tried again the next time the library opens. +//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's +//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between +//! files while they are around. +//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand +//! with `photo.readFocusPoints`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use lightcraft_meta::focus::HeaderRead; +use serde_json::{Value, json}; + +use crate::activity::{Cancel, TaskGuard}; +use crate::faces_index::Scanned; +use crate::{Result, Session}; + +/// The record of photos looked at with nothing found, beside the library. +pub(crate) const RECORD_FILE: &str = "focus-scanned.bin"; +const RECORD_MAGIC: [u8; 4] = *b"LCFP"; +/// Photos committed at once. +pub(crate) const COMMIT_EVERY: usize = 200; +/// What has been read is committed at least this often, so points appear while a long run goes on. +const COMMIT_AFTER: Duration = Duration::from_secs(3); +/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first. +const LIGHT_REST: Duration = Duration::from_millis(40); +/// How often a paused worker looks at its pace again. +const PAUSE_POLL: Duration = Duration::from_millis(50); +/// Results taken in per pump (the rest wait for the next frame). +const MAX_DRAIN: usize = 5000; + +/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads). +const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"]; + +/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum Pace { + Pause = 0, + Light = 1, + Normal = 2, +} + +impl Pace { + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + _ => Pace::Normal, + } + } +} + +/// The focus point in the file at `path`, read from its header only. +pub fn read_focus(path: &str) -> std::result::Result<HeaderRead, String> { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + lightcraft_meta::focus::read_header(len, &mut |off, buf| { + file.seek(SeekFrom::Start(off))?; + file.read_exact(buf) + }) + .map_err(|e| format!("{path}: {e}")) +} + +/// What the worker says about one photo. +enum Outcome { + /// The header was read: the point it holds, or none. + Read { id: PhotoId, focus: Option<FocusPoint>, bytes: u64 }, + /// The file couldn't be read (offline, gone, changed under the read): tried again another time. + Unavailable, +} + +/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Summary { + pub total: u64, + pub done: u64, + pub found: u64, + pub none: u64, + pub unavailable: u64, + pub bytes: u64, + pub seconds: f64, + pub cancelled: bool, +} + +impl Summary { + fn json(&self) -> Value { + json!({ + "total": self.total, + "done": self.done, + "found": self.found, + "none": self.none, + "unavailable": self.unavailable, + "bytesRead": self.bytes, + "seconds": self.seconds, + "cancelled": self.cancelled, + }) + } +} + +struct Job { + identity: Arc<()>, + cancel: Arc<AtomicBool>, + pace: Arc<AtomicU8>, + rx: mpsc::Receiver<(Outcome, String)>, + /// Its row in the activity stack (dropping the job removes it). + guard: TaskGuard, + /// The path each photo was read at: a photo moved or relinked since is left for another run. + paths: HashMap<PhotoId, String>, + pending: Vec<(PhotoId, Option<FocusPoint>)>, + committed_at: Instant, + started: Instant, + summary: Summary, +} + +impl Drop for Job { + fn drop(&mut self) { + // the worker stops before its next file (and when it can't hand a result over) + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The backfill's state in the session. +pub(crate) struct FocusScan { + record: Scanned, + /// The library opening the record and the automatic start belong to. + library: Option<Arc<()>>, + auto_checked: bool, + job: Option<Job>, + last: Option<Summary>, + /// The pace the UI last gave (a job started between two pumps starts at it). + pace: Pace, +} + +impl Default for FocusScan { + fn default() -> Self { + FocusScan { + record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()), + library: None, + auto_checked: false, + job: None, + last: None, + pace: Pace::Normal, + } + } +} + +/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and +/// are read again there next time) and loads that library's record. +fn follow_library(s: &mut Session) { + if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) { + return; + } + s.focus_scan.job = None; + s.focus_scan.auto_checked = false; + let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE)); + s.focus_scan.record.reset(path); + s.focus_scan.library = Some(Arc::clone(&s.library_identity)); +} + +/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file; +/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together). +fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> { + let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> { + if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) { + return None; + } + let Source::File { path } = &ph.source else { return None }; + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone())) + }; + let mut out: Vec<(PhotoId, String)> = match ids { + Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(), + None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(), + }; + out.sort_by(|a, b| a.1.cmp(&b.1)); + out.dedup_by_key(|c| c.0); + out +} + +/// Start reading `list` on a worker thread. +fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> { + let cancel = Arc::new(AtomicBool::new(false)); + let pace = Arc::new(AtomicU8::new(pace as u8)); + let (tx, rx) = mpsc::channel(); + let paths: HashMap<PhotoId, String> = list.iter().cloned().collect(); + let total = list.len() as u64; + let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone()); + std::thread::Builder::new() + .name("focus-points".into()) + .spawn(move || { + for (id, path) in list { + loop { + if worker_cancel.load(Ordering::Relaxed) { + return; + } + if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 { + break; + } + std::thread::sleep(PAUSE_POLL); + } + if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 { + std::thread::sleep(LIGHT_REST); + } + // a decoder bug in one file is that file's problem, not the job's end + let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) { + Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read }, + Ok(Err(e)) => { + log::debug!("focus point: {e}"); + Outcome::Unavailable + } + Err(_) => Outcome::Unavailable, + }; + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if tx.send((outcome, name)).is_err() { + return; + } + } + }) + .map_err(|e| format!("could not start reading focus points: {e}"))?; + let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone())); + guard.progress(0, total); + let now = Instant::now(); + s.focus_scan.job = Some(Job { + identity: Arc::clone(&s.library_identity), + cancel, + pace, + rx, + guard, + paths, + pending: Vec::new(), + committed_at: now, + started: now, + summary: Summary { total, ..Default::default() }, + }); + Ok(()) +} + +/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none +/// (to the record). +fn commit(s: &mut Session) { + let Some(job) = s.focus_scan.job.as_mut() else { return }; + let pending = std::mem::take(&mut job.pending); + job.committed_at = Instant::now(); + if !Arc::ptr_eq(&job.identity, &s.library_identity) { + return; + } + let mut ops = Vec::new(); + let mut none = Vec::new(); + for (id, focus) in pending { + let Some(ph) = s.catalog.photo(id) else { continue }; + let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read); + // deleted, moved, relinked or given a point meanwhile: leave it + if !same_file || !ph.in_library() || ph.meta.focus.is_some() { + continue; + } + match focus { + Some(f) => { + let mut meta = ph.meta.clone(); + meta.focus = Some(f); + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + None => none.push(id.0), + } + } + for id in none { + s.focus_scan.record.insert(id); + } + if let Err(e) = s.focus_scan.record.save() { + log::warn!("could not save the list of photos read for focus points: {e}"); + } + if ops.is_empty() { + return; + } + match s.apply_system(Op::Batch { ops }) { + // the focus point is never written to XMP: no sidecar is rewritten for it + Ok(()) => s.skip_auto_write = true, + Err(e) => log::warn!("could not store focus points: {e}"), + } +} + +/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled. +/// Returns whether a job is still running. +fn step(s: &mut Session, pace: Pace) -> bool { + let Some(job) = s.focus_scan.job.as_mut() else { return false }; + job.pace.store(pace as u8, Ordering::Relaxed); + let mut finished = job.cancel.load(Ordering::Relaxed); + let mut last_name = None; + for _ in 0..MAX_DRAIN { + match job.rx.try_recv() { + Ok((outcome, name)) => { + job.summary.done += 1; + last_name = Some(name); + match outcome { + Outcome::Read { id, focus, bytes } => { + job.summary.bytes = job.summary.bytes.saturating_add(bytes); + if focus.is_some() { + job.summary.found += 1; + } else { + job.summary.none += 1; + } + job.pending.push((id, focus)); + } + Outcome::Unavailable => job.summary.unavailable += 1, + } + } + Err(mpsc::TryRecvError::Empty) => break, + // the worker ended (all read, cancelled, or it could not go on) + Err(mpsc::TryRecvError::Disconnected) => { + finished = true; + break; + } + } + } + finished |= job.summary.done >= job.summary.total; + job.guard.progress(job.summary.done, job.summary.total); + if let Some(name) = last_name { + job.guard.detail(&name); + } + if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) { + commit(s); + } + if finished && let Some(job) = s.focus_scan.job.take() { + let mut summary = job.summary.clone(); + summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total; + summary.seconds = job.started.elapsed().as_secs_f64(); + log::info!( + "focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s", + summary.done, + summary.total, + summary.found, + summary.none, + summary.unavailable, + summary.bytes / 1024, + summary.seconds + ); + s.focus_scan.last = Some(summary); + return false; + } + true +} + +fn status(s: &Session) -> Value { + let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found})); + json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()}) +} + +/// `focus.pump {pace?}`: cheap to call every frame. +pub(crate) fn pump(s: &mut Session, p: &Value) -> Result<Value> { + follow_library(s); + let pace = Pace::parse(p.get("pace").and_then(Value::as_str)); + s.focus_scan.pace = pace; + if !step(s, pace) && !s.focus_scan.auto_checked { + // once per library opening: start by itself when photos are left to look at (a few KB each) + s.focus_scan.auto_checked = true; + let list = candidates(s, None); + if !list.is_empty() + && let Err(e) = start(s, list, pace) + { + log::warn!("{e}"); + } + } + Ok(status(s)) +} + +/// `photo.readFocusPoints {ids?, wait?}`. +pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "photo.readFocusPoints"; + follow_library(s); + if s.focus_scan.job.is_some() { + return Ok(json!({"queued": 0, "running": true, "status": status(s)})); + } + let ids: Option<Vec<PhotoId>> = match p.get("ids") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_array() + .ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))? + .iter() + .map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))) + .collect::<Result<Vec<_>>>()?, + ), + }; + let list = candidates(s, ids.as_deref()); + let queued = list.len(); + if queued == 0 { + return Ok(json!({"queued": 0, "running": false})); + } + let wait = p.get("wait").and_then(Value::as_bool) == Some(true); + start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?; + // a run asked for counts as this library opening's automatic one + s.focus_scan.auto_checked = true; + if !wait { + return Ok(json!({"queued": queued, "running": true})); + } + // a script's call: read them all now (the batches still commit as they come) + while step(s, Pace::Normal) { + std::thread::sleep(Duration::from_millis(5)); + } + let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null); + Ok(json!({"queued": queued, "running": false, "last": last})) +} + +#[cfg(test)] +pub(crate) fn record_len(s: &Session) -> usize { + s.focus_scan.record.len() +} + +#[cfg(test)] +pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool { + s.focus_scan.record.contains(id.0) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9631b7d84..46d99073a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,8 @@ mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; +#[cfg(not(target_arch = "wasm32"))] +mod focus_scan; pub mod fonts; pub mod guard; pub mod import; @@ -209,6 +211,9 @@ pub struct Session { /// The loaded recognition model and the face embeddings made with it. #[cfg(not(target_arch = "wasm32"))] pub(crate) faces: faces_index::FacesState, + /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) focus_scan: focus_scan::FocusScan, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option<Value>, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -350,6 +355,8 @@ impl Session { face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + focus_scan: Default::default(), clipboard: None, meta_clipboard: None, browse: None, @@ -1002,6 +1009,8 @@ mod tests_face_models; mod tests_face_recognize; #[cfg(test)] mod tests_filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_focus_scan; #[cfg(test)] mod tests_folders; #[cfg(test)] diff --git a/crates/engine/src/tests_focus_scan.rs b/crates/engine/src/tests_focus_scan.rs new file mode 100644 index 000000000..eadfbdbc2 --- /dev/null +++ b/crates/engine/src/tests_focus_scan.rs @@ -0,0 +1,297 @@ +//! Reading focus points for photos already in the library (`photo.readFocusPoints`, `focus.pump`): header-only reads, +//! batches committed without undo steps, "looked, found nothing" remembered across sessions, and the activity row. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::focus_scan::{read_focus, record_contains, record_len}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-focus-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn sony_arw(mode: u8, samples: usize) -> Vec<u8> { + lightcraft_meta::focus::synthetic_arw(mode, samples) +} + +/// A photo of `camera` at `path` (an import made before focus points were read: none stored). +fn photo(id: u64, path: &Path, camera: &str) -> Photo { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into_owned() }, &name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = camera.into(); + p +} + +fn add(s: &mut Session, photos: Vec<Photo>) { + s.execute_fn("test.add", |s| { + for p in photos { + s.apply_system(Op::AddPhoto { photo: Box::new(p) })?; + } + Ok(Value::Null) + }) + .unwrap(); +} + +fn focus(s: &Session, id: u64) -> Option<lightcraft_meta::FocusPoint> { + s.catalog.photo(PhotoId(id)).unwrap().meta.focus.clone() +} + +fn pump_until_idle(s: &mut Session) -> Value { + let started = Instant::now(); + loop { + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + if v["active"] == false { + return v; + } + assert!(started.elapsed() < Duration::from_secs(30), "the job never ended: {v}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn the_header_read_reads_a_small_part_of_the_file() { + let dir = temp_dir("header"); + let path = dir.join("a.ARW"); + let file = sony_arw(3, 4_000_000); + std::fs::write(&path, &file).unwrap(); + let r = read_focus(&path.to_string_lossy()).unwrap(); + assert!(r.focus.is_some()); + assert_eq!(r.focus, lightcraft_meta::extract(&file).focus, "the same point as a whole-file read"); + assert!(r.bytes_read <= 32 * 1024 && r.bytes_read * 100 < file.len() as u64, "read {} of {}", r.bytes_read, file.len()); + assert!(read_focus(&dir.join("gone.ARW").to_string_lossy()).is_err()); +} + +/// Fills the missing points; leaves other cameras, other formats and known points alone without opening them; records +/// "looked, found nothing" (manual focus, garbage) but not a file it could not read; makes no undo step. +#[test] +fn fills_missing_points_and_remembers_photos_without_one() { + let dir = temp_dir("fill"); + let af = dir.join("af.ARW"); + std::fs::write(&af, sony_arw(3, 100_000)).unwrap(); + let manual = dir.join("manual.ARW"); + std::fs::write(&manual, sony_arw(0, 100_000)).unwrap(); + let garbage = dir.join("garbage.ARW"); + std::fs::write(&garbage, vec![0x5a; 70_000]).unwrap(); + let heif = dir.join("x.HIF"); + std::fs::write(&heif, sony_arw(3, 1000)).unwrap(); + let known = lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 }, + mode: Some("AF-S".into()), + area: None, + tracking: None, + }; + let mut with_point = photo(4, &af, "SONY ILCE-1"); + with_point.meta.focus = Some(known.clone()); + let mut s = Session::new(); + add( + &mut s, + vec![ + photo(1, &af, "SONY ILCE-1"), + photo(2, &manual, "SONY ILCE-1"), + // another body's photo is not opened, even when its file would have a point + photo(3, &af, "Canon EOS R5"), + with_point, + photo(5, &dir.join("offline.ARW"), "SONY ILCE-7SM3"), + photo(6, &garbage, "SONY ILCE-1"), + photo(7, &heif, "SONY ILCE-1"), + ], + ); + let undo = s.undo.len(); + let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap(); + assert_eq!(r["queued"], 4, "{r}"); + let last = &r["last"]; + assert_eq!( + (last["done"].as_u64(), last["found"].as_u64(), last["none"].as_u64(), last["unavailable"].as_u64()), + (Some(4), Some(1), Some(2), Some(1)), + "{r}" + ); + assert!(last["bytesRead"].as_u64().unwrap() <= 3 * 32 * 1024, "{r}"); + assert_eq!(focus(&s, 1), lightcraft_meta::extract(&std::fs::read(&af).unwrap()).focus); + assert!(focus(&s, 1).is_some()); + assert_eq!(focus(&s, 1).unwrap().words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert_eq!(focus(&s, 2), None); + assert_eq!(focus(&s, 3), None); + assert_eq!(focus(&s, 4), Some(known), "a stored point is never replaced"); + assert_eq!(focus(&s, 5), None); + assert_eq!(focus(&s, 7), None); + assert!(record_contains(&s, PhotoId(2)) && record_contains(&s, PhotoId(6))); + assert!(!record_contains(&s, PhotoId(5)), "an unreadable file is tried again later"); + assert_eq!(record_len(&s), 2); + assert_eq!(s.undo.len(), undo, "no undo step"); + // asked again: only the offline photo is left + assert_eq!(s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap()["queued"], 1); + // named photos are looked at again, even those recorded as having none + assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 3, 4], "wait": true})).unwrap()["queued"], 1); +} + +/// What was read survives a restart: the next session reads nothing again, and a photo whose file was offline is read +/// once it is back. The automatic start (once per library opening, from the pump) picks it up. +#[test] +fn resumes_after_a_restart_without_reading_again() { + let dir = temp_dir("resume"); + let lib = dir.join("library"); + let af = dir.join("af.ARW"); + std::fs::write(&af, sony_arw(3, 50_000)).unwrap(); + let manual = dir.join("manual.ARW"); + std::fs::write(&manual, sony_arw(0, 50_000)).unwrap(); + let later = dir.join("later.ARW"); + { + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + add(&mut s, vec![photo(1, &af, "SONY ILCE-1"), photo(2, &manual, "SONY ILCE-1"), photo(3, &later, "SONY ILCE-1")]); + // the pump starts it by itself + let first = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert_eq!(first["active"], true, "{first}"); + let v = pump_until_idle(&mut s); + assert_eq!(v["last"]["found"], 1, "{v}"); + assert!(lib.join(crate::focus_scan::RECORD_FILE).exists()); + } + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + assert!(focus(&s, 1).is_some(), "the point was saved with the library"); + // the file that was offline is back: only it is read + std::fs::write(&later, sony_arw(3, 50_000)).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert!(record_contains(&s, PhotoId(2)), "the record was read back"); + assert_eq!(v["running"]["total"], 1, "{v}"); + let v = pump_until_idle(&mut s); + assert_eq!((v["last"]["total"].as_u64(), v["last"]["found"].as_u64()), (Some(1), Some(1)), "{v}"); + assert!(focus(&s, 3).is_some()); + assert!(record_contains(&s, PhotoId(2))); + // and it doesn't start again in this opening + assert_eq!(s.execute("focus.pump", &json!({})).unwrap()["active"], false); +} + +/// The job has a row in the activity stack (n of N, cancellable); paused, it reads nothing new; cancelled (the row's ✕), +/// it commits what it read and the row goes away. +#[test] +fn the_activity_row_progresses_pauses_and_cancels() { + let dir = temp_dir("row"); + let mut photos = Vec::new(); + for i in 0..40u64 { + let p = dir.join(format!("{i:03}.ARW")); + std::fs::write(&p, sony_arw(3, 20_000)).unwrap(); + photos.push(photo(i + 1, &p, "SONY ILCE-7SM3")); + } + let mut s = Session::new(); + add(&mut s, photos); + // the pump starts the job by itself, at the pace it is given: paused, while the user drags + let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(v["active"], true); + let rows = s.activity.list(); + let row = rows.iter().find(|t| t.kind == "focus").expect("an activity row"); + assert_eq!((row.label.as_str(), row.total, row.cancellable), ("Reading focus points", 40, true)); + // already running: asking again starts nothing new + let r = s.execute("photo.readFocusPoints", &json!({})).unwrap(); + assert_eq!((r["queued"].as_u64(), r["running"].as_bool()), (Some(0), Some(true))); + std::thread::sleep(Duration::from_millis(300)); + let still = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["done"].as_u64().unwrap(); + assert_eq!(still, 0, "paused: nothing read"); + // a few more at a light pace, then cancel + let started = Instant::now(); + let mut done = still; + while done < still + 3 && started.elapsed() < Duration::from_secs(20) { + std::thread::sleep(Duration::from_millis(20)); + done = s.execute("focus.pump", &json!({"pace": "light"})).unwrap()["running"]["done"].as_u64().unwrap_or(40); + } + let row = s.activity.list().into_iter().find(|t| t.kind == "focus").expect("still running"); + assert!(row.done >= 3, "progress shows: {}", row.done); + s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + s.activity.cancel(row.id).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(v["active"], false, "{v}"); + assert_eq!(v["last"]["cancelled"], true, "{v}"); + assert!(s.activity.list().iter().all(|t| t.kind != "focus"), "the row is gone"); + let found = s.catalog.photos().filter(|p| p.meta.focus.is_some()).count() as u64; + assert_eq!(found, v["last"]["found"].as_u64().unwrap(), "what was read before the cancel is kept"); + assert!(found < 40); + // the rest are read next time + let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap(); + assert_eq!(r["queued"].as_u64().unwrap(), 40 - found); + assert_eq!(s.catalog.photos().filter(|p| p.meta.focus.is_some()).count(), 40); +} + +/// Photos deleted, moved or given a point while the job runs are left alone; a library closed mid-run takes the job +/// with it (nothing lands in the next library), and nothing panics. +#[test] +fn changes_under_a_running_job_are_respected() { + let dir = temp_dir("changes"); + let mut photos = Vec::new(); + for i in 0..6u64 { + let p = dir.join(format!("{i}.ARW")); + std::fs::write(&p, sony_arw(3, 10_000)).unwrap(); + photos.push(photo(i + 1, &p, "SONY ILCE-1")); + } + let mut s = Session::new(); + s.open_library(dir.join("lib-a"), false).unwrap(); + add(&mut s, photos); + // started by the pump, paused + assert_eq!(s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["total"], 6); + // meanwhile: photo 1 removed, photo 2 relinked elsewhere, photo 3 given a point by the user's Reload, photo 4's file + // replaced by garbage, photo 5's deleted + let mine = + lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.6 }, mode: None, area: None, tracking: None }; + s.execute_fn("test.change", |s| { + s.apply_system(Op::RemovePhoto { id: PhotoId(1) })?; + let moved = dir.join("moved.ARW"); + std::fs::copy(dir.join("1.ARW"), &moved).map_err(|e| crate::EngineError::Other(e.to_string()))?; + s.apply_system(Op::Relink { + id: PhotoId(2), + file_name: "moved.ARW".into(), + source: Source::File { path: moved.to_string_lossy().into() }, + format: None, + })?; + let mut meta = s.catalog.photo(PhotoId(3)).unwrap().meta.clone(); + meta.focus = Some(mine.clone()); + s.apply_system(Op::SetMeta { id: PhotoId(3), meta: Box::new(meta) })?; + Ok(Value::Null) + }) + .unwrap(); + std::fs::write(dir.join("3.ARW"), b"not a raw any more").unwrap(); + std::fs::remove_file(dir.join("4.ARW")).unwrap(); + let v = pump_until_idle(&mut s); + assert!(s.catalog.photo(PhotoId(1)).is_none()); + assert_eq!(focus(&s, 2), None, "read at the old path: left for another run"); + assert_eq!(focus(&s, 3), Some(mine)); + assert_eq!(focus(&s, 5), None); + assert!(focus(&s, 6).is_some()); + assert!(v["last"]["done"].as_u64().unwrap() <= 6, "{v}"); + // a library closed mid-run: the job goes with it + s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 4, 5]})).unwrap()["queued"], 3); + s.open_library(dir.join("lib-b"), false).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert_eq!(v["active"], false, "{v}"); + assert!(s.activity.list().iter().all(|t| t.kind != "focus")); + assert_eq!(s.catalog.photos().count(), 0); +} + +/// Opt-in measurement on real files: `LIGHTCRAFT_FOCUS_FILES=<a text file listing raw paths, one per line>` reads each +/// whole and by header, asserts they give the same focus point, and prints the bytes the header read took. Skipped +/// without it; no user file is part of the repository. +#[test] +fn real_files_header_read_matches_whole_file() { + let Ok(list) = std::env::var("LIGHTCRAFT_FOCUS_FILES") else { return }; + let paths: Vec<String> = std::fs::read_to_string(list).unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(); + let (mut n, mut with_point, mut bytes, mut max) = (0u64, 0u64, 0u64, 0u64); + for p in &paths { + let whole = lightcraft_meta::extract(&std::fs::read(p).unwrap()).focus; + let r = read_focus(p).unwrap(); + assert_eq!(r.focus, whole, "{p}"); + n += 1; + with_point += u64::from(whole.is_some()); + bytes += r.bytes_read; + max = max.max(r.bytes_read); + } + eprintln!( + "focus header reads: {n} files ({with_point} with a point) agree with whole-file reads; {} bytes each on average, {max} at most", + bytes / n.max(1) + ); +} diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs index 92801f8a7..e1c84f296 100644 --- a/crates/meta/src/focus.rs +++ b/crates/meta/src/focus.rs @@ -29,6 +29,7 @@ //! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or //! Flexible Spot frame is a real choice and is kept. +pub use crate::focus_read::{HEADER_PREFIX, HeaderRead, MAX_HEADER_BYTES, read_header}; use crate::{Orientation, Rect}; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; use serde::{Deserialize, Serialize}; @@ -80,6 +81,19 @@ impl FocusPoint { /// tags differently (or not mean the same by them): it shows no focus point until its files are checked. pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; +/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is +/// one whose files can carry a focus point, so a photo from any other camera is never opened to look. +pub fn camera_supported(camera: &str) -> bool { + let camera = camera.trim(); + camera.get(..4).is_some_and(|m| m.eq_ignore_ascii_case("SONY")) && camera.rsplit(' ').next().is_some_and(|model| SONY_MODELS.contains(&model)) +} + +/// Names what [`read`] decodes (its bodies), so a record of "looked, found nothing" made by this +/// decoder is not trusted by one that knows more bodies. +pub fn decoder_tag() -> String { + format!("sony-v1:{}", SONY_MODELS.join(",")) +} + const FOCUS_MODE: u16 = 0x201b; const AF_AREA_SETTING: u16 = 0x201c; const AF_TRACKING: u16 = 0x2021; @@ -160,6 +174,54 @@ pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Opt FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() } +/// A minimal little-endian TIFF laid out as an α1 ARW's metadata is (IFD0 with Make `SONY` and Model `ILCE-1` → +/// Exif IFD → a header-less maker note with the focus tags: AF-C or the given FocusMode byte, Wide, Face tracking, +/// Animal Eye Tracking at (3226, 1980) of 8640 × 5760), followed by `samples` bytes standing in for sensor data. For +/// tests of the readers and the backfill in other crates; not a real raw (it has no image). +#[doc(hidden)] +pub fn synthetic_arw(mode: u8, samples: usize) -> Vec<u8> { + fn entry(f: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: [u8; 4]) { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&typ.to_le_bytes()); + f.extend_from_slice(&count.to_le_bytes()); + f.extend_from_slice(&value); + } + let encipher = |p: u8| if p >= 249 { p } else { (u32::from(p).pow(3) % 249) as u8 }; + let mut f = b"II*\0".to_vec(); + f.extend_from_slice(&8u32.to_le_bytes()); + // IFD0 at 8: Make (at 62), Model (at 68), Orientation, the Exif IFD (at 76) + f.extend_from_slice(&4u16.to_le_bytes()); + entry(&mut f, t::MAKE, 2, 5, 62u32.to_le_bytes()); + entry(&mut f, t::MODEL, 2, 7, 68u32.to_le_bytes()); + entry(&mut f, t::ORIENTATION, 3, 1, [1, 0, 0, 0]); + entry(&mut f, t::EXIF_IFD, 4, 1, 76u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(b"SONY\0\0ILCE-1\0\0"); + // the Exif IFD: the maker note at 94, 156 bytes + f.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut f, t::MAKER_NOTE, 7, 156, 94u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + // the maker note's IFD, its values from 172 + f.extend_from_slice(&6u16.to_le_bytes()); + entry(&mut f, FOCUS_MODE, 1, 1, [mode, 0, 0, 0]); + entry(&mut f, AF_AREA_SETTING, 1, 1, [0, 0, 0, 0]); + entry(&mut f, AF_TRACKING, 1, 1, [1, 0, 0, 0]); + entry(&mut f, FOCUS_LOCATION, 3, 4, 172u32.to_le_bytes()); + entry(&mut f, FOCUS_FRAME_SIZE, 7, 6, 180u32.to_le_bytes()); + entry(&mut f, TAG_9402, 7, 64, 186u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + for v in [8640u16, 5760, 3226, 1980] { + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0xaf, 0, 0xb4, 0, 1, 1]); + let mut block = [0u8; 64]; + block[0x16] = encipher(0x80 | mode); + block[0x17] = encipher(20); + f.extend_from_slice(&block); + f.extend((0..samples).map(|i| (i % 251) as u8)); + f +} + /// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. fn decipher(b: u8) -> u8 { if b >= 249 { @@ -415,13 +477,20 @@ mod tests { /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec<u8> { + sony_file_with(make, model, orientation, note_entries, None) + } + + /// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF + /// (the gap filled with "sample data") when `far` is given. + fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option<usize>) -> Vec<u8> { let order = ByteOrder::Little; // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, // which we only know after writing; write once to learn where the note lands, then rebuild - let build = |note_at: u64| -> Vec<u8> { + let build = |note_at: u64, end: u64| -> (Vec<u8>, Vec<u8>) { let mut note = b"SONY DSC \0\0\0".to_vec(); let ifd_len = 2 + note_entries.len() * 12 + 4; let mut tail: Vec<u8> = Vec::new(); + let mut outside: Vec<u8> = Vec::new(); order.put_u16(&mut note, note_entries.len() as u16); for (tag, v) in note_entries { let raw = lightcraft_tiff::writer::encode(order, v); @@ -432,6 +501,9 @@ mod tests { let mut r = raw.clone(); r.resize(4, 0); note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; } else { order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); tail.extend(raw); @@ -449,12 +521,17 @@ mod tests { ifd0.set(t::MODEL, Value::Ascii(model.into())); ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); ifd0.set_child(t::EXIF_IFD, exif); - TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap() + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) }; - let first = build(0); + let (first, _) = build(0, 0); let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; - let file = build(at); + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } file } @@ -499,6 +576,119 @@ mod tests { } } + /// A whole-file read and a header-only read of the same file agree, and the header read reads a + /// small part of it (the sample data after the metadata is never touched). + #[test] + fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); + } + + /// Every truncation and corruption of a file gives the header read the whole-file read's answer + /// (or an error when the file is shorter than it said), never a panic. + #[test] + fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); + } + + #[test] + fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); + } + + #[test] + fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } + } + /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=<dir>` is the photo folder the paths below are relative /// to; skipped without it. No user file is part of the repository. diff --git a/crates/meta/src/focus_read.rs b/crates/meta/src/focus_read.rs new file mode 100644 index 000000000..5a5d349d1 --- /dev/null +++ b/crates/meta/src/focus_read.rs @@ -0,0 +1,465 @@ +//! Reading a file's focus point without reading the file: [`read_header`] asks for the few byte +//! ranges [`crate::focus::read`] looks at (a TIFF raw's IFD0, its Exif IFD, the maker-note IFD and +//! the values of the focus tags; a JPEG's Exif segment) and decodes them exactly as a whole-file +//! read would. On the α1 and the A7S III all of it sits in the first 16 KB of a 50–120 MB ARW, so a +//! library of them can be backfilled from a slow disk by reading one small block per photo. +//! +//! The ranges are found by walking the structure as the readers do (the IFD tables and the value +//! offsets they hold), round by round: each round asks only for what the bytes loaded so far point +//! at. A TIFF is decoded from a buffer of the file's length in which only the loaded ranges hold the +//! file's bytes (the rest stays zero and its pages are never touched), so every offset and bound the +//! reader checks is the one it checks on the whole file. Rounds, bytes and the buffer are bounded: +//! a file that wants more is reported as having no focus point. + +use std::io; + +use crate::focus::FocusPoint; +use lightcraft_tiff::{ByteOrder, FieldType, Tiff, tags as t}; + +/// Bytes read first: the α1's and A7S III's focus data ends before 16 KB, so one read covers it. +pub const HEADER_PREFIX: u64 = 32 * 1024; +/// Later reads are widened to this granularity (fewer, larger reads on a spinning disk). +const ALIGN: u64 = 4 * 1024; +/// The most rounds of reads one file gets (a real file needs one; the walk takes at most nine). +const MAX_ROUNDS: usize = 12; +/// The most bytes read from one file: past this the layout is not one the decoder knows. +pub const MAX_HEADER_BYTES: u64 = 2 << 20; +/// The largest TIFF given a buffer of its length (its untouched pages are never written, but the +/// allocation must succeed: the engine's planned preview reads use the same bound). Raws are far below it. +const MAX_SPARSE_FILE: u64 = 1 << 30; +/// JPEG segments walked before giving up. +const MAX_SEGMENTS: usize = 1024; +/// Entries read from one IFD (the TIFF reader's limit). +const MAX_ENTRIES: u64 = 8192; + +/// The focus tags whose values [`crate::focus`] reads from a Sony maker note. +const FOCUS_TAGS: [u16; 6] = [0x201b, 0x201c, 0x2021, 0x2027, 0x2037, 0x9402]; + +/// What [`read_header`] found, and what it cost. +#[derive(Clone, Debug, PartialEq)] +pub struct HeaderRead { + /// The focus point a whole-file read gives ([`crate::extract`]`(file).focus`). + pub focus: Option<FocusPoint>, + /// Bytes read from the file. + pub bytes_read: u64, +} + +/// The focus point of a file of `len` bytes, reading only what it needs through `read_at(offset, +/// buf)`, which fills `buf` with the file's bytes at `offset` (an error — a file that shrank or +/// went away — is returned). A malformed or unknown file is `Ok` with no point. +pub fn read_header(len: u64, read_at: &mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>) -> io::Result<HeaderRead> { + let mut reader = Reader { len, read_at, bytes: 0 }; + let head = reader.fetch(0, HEADER_PREFIX.min(len))?; + let focus = if head.starts_with(&[0xff, 0xd8]) { + jpeg(&mut reader, &head)? + } else if matches!(Tiff::sniff(&head), Some((_, false))) { + tiff(&mut reader, head)? + } else { + None + }; + Ok(HeaderRead { focus, bytes_read: reader.bytes }) +} + +struct Reader<'a> { + len: u64, + read_at: &'a mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>, + bytes: u64, +} + +impl Reader<'_> { + /// `[start, end)` of the file (clipped to it), counted against [`MAX_HEADER_BYTES`]. + fn fetch(&mut self, start: u64, end: u64) -> io::Result<Vec<u8>> { + let end = end.min(self.len); + let n = end.saturating_sub(start); + if self.bytes.saturating_add(n) > MAX_HEADER_BYTES { + return Err(io::Error::other(OverBudget)); + } + let mut buf = vec![0u8; usize::try_from(n).map_err(|_| io::Error::other("range too large"))?]; + if n > 0 { + (self.read_at)(start, &mut buf)?; + } + self.bytes = self.bytes.saturating_add(n); + Ok(buf) + } +} + +/// A file whose focus data would take more than [`MAX_HEADER_BYTES`] to read. +#[derive(Debug)] +struct OverBudget; + +impl std::fmt::Display for OverBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("focus data larger than a header") + } +} + +impl std::error::Error for OverBudget {} + +fn over_budget(e: &io::Error) -> bool { + e.get_ref().is_some_and(|i| i.is::<OverBudget>()) +} + +/// An over-budget read is "no focus point here", not a failure to report. +fn budget_is_none<T>(r: io::Result<Option<T>>) -> io::Result<Option<T>> { + match r { + Err(e) if over_budget(&e) => Ok(None), + r => r, + } +} + +/// A JPEG's first `Exif` APP1 segment, found as [`crate::jpeg_segments`] finds it. +fn jpeg(r: &mut Reader, head: &[u8]) -> io::Result<Option<FocusPoint>> { + budget_is_none(jpeg_inner(r, head)) +} + +fn jpeg_inner(r: &mut Reader, head: &[u8]) -> io::Result<Option<FocusPoint>> { + let len = r.len; + let at = |r: &mut Reader, start: u64, n: u64| -> io::Result<Vec<u8>> { + let local = usize::try_from(start).ok().zip(usize::try_from(start.saturating_add(n)).ok()).and_then(|(a, b)| head.get(a..b)); + match local { + Some(s) => Ok(s.to_vec()), + None => r.fetch(start, start.saturating_add(n)), + } + }; + let mut i = 2u64; + for _ in 0..MAX_SEGMENTS { + if i.saturating_add(4) > len { + break; + } + let h = at(r, i, 4)?; + let (Some(&ff), Some(&marker), Some(&a), Some(&b)) = (h.first(), h.get(1), h.get(2), h.get(3)) else { break }; + if ff != 0xff { + break; + } + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let seg = u64::from(u16::from_be_bytes([a, b])); + if seg < 2 { + break; + } + let end = i.saturating_add(2).saturating_add(seg).min(len); + if marker == 0xe1 { + let payload = at(r, i + 4, end.saturating_sub(i + 4))?; + if payload.starts_with(b"Exif\0") { + return Ok(crate::read_exif(payload.get(6..).unwrap_or(&[])).focus); + } + } + i = end; + } + Ok(None) +} + +/// A TIFF raw, decoded from a buffer of its length holding only the ranges [`plan`] asked for. +fn tiff(r: &mut Reader, head: Vec<u8>) -> io::Result<Option<FocusPoint>> { + if r.len > MAX_SPARSE_FILE { + return Ok(None); + } + let Ok(len) = usize::try_from(r.len) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let Some(dst) = buf.get_mut(..head.len()) else { return Ok(None) }; + dst.copy_from_slice(&head); + let mut loaded = Loaded::default(); + loaded.insert(0, head.len() as u64); + for _ in 0..MAX_ROUNDS { + match plan(&buf, &loaded) { + Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)), + Step::Nothing => return Ok(None), + Step::Need(ranges) => { + for (a, b) in ranges { + // widen to the read granularity, but never re-read what is loaded + let (wa, wb) = ((a / ALIGN) * ALIGN, b.div_ceil(ALIGN).saturating_mul(ALIGN).min(r.len)); + for (s, e) in loaded.gaps(wa, wb) { + let bytes = match r.fetch(s, e) { + Err(e) if over_budget(&e) => return Ok(None), + other => other?, + }; + let (Ok(su), Ok(eu)) = (usize::try_from(s), usize::try_from(e)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(su..eu) else { return Ok(None) }; + dst.copy_from_slice(&bytes); + loaded.insert(s, e); + } + } + } + } + } + Ok(None) +} + +/// Sorted, disjoint, non-touching byte ranges. +#[derive(Default)] +struct Loaded(Vec<(u64, u64)>); + +impl Loaded { + fn insert(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + let (mut s, mut e) = (start, end); + let mut out = Vec::with_capacity(self.0.len() + 1); + for &(a, b) in &self.0 { + if b < s || a > e { + out.push((a, b)); + } else { + s = s.min(a); + e = e.max(b); + } + } + out.push((s, e)); + out.sort_unstable(); + self.0 = out; + } + + fn contains(&self, start: u64, end: u64) -> bool { + start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) + } + + /// The parts of `[start, end)` not loaded. + fn gaps(&self, start: u64, end: u64) -> Vec<(u64, u64)> { + let mut out = Vec::new(); + let mut at = start; + for &(a, b) in &self.0 { + if b <= at || a >= end { + continue; + } + if a > at { + out.push((at, a.min(end))); + } + at = at.max(b); + if at >= end { + break; + } + } + if at < end { + out.push((at, end)); + } + out + } +} + +enum Step { + /// Load these and ask again. + Need(Vec<(u64, u64)>), + /// Everything the decoder reads is loaded. + Ready, + /// The file has nothing the decoder could read (no Exif, no maker note, a structure it rejects). + Nothing, +} + +/// One IFD entry as the TIFF reader sees it: where its value is (absolute), if it fits the file. +struct RawEntry { + tag: u16, + typ: FieldType, + start: u64, + end: u64, +} + +struct Want<'a> { + buf: &'a [u8], + order: ByteOrder, + loaded: &'a Loaded, + need: Vec<(u64, u64)>, +} + +impl Want<'_> { + fn len(&self) -> u64 { + self.buf.len() as u64 + } + + /// Whether `[start, end)` (clipped to the file) is loaded; asks for it if not. + fn want(&mut self, start: u64, end: u64) -> bool { + let end = end.min(self.len()); + if self.loaded.contains(start, end) { + return true; + } + self.need.push((start, end)); + false + } + + /// The entries of the IFD at `off` (values relative to 0), once its table is loaded; `Some(empty)` + /// for one the reader would reject. + fn table(&mut self, off: u64) -> Option<Vec<RawEntry>> { + let len = self.len(); + if off == 0 || off >= len { + return Some(Vec::new()); + } + if !self.want(off, off.saturating_add(2)) { + return None; + } + let pos = usize::try_from(off).ok()?; + let n = u64::from(self.order.read_u16(self.buf, pos)?); + let table = off + 2; + let n_read = n.min(len.saturating_sub(table) / 12).min(MAX_ENTRIES); + let end = table + n_read * 12 + if n_read == n { 4 } else { 0 }; + if !self.want(off, end) { + return None; + } + let mut out = Vec::new(); + for i in 0..n_read { + let Ok(p) = usize::try_from(table + i * 12) else { continue }; + let (Some(tag), Some(typ), Some(count)) = ( + self.order.read_u16(self.buf, p), + self.order.read_u16(self.buf, p + 2).and_then(FieldType::from_u16), + self.order.read_u32(self.buf, p + 4), + ) else { + continue; + }; + let Some(total) = u64::from(count).checked_mul(typ.size() as u64) else { continue }; + let field = p as u64 + 8; + let start = if total <= 4 { + field + } else { + match self.order.read_u32(self.buf, p + 8) { + Some(v) => u64::from(v), + None => continue, + } + }; + let Some(end) = start.checked_add(total).filter(|&e| e <= len) else { continue }; + out.push(RawEntry { tag, typ, start, end }); + } + Some(out) + } + + /// The first unsigned value of an entry (loaded). + fn first_value(&self, e: &RawEntry) -> Option<u64> { + let pos = usize::try_from(e.start).ok()?; + match e.typ.size() { + 1 => self.buf.get(pos).map(|&b| u64::from(b)), + 2 => self.order.read_u16(self.buf, pos).map(u64::from), + 4 => self.order.read_u32(self.buf, pos).map(u64::from), + _ => self.order.read_u64(self.buf, pos), + } + } + + fn step(self) -> Step { + if self.need.is_empty() { Step::Ready } else { Step::Need(self.need) } + } +} + +/// What of a classic TIFF the focus decoder still needs, given what is `loaded`: IFD0 (Make, Model, +/// the Exif pointer), the Exif IFD (the maker-note entry), the maker note's header and IFD as +/// `lightcraft_tiff::makernote` locates it, and the focus tags' values. +fn plan(buf: &[u8], loaded: &Loaded) -> Step { + let Some((order, false)) = Tiff::sniff(buf) else { return Step::Nothing }; + let mut w = Want { buf, order, loaded, need: Vec::new() }; + let Some(ifd0) = order.read_u32(buf, 4).map(u64::from) else { return Step::Nothing }; + let Some(e0) = w.table(ifd0) else { return w.step() }; + let mut exif = None; + for e in &e0 { + if matches!(e.tag, t::MAKE | t::MODEL | t::UNIQUE_CAMERA_MODEL | t::ORIENTATION) { + w.want(e.start, e.end); + } + if e.tag == t::EXIF_IFD && exif.is_none() && w.want(e.start, e.end) { + exif = w.first_value(e); + } + } + if !w.need.is_empty() { + return w.step(); + } + let Some(exif) = exif.filter(|&o| o != 0) else { return Step::Nothing }; + let Some(ee) = w.table(exif) else { return w.step() }; + let Some(note) = ee.iter().find(|e| e.tag == t::MAKER_NOTE) else { return Step::Nothing }; + let head_end = note.start.saturating_add(12).min(note.end); + if !w.want(note.start, head_end) { + return w.step(); + } + let head = usize::try_from(note.start).ok().zip(usize::try_from(head_end).ok()).and_then(|(a, b)| buf.get(a..b)).unwrap_or(&[]); + let sony = head.starts_with(b"SONY") || head.starts_with(b"VHAB \0"); + // another maker's header: `focus::read` only decodes Sony and header-less notes + let foreign: [&[u8]; 13] = [ + b"Nikon\0", + b"FUJIFILM", + b"GENERALE", + b"OLYMPUS\0", + b"OM SYSTEM\0", + b"OLYMP\0", + b"EPSON\0", + b"MINOL\0", + b"CAMER\0", + b"SANYO\0", + b"Panasonic\0", + b"AOC\0", + b"PENTAX \0", + ]; + if !sony && foreign.iter().any(|p| head.starts_with(p)) { + return Step::Nothing; + } + let mut entries = None; + if sony { + match w.table(note.start.saturating_add(12)) { + None => return w.step(), + Some(e) if !e.is_empty() => entries = Some(e), + Some(_) => {} + } + } + let entries = match entries { + Some(e) => e, + None => match w.table(note.start) { + Some(e) => e, + None => return w.step(), + }, + }; + for e in entries.iter().filter(|e| FOCUS_TAGS.contains(&e.tag)) { + w.want(e.start, e.end); + } + w.step() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader over `file` that counts what it is asked for. + fn read(file: &[u8]) -> (io::Result<HeaderRead>, u64) { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let start = usize::try_from(off).map_err(io::Error::other)?; + let src = file.get(start..start + buf.len()).ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + buf.copy_from_slice(src); + Ok(()) + }); + (r, asked) + } + + #[test] + fn loaded_ranges() { + let mut l = Loaded::default(); + l.insert(10, 20); + l.insert(30, 40); + assert_eq!(l.gaps(0, 50), vec![(0, 10), (20, 30), (40, 50)]); + l.insert(20, 30); + assert_eq!(l.0, vec![(10, 40)]); + assert!(l.contains(12, 38) && !l.contains(5, 12)); + assert!(l.gaps(15, 35).is_empty()); + } + + #[test] + fn garbage_and_empty_files_are_no_point() { + for file in [vec![], vec![0xff, 0xd8], b"II*\0\x08\0\0\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), vec![0x42; 100_000]] { + let (r, _) = read(&file); + assert_eq!(r.unwrap().focus, None); + } + // a JPEG of nothing but fill bytes and tiny segments stops at its bounds + let mut j = vec![0xff, 0xd8]; + for _ in 0..5000 { + j.extend_from_slice(&[0xff, 0xe2, 0, 2]); + } + assert_eq!(read(&j).0.unwrap().focus, None); + } + + #[test] + fn a_file_that_shrinks_mid_read_is_an_error() { + // the reader is told the file is longer than it is (replaced between the stat and the read) + let r = read_header(1 << 20, &mut |_, _| Err(io::Error::from(io::ErrorKind::UnexpectedEof))); + assert!(r.is_err()); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index abf6fd868..ad8ffbafd 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -16,6 +16,7 @@ pub mod cr3; mod datetime; mod exif; pub mod focus; +mod focus_read; mod gpx; mod iptc; pub mod tags; diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index db5349830..0256c33f3 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2136,5 +2136,7 @@ "Tracking": "Tracking", "Face Tracking": "Gesichtsverfolgung", "Animal Eye Tracking": "Tieraugen-Tracking", - "Human Eye Tracking": "Augen-Tracking (Mensch)" + "Human Eye Tracking": "Augen-Tracking (Mensch)", + "Read Focus Points": "Fokuspunkte lesen", + "Reading focus points": "Fokuspunkte werden gelesen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index e94a5b377..5167800df 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2013,5 +2013,7 @@ "Tracking": "Seguimiento", "Face Tracking": "Seguimiento de caras", "Animal Eye Tracking": "Seguimiento de ojos de animales", - "Human Eye Tracking": "Seguimiento de ojos humanos" + "Human Eye Tracking": "Seguimiento de ojos humanos", + "Read Focus Points": "Leer puntos de enfoque", + "Reading focus points": "Leyendo puntos de enfoque" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 89b1d309b..0660fe67f 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1975,5 +1975,7 @@ "Tracking": "Suivi", "Face Tracking": "Suivi des visages", "Animal Eye Tracking": "Suivi des yeux d’animaux", - "Human Eye Tracking": "Suivi des yeux humains" + "Human Eye Tracking": "Suivi des yeux humains", + "Read Focus Points": "Lire les points de mise au point", + "Reading focus points": "Lecture des points de mise au point" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index bec052af7..10314b8a9 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1979,5 +1979,7 @@ "Tracking": "トラッキング", "Face Tracking": "顔トラッキング", "Animal Eye Tracking": "動物瞳トラッキング", - "Human Eye Tracking": "人物瞳トラッキング" + "Human Eye Tracking": "人物瞳トラッキング", + "Read Focus Points": "フォーカスポイントを読み込む", + "Reading focus points": "フォーカスポイントを読み込み中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e64080fa3..e92b5d0be 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2016,5 +2016,7 @@ "Tracking": "Rastreamento", "Face Tracking": "Rastreamento de rosto", "Animal Eye Tracking": "Rastreamento de olhos de animais", - "Human Eye Tracking": "Rastreamento de olhos humanos" + "Human Eye Tracking": "Rastreamento de olhos humanos", + "Read Focus Points": "Ler pontos de foco", + "Reading focus points": "Lendo pontos de foco" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 7e1de22df..92753fd94 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2169,5 +2169,7 @@ "Tracking": "Слежение", "Face Tracking": "Отслеживание лиц", "Animal Eye Tracking": "Слежение за глазами животных", - "Human Eye Tracking": "Слежение за глазами людей" + "Human Eye Tracking": "Слежение за глазами людей", + "Read Focus Points": "Прочитать точки фокусировки", + "Reading focus points": "Чтение точек фокусировки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 5f884dfb2..7e07ec82c 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2169,5 +2169,7 @@ "Tracking": "Стеження", "Face Tracking": "Відстеження облич", "Animal Eye Tracking": "Стеження за очима тварин", - "Human Eye Tracking": "Стеження за очима людей" + "Human Eye Tracking": "Стеження за очима людей", + "Read Focus Points": "Прочитати точки фокусування", + "Reading focus points": "Читання точок фокусування" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b5e340a5c..6652d249e 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2015,5 +2015,7 @@ "Tracking": "追踪", "Face Tracking": "人脸追踪", "Animal Eye Tracking": "动物眼部追踪", - "Human Eye Tracking": "人眼追踪" + "Human Eye Tracking": "人眼追踪", + "Read Focus Points": "读取对焦点", + "Reading focus points": "正在读取对焦点" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index af33e1f94..103164ad8 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1977,5 +1977,7 @@ "Tracking": "追蹤", "Face Tracking": "臉部追蹤", "Animal Eye Tracking": "動物眼部追蹤", - "Human Eye Tracking": "人眼追蹤" + "Human Eye Tracking": "人眼追蹤", + "Read Focus Points": "讀取對焦點", + "Reading focus points": "正在讀取對焦點" } diff --git a/crates/ui-egui/src/focus_scan.rs b/crates/ui-egui/src/focus_scan.rs new file mode 100644 index 000000000..de6e292dc --- /dev/null +++ b/crates/ui-egui/src/focus_scan.rs @@ -0,0 +1,24 @@ +//! Keeps the engine's focus-point reader going (`focus.pump`): it reads the camera's focus point for photos imported +//! before it was read, a few KB of each file, at the pace the face scan uses (nothing new while the user drags, types or +//! scrolls). Its progress is its row in the activity stack. + +use serde_json::json; + +use crate::LightcraftApp; + +/// How often a running reader is asked for what it has read (its batches commit from here). +const BUSY_SECS: f64 = 0.25; + +/// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`]. +pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if now < app.caches.focus_next_pump { + return; + } + let pace = crate::panels::faces::background_pace(app, ctx, now); + let Ok(v) = app.session.execute("focus.pump", &json!({"pace": pace})) else { return }; + if v["active"] == true { + app.caches.focus_next_pump = now + BUSY_SECS; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BUSY_SECS)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bfd72a9c6..090f29137 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod credits; pub mod date_picker; pub mod docking; pub mod export_task; +mod focus_scan; pub mod headless; pub mod i18n; pub mod icons; @@ -1079,6 +1080,7 @@ impl LightcraftApp { panels::denoise::pump(self, ctx); model_setup::pump(self, ctx); panels::faces::pump(self, ctx); + focus_scan::pump(self, ctx); self.collect_screenshots(ctx); self.issue_screenshots(ctx); if self.fonts_ready { @@ -1512,6 +1514,8 @@ pub struct Caches { pub faces_indexed: u64, pub faces_pending: u64, pub faces_next_pump: f64, + /// When to ask the focus-point reader again (`focus_scan::pump`). + pub focus_next_pump: f64, /// When the user last dragged, typed or scrolled, and when they last moved the pointer (egui time). pub last_input: f64, /// The pace the face scan was last given, and whether the window was in front then (for `ui.inspect`). diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 43703ed9c..b753d1ef2 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -204,6 +204,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.saveMetadataToFile", "photo.readMetadataFromFile", "photo.reload", + "photo.readFocusPoints", "app.showInFinder", "dialog.rename", "dialog.captureTime", diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..902dabc06 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -27,6 +27,13 @@ fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static st (pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized) } +/// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without +/// the "watching" speed-up. +pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str { + let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false) +} + /// The pace for a window that has the keyboard (or not) and is minimized (or not), `worked` seconds after the user last /// dragged, typed or scrolled and `moved` seconds after they last moved the pointer, while they are (or are not) looking /// at the scan's progress. diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs index fa9ae60a0..f93dd4f7b 100644 --- a/crates/ui-egui/src/tests_focus_point.rs +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -156,3 +156,34 @@ fn compare_and_survey_draw_each_photos_focus_point() { assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); } } + +/// A photo imported before focus points were read gets its point from the background reader the frame loop drives +/// (`focus.pump`, started by itself once the library is open), and Photo ▸ Read Focus Points runs it on demand. +#[test] +fn the_frame_loop_reads_focus_points_for_photos_imported_earlier() { + let dir = std::env::temp_dir().join(format!("lc-ui-focus-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let add = |app: &mut LightcraftApp, id: u64, name: &str| { + let path = dir.join(name); + std::fs::write(&path, lightcraft_meta::focus::synthetic_arw(3, 200_000)).expect("write"); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into() }, name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = "SONY ILCE-1".into(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).expect("add"); + }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + add(&mut app, 9001, "old.ARW"); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let has_point = |h: &Headless, id: u64| h.app.session.catalog.photo(PhotoId(id)).is_some_and(|p| p.meta.focus.is_some()); + assert!(h.step_until(SETTLE, |h| has_point(h, 9001)), "read by itself"); + assert!(h.step_until(T, |h| h.app.session.activity.list().iter().all(|t| t.kind != "focus")), "its row goes when it is done"); + // a photo added later is read when asked (the menu item runs the command) + add(&mut h.app, 9002, "later.ARW"); + let spec = h.app.session.commands().into_iter().find(|c| c.id == "photo.readFocusPoints").expect("the command"); + assert_eq!(spec.menu, vec!["Photo"]); + let r = run(&mut h, "photo.readFocusPoints", json!({})); + assert_eq!(r["queued"], 1, "{r}"); + assert!(h.step_until(SETTLE, |h| has_point(h, 9002))); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 02336c97c..5a09a9a48 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -33,7 +33,7 @@ guard.detail(&file_name); dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, `Clone + Send`). - **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, - `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + `download`, `faces`, `focus`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog in `crates/ui-egui/locales/`. - **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` @@ -59,6 +59,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 185ad2678..a5ea5ad4a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,6 +107,9 @@ The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none). `view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}` (on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set). +`photo.readFocusPoints {ids?, wait?}` reads the point for photos imported before it was read, from a few KB of each +file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls +`focus.pump {pace}` every frame to drive it. The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: diff --git a/docs/focus-points.md b/docs/focus-points.md index c6993948f..61c562b03 100644 --- a/docs/focus-points.md +++ b/docs/focus-points.md @@ -21,11 +21,46 @@ zooms to it, so checking whether the subject is sharp takes one key while cullin Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it zoomed to one. -The point is read when a photo is imported. Photos imported earlier get it with **Photo ▸ Reload from Disk** -(`photo.reload`), which fills camera data the catalog lacks; it never replaces a point already stored. It is +The point is read when a photo is imported. Photos imported earlier get it in the background (below). It is read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read unchanged (no catalog format change). +## Photos imported earlier + +A library imported before focus points were read gets them by itself: when a library opens and some of its photos +from a supported body have no point and haven't been looked at, LightCraft reads them in the background, with a +**Reading focus points** row in the activity stack (n of N; ✕ stops it). **Photo ▸ Read Focus Points** +(`photo.readFocusPoints`) starts the same job by hand. Photo ▸ Reload from Disk still fills a missing point too, but it +re-reads and hashes whole files and runs while you wait, so it is for a few photos, not a library. + +- **What is opened**: only photos whose stored camera is a supported body (`SONY ILCE-1`, `SONY ILCE-7SM3`), whose + file is an ARW or JPEG, that are in the library and have no point yet. Nothing else is opened. +- **What is read**: the few kilobytes that hold the focus data, not the file + (`lightcraft_meta::focus::read_header`). It walks the file's structure as the decoder does — the TIFF header and + IFD0 (Make, Model), the Exif IFD (the MakerNote entry), the maker note's IFD and the values of the focus tags; in a + JPEG, the segments up to the Exif one — asking only for the ranges the bytes read so far point at, and decodes them + with the same code as a whole-file read. On the α1 and the A7S III everything sits in the first 16 KB, so one 32 KB + read per photo does it (instead of 50–120 MB). Rounds (12), bytes (2 MB) and the file size are bounded; a file that + wants more is treated as having no point. On 109 real α1 / A7S III ARWs (83 with a point) the header read gave the + same point as the whole-file read for every file. +- **Pace**: one file at a time on one worker thread (the drive may be a spinning disk), in path order so neighbouring + files are read together. Nothing new is read while you drag, type or scroll, and it rests briefly between files + while you are around, so your own previews come first. Measured on a 5400 rpm USB drive, cold cache, 200 α1 / A7S III + ARWs: 1.9 s (about 100 photos a second, 32 KB each), so a library of 58,796 raws takes about ten minutes at full + pace instead of re-reading 2.25 TB. +- **Saving**: what was read is committed every 200 photos (or every 3 seconds) as LightCraft's own bookkeeping: no + undo step, no sidecar written, and only `meta.focus` changes, on a photo that still has none and still points at the + file that was read (a photo deleted, moved or relinked meanwhile is left for another run). Photos that turned out to + have no point (manual focus, a centre-only Wide report) are listed in `focus-scanned.bin` beside the library, so they + are not read again at every launch. The list is headed by the decoder's tag (the bodies it knows): when a body is + added, the list starts afresh and those photos are looked at again. A file that can't be read (an offline drive, a + file gone or replaced during the read) is not listed, and is tried again the next time the library opens. +- **Resuming**: quitting mid-run keeps what was committed; the next launch reads the rest. +- **Agents**: `photo.readFocusPoints {ids?, wait?}` — without `ids`, every photo not looked at yet; with `ids`, those + photos, looked at again even if listed. `wait: true` returns when done with `{last: {total, done, found, none, + unavailable, bytesRead, seconds, cancelled}}` (`lightcraft-cli run --library DIR photo.readFocusPoints wait=true`). + The app's frame loop calls `focus.pump {pace}`, which also starts the job once per library opening. + ## Cameras Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** @@ -75,5 +110,10 @@ DMF (AF, then manual touch-up) keeps its point. - `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests, including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=<photo folder>`). +- `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact, + truncated and corrupted files and count what it reads. +- `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and + `crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files + (`LIGHTCRAFT_FOCUS_FILES=<a list of paths>`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop. - `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. diff --git a/docs/mcp.md b/docs/mcp.md index d3d23e325..51fb7968a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -208,7 +208,7 @@ still reaches every command. |---|---| | `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | | `lightcraft://photos` | Photos in the current view (`catalog.query`) | -| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md)) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md); `run_command photo.readFocusPoints {wait: true}` reads it for photos imported before it was read, a few KB per file) | | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 45bdaac01..578ea28c5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | -| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `crates/meta/src/focus.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import (Reload from Disk fills older photos) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | @@ -790,7 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it (#1480) | +| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it; photos already in the library read in the background, header only (#1480) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7bf854d0e..c30248ee5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -9,8 +9,10 @@ - **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key (View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before. - Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame - centre show nothing. Photos imported earlier get their point with Photo ▸ Reload from Disk. More cameras will - follow. Details: [focus-points.md](focus-points.md). + centre show nothing. More cameras will follow. Details: [focus-points.md](focus-points.md). +- **Photos already in your library get theirs in the background**: LightCraft reads just the few kilobytes of each + file that hold the focus point (about 100 photos a second from a spinning disk), with a *Reading focus points* row + in the activity stack you can stop. Photo ▸ Read Focus Points runs it by hand. ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you From d64a1ecc93ea7cc78d87676cb7f55c3ad20894c5 Mon Sep 17 00:00:00 2001 From: PiMinusMax <121135695+PiMinusMax@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:33:23 +0200 Subject: [PATCH 114/315] LR-PROF-CAMERACOLOR: bundled Sony ILCA-99M2 camera profile Fitted with `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style, DRO Auto) and their in-camera JPEGs; aggregate colour statistics only. On 30 withheld photos (profile fitted to the other 120) mean dE76 against the camera JPEG goes 5.11 -> 4.87; 25 of 30 improve, the worst regression is 0.24. All 150 photos stay accepted. The public raw.pixls.us ILCA-99M2 file, not used for fitting, stays accepted (5.57 -> 5.73). A corpus test checks that this public sample's fit keeps exactly the built-in profile's matrix and table; the ILCE-7CR test now shares its body. LOOK_VERSION 5 -> 6, as the profile changes the fit of this model. --- README.md | 2 +- README.zh-hans.md | 2 +- ROADMAP.md | 3 + assets/ATTRIBUTION.md | 1 + assets/camera-profiles/ILCA-99M2.json | 1819 +++++++++++++++++++++++++ crates/engine/src/camera_preview.rs | 33 +- crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 8 +- docs/gaps.md | 4 +- docs/parity-checklist.md | 3 +- docs/raw-parity.md | 5 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- xtask/src/main.rs | 6 + 14 files changed, 1871 insertions(+), 20 deletions(-) create mode 100644 assets/camera-profiles/ILCA-99M2.json diff --git a/README.md b/README.md index 2aea14d87..fefade3f6 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 9a5331435..bffad8a75 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/ROADMAP.md b/ROADMAP.md index 3f02a1199..88ca45c72 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ / localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export (#646), Lightroom-like Auto (#628), activity stack. +- 2026-10-11: Sony ILCA-99M2 (α99 II) camera colour: bundled profile from 144 photos; on 30 withheld photos + mean ΔE vs the camera JPEG 5.11 → 4.87, 25 of them closer. - 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW, contact-sheet PDF export, Ukrainian and French UI, release v0.5.0. @@ -163,6 +165,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..628c27df5 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -12,6 +12,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | +| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none | | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | diff --git a/assets/camera-profiles/ILCA-99M2.json b/assets/camera-profiles/ILCA-99M2.json new file mode 100644 index 000000000..ff18a63c1 --- /dev/null +++ b/assets/camera-profiles/ILCA-99M2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "ILCA-99M2", + "files": 144, + "samples": 501511, + "matrix": [ + [1.1432775892083846, -0.27864150084892714, 0.13786480817829982], + [-0.05589204732762987, 1.1699605558717394, -0.11694884297799035], + [0.004313413220749851, -0.7088386949439993, 1.7263782534882033] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [2.804254, 1.2235245, 1.0], + [5.0115886, 1.2228547, 1.0], + [5.1745224, 1.1897815, 1.0], + [3.4398973, 1.0871476, 1.0], + [0.0, 1.0, 1.0], + [2.1009562, 1.231483, 1.0], + [3.2371128, 1.1828748, 1.0], + [3.6905267, 1.1272595, 1.0], + [3.2581418, 1.0763286, 1.0], + [0.0, 1.0, 1.0], + [1.3609977, 1.2428942, 1.0], + [1.1722605, 1.1633604, 1.0], + [1.2722441, 1.0881792, 1.0], + [0.99433255, 1.026622, 1.0], + [0.0, 1.0, 1.0], + [-0.12375347, 1.2171124, 1.0], + [-0.6004144, 1.1662252, 1.0], + [-1.2418907, 1.1001159, 1.0], + [-1.3929275, 1.017488, 1.0], + [0.0, 1.0, 1.0], + [-1.4741973, 1.1985689, 1.0], + [-2.3110185, 1.1587616, 1.0], + [-3.2606716, 1.1098144, 1.0], + [-3.9140587, 1.0290464, 1.0], + [0.0, 1.0, 1.0], + [-2.748514, 1.1680661, 1.0], + [-3.8273644, 1.1325561, 1.0], + [-4.7215014, 1.0942773, 1.0], + [-5.6006517, 1.0109093, 1.0], + [0.0, 1.0, 1.0], + [-3.6385696, 1.1548748, 1.0], + [-4.8762474, 1.106731, 1.0], + [-5.845225, 1.0598869, 1.0], + [-6.7829485, 0.9773247, 1.0], + [0.0, 1.0, 1.0], + [-4.4043136, 1.1618558, 1.0], + [-5.663378, 1.1124074, 1.0], + [-6.676991, 1.0622271, 1.0], + [-7.2388797, 0.9792954, 1.0], + [0.0, 1.0, 1.0], + [-4.5636735, 1.1730139, 1.0], + [-5.5787325, 1.131743, 1.0], + [-6.434246, 1.0818648, 1.0], + [-7.713659, 0.977632, 1.0], + [0.0, 1.0, 1.0], + [-4.1642222, 1.1961858, 1.0], + [-5.135349, 1.1508327, 1.0], + [-5.9561267, 1.1025041, 1.0], + [-7.8126693, 0.9789329, 1.0], + [0.0, 1.0, 1.0], + [-3.3540795, 1.2313552, 1.0], + [-4.0874486, 1.1859667, 1.0], + [-5.090505, 1.1065466, 1.0], + [-6.980868, 0.94789493, 1.0], + [0.0, 1.0, 1.0], + [-3.2277284, 1.2495877, 1.0], + [-3.6526108, 1.1937634, 1.0], + [-3.5733712, 1.0832378, 1.0], + [-5.550715, 0.9253031, 1.0], + [0.0, 1.0, 1.0], + [-3.2758887, 1.2372755, 1.0], + [-2.8500462, 1.1516474, 1.0], + [-1.8038367, 1.0081928, 1.0], + [-2.1356683, 0.93026036, 1.0], + [0.0, 1.0, 1.0], + [-2.842822, 1.1995343, 1.0], + [-1.5608833, 1.0880193, 1.0], + [-0.6414115, 0.96593964, 1.0], + [-0.8170321, 0.9045082, 1.0], + [0.0, 1.0, 1.0], + [-1.8589126, 1.1368302, 1.0], + [-0.5850774, 1.0399925, 1.0], + [-0.4096108, 0.94941473, 1.0], + [-0.67716926, 0.89125115, 1.0], + [0.0, 1.0, 1.0], + [-0.50222045, 1.0790738, 1.0], + [0.13412993, 1.0091311, 1.0], + [0.04161173, 0.94268256, 1.0], + [0.21809827, 0.89096487, 1.0], + [0.0, 1.0, 1.0], + [0.5395973, 1.0523564, 1.0], + [0.68198884, 0.9942523, 1.0], + [0.8921194, 0.93612367, 1.0], + [1.5682732, 0.8875503, 1.0], + [0.0, 1.0, 1.0], + [1.4230179, 1.0399433, 1.0], + [1.7522293, 0.98858124, 1.0], + [2.120758, 0.93895113, 1.0], + [2.7880068, 0.8917508, 1.0], + [0.0, 1.0, 1.0], + [2.485209, 1.0362785, 1.0], + [3.2553241, 0.9826427, 1.0], + [3.6980822, 0.9420671, 1.0], + [3.6589043, 0.9088622, 1.0], + [0.0, 1.0, 1.0], + [3.8520021, 1.0283107, 1.0], + [4.4029727, 0.97790647, 1.0], + [4.6192904, 0.941655, 1.0], + [3.3324108, 0.9242873, 1.0], + [0.0, 1.0, 1.0], + [4.579471, 1.0396363, 1.0], + [5.1236196, 0.99359185, 1.0], + [4.4844737, 0.9637591, 1.0], + [0.93364024, 0.9805732, 1.0], + [0.0, 1.0, 1.0], + [5.6343756, 1.0391579, 1.0], + [5.9764285, 0.9963135, 1.0], + [4.2140217, 0.97128063, 1.0], + [0.31541073, 0.99498296, 1.0], + [0.0, 1.0, 1.0], + [6.3622503, 1.0628132, 1.0], + [5.916998, 1.0203458, 1.0], + [1.3126858, 0.9978764, 1.0], + [-0.040236503, 0.99987936, 1.0], + [0.0, 1.0, 1.0], + [6.1092844, 1.1148107, 1.0], + [5.2341003, 1.0724294, 1.0], + [0.57583106, 1.0097842, 1.0], + [-0.004625028, 1.0000128, 1.0], + [0.0, 1.0, 1.0], + [5.6949396, 1.1773212, 1.0], + [3.8311253, 1.1038187, 1.0], + [-0.11847352, 1.0086719, 1.0], + [-0.0105433175, 1.000084, 1.0], + [0.0, 1.0, 1.0], + [4.546873, 1.1809658, 1.0], + [0.6606819, 1.0607728, 1.0], + [-1.8319762, 0.98954993, 1.0], + [-0.11198713, 0.9991004, 1.0], + [0.0, 1.0, 1.0], + [2.975616, 1.171921, 1.0], + [-2.6058009, 1.0177505, 1.0], + [-5.031273, 0.9486056, 1.0], + [-0.71531844, 0.99169016, 1.0], + [0.0, 1.0, 1.0], + [2.3296447, 1.1538781, 1.0], + [-4.7061315, 0.9983956, 1.0], + [-7.1538033, 0.9327583, 1.0], + [-1.329432, 0.9859865, 1.0], + [0.0, 1.0, 1.0], + [1.33782, 1.1317362, 1.0], + [-5.738668, 0.9846136, 1.0], + [-8.032541, 0.93113464, 1.0], + [-1.414409, 0.9868347, 1.0], + [0.0, 1.0, 1.0], + [1.1102281, 1.1464978, 1.0], + [-4.347359, 1.0098535, 1.0], + [-5.8042417, 0.9558565, 1.0], + [-0.65224177, 0.99453866, 1.0], + [0.0, 1.0, 1.0], + [1.3978106, 1.1282454, 1.0], + [-2.5084882, 1.0191262, 1.0], + [-2.5297656, 0.9832681, 1.0], + [-0.15988652, 0.99876547, 1.0], + [0.0, 1.0, 1.0], + [1.6285329, 1.1082805, 1.0], + [-1.8376639, 1.0320127, 1.0], + [-1.5057008, 0.99416333, 1.0], + [-0.07763386, 0.99956375, 1.0], + [0.0, 1.0, 1.0], + [0.47474164, 1.1118255, 1.0], + [-2.2814848, 1.0398426, 1.0], + [-0.96154535, 0.9967108, 1.0], + [-0.038478848, 0.99972326, 1.0], + [0.0, 1.0, 1.0], + [-0.28353742, 1.1260961, 1.0], + [-2.6729789, 1.0359904, 1.0], + [-0.76547706, 0.9970432, 1.0], + [-0.02355436, 0.99970925, 1.0], + [0.0, 1.0, 1.0], + [0.65706646, 1.1279969, 1.0], + [-2.8462512, 1.0391037, 1.0], + [-0.5868237, 1.0005401, 1.0], + [-0.010716562, 0.9999053, 1.0], + [0.0, 1.0, 1.0], + [0.12108033, 1.1502995, 1.0], + [-1.9704113, 1.0370626, 1.0], + [-0.63179135, 1.0001434, 1.0], + [-0.015800219, 0.99995714, 1.0], + [0.0, 1.0, 1.0], + [1.0309358, 1.1655595, 1.0], + [0.13250555, 1.0445937, 1.0], + [-0.07715364, 1.0009365, 1.0], + [-0.0021050358, 0.99999684, 1.0], + [0.0, 1.0, 1.0], + [-0.2827152, 1.1324676, 1.0], + [0.014774211, 1.0343506, 1.0], + [-0.019916466, 1.0006523, 1.0], + [-0.00040274212, 1.0000006, 1.0], + [0.0, 1.0, 1.0], + [-2.2018666, 1.1418259, 1.0], + [-1.4933577, 1.0408006, 1.0], + [-0.3667708, 1.0002662, 1.0], + [-0.007263095, 0.9999904, 1.0], + [0.0, 1.0, 1.0], + [-2.64397, 1.1194679, 1.0], + [-1.6423713, 1.0442007, 1.0], + [-0.20484583, 1.0007143, 1.0], + [-0.0025689714, 0.99998134, 1.0], + [0.0, 1.0, 1.0], + [-3.8773065, 1.110005, 1.0], + [-2.6916754, 1.0417831, 1.0], + [-0.33602908, 1.0002605, 1.0], + [-0.0021417958, 0.9999703, 1.0], + [0.0, 1.0, 1.0], + [-3.714574, 1.0977588, 1.0], + [-3.5595996, 1.0386114, 1.0], + [-1.5827396, 1.0060165, 1.0], + [-0.028114408, 1.0000618, 1.0], + [0.0, 1.0, 1.0], + [-3.2299962, 1.0955467, 1.0], + [-3.3268821, 1.0478108, 1.0], + [-1.9395999, 1.0131217, 1.0], + [-0.075512886, 1.0002799, 1.0], + [0.0, 1.0, 1.0], + [-1.336435, 1.0960432, 1.0], + [-0.8794556, 1.0795935, 1.0], + [-0.29321554, 1.0271318, 1.0], + [0.00032340753, 1.0008185, 1.0], + [0.0, 1.0, 1.0], + [0.09420732, 1.1007514, 1.0], + [0.45092306, 1.0983796, 1.0], + [1.0769465, 1.0420003, 1.0], + [0.22761619, 1.0022283, 1.0], + [0.0, 1.0, 1.0], + [0.71772265, 1.1304002, 1.0], + [1.5783058, 1.1197016, 1.0], + [2.8271558, 1.0629567, 1.0], + [1.0452774, 1.0075884, 1.0], + [0.0, 1.0, 1.0], + [1.7700194, 1.1402828, 1.0], + [2.267522, 1.1383678, 1.0], + [2.3750477, 1.0799156, 1.0], + [0.46060455, 0.99580747, 1.0], + [0.0, 1.0, 1.0], + [2.9440982, 1.1368023, 1.0], + [3.6898718, 1.1418967, 1.0], + [3.8923314, 1.1259586, 1.0], + [1.2024251, 1.0325387, 1.0], + [0.0, 1.0, 1.0], + [4.2370567, 1.1504492, 1.0], + [4.6764765, 1.1490715, 1.0], + [3.910422, 1.1321807, 1.0], + [0.89606607, 1.0308274, 1.0], + [0.0, 1.0, 1.0], + [4.9599085, 1.134546, 1.0], + [5.1401796, 1.1103181, 1.0], + [3.7372687, 1.0921954, 1.0], + [0.55078083, 1.021753, 1.0], + [0.0, 1.0, 1.0], + [5.7153788, 1.1273357, 1.0], + [5.978767, 1.1330243, 1.0], + [3.9994862, 1.1207391, 1.0], + [0.36665595, 1.0121281, 1.0], + [0.0, 1.0, 1.0], + [6.2697597, 1.1095688, 1.0], + [5.6365104, 1.1205149, 1.0], + [3.4179032, 1.1214768, 1.0], + [0.26656342, 1.0103098, 1.0], + [0.0, 1.0, 1.0], + [6.1428804, 1.0995386, 1.0], + [3.956236, 1.1270815, 1.0], + [1.9768763, 1.1226226, 1.0], + [0.11463216, 1.0099398, 1.0], + [0.0, 1.0, 1.0], + [6.929935, 1.1041147, 1.0], + [3.3182452, 1.1096137, 1.0], + [1.2072796, 1.0818946, 1.0], + [-0.01571625, 1.005663, 1.0], + [0.0, 1.0, 1.0], + [7.265804, 1.1154059, 1.0], + [3.7104347, 1.0477185, 1.0], + [1.056846, 0.9974326, 1.0], + [0.03417005, 0.9998331, 1.0], + [0.0, 1.0, 1.0], + [7.849703, 1.1269237, 1.0], + [4.3993697, 1.0325269, 1.0], + [1.3606595, 0.99489033, 1.0], + [0.047941446, 0.99993676, 1.0], + [0.0, 1.0, 1.0], + [8.439311, 1.1298556, 1.0], + [3.303848, 1.0538055, 1.0], + [0.59302783, 1.0085934, 1.0], + [0.025294233, 1.0004109, 1.0], + [0.0, 1.0, 1.0], + [8.608997, 1.1400498, 1.0], + [3.3591669, 1.0461109, 1.0], + [0.40004572, 1.0048742, 1.0], + [0.01262109, 1.0001965, 1.0], + [0.0, 1.0, 1.0], + [6.761172, 1.1502578, 1.0], + [2.1108382, 1.0369791, 1.0], + [0.11530293, 1.0012623, 1.0], + [0.001693561, 1.0000176, 1.0], + [0.0, 1.0, 1.0], + [5.5255957, 1.1716747, 1.0], + [1.0459789, 1.037805, 1.0], + [0.09180857, 1.001637, 1.0], + [0.003279748, 1.0000362, 1.0], + [0.0, 1.0, 1.0], + [5.1823792, 1.1748279, 1.0], + [1.723147, 1.0416057, 1.0], + [0.2180456, 1.0030037, 1.0], + [0.005272273, 1.0000591, 1.0], + [0.0, 1.0, 1.0], + [5.7087555, 1.1780843, 1.0], + [2.619079, 1.0446409, 1.0], + [0.35640785, 1.0032725, 1.0], + [0.010388461, 1.0001107, 1.0], + [0.0, 1.0, 1.0], + [6.8262243, 1.1712428, 1.0], + [3.972946, 1.0097475, 1.0], + [0.6286015, 0.9970032, 1.0], + [0.017907226, 1.0001063, 1.0], + [0.0, 1.0, 1.0], + [6.524165, 1.182861, 1.0], + [3.200896, 1.0579331, 1.0], + [0.28860277, 1.0027157, 1.0], + [0.0036479658, 1.0000447, 1.0], + [0.0, 1.0, 1.0], + [5.831539, 1.1791148, 1.0], + [2.774682, 1.0702703, 1.0], + [0.35563686, 1.0059416, 1.0], + [0.0069925115, 1.0001086, 1.0], + [0.0, 1.0, 1.0], + [7.6593747, 1.1839095, 1.0], + [2.9386632, 1.0513804, 1.0], + [0.23482445, 0.999349, 1.0], + [0.0063177217, 1.0000361, 1.0], + [0.0, 1.0, 1.0], + [7.268381, 1.1659219, 1.0], + [2.5044994, 1.0421095, 1.0], + [0.20415363, 0.99924254, 1.0], + [0.0059488467, 1.0000399, 1.0], + [0.0, 1.0, 1.0], + [7.236816, 1.1485709, 1.0], + [4.5540986, 1.06799, 1.0], + [1.4449328, 1.0170963, 1.0], + [0.0639702, 1.0006945, 1.0], + [0.0, 1.0, 1.0], + [6.453048, 1.1589189, 1.0], + [6.3102107, 1.1038384, 1.0], + [3.1707234, 1.032528, 1.0], + [0.19751447, 1.00155, 1.0], + [0.0, 1.0, 1.0], + [6.372379, 1.202752, 1.0], + [7.4304466, 1.1593263, 1.0], + [5.6761837, 1.0836742, 1.0], + [0.6670318, 1.0070612, 1.0], + [0.0, 1.0, 1.0], + [5.3508396, 1.1789645, 1.0], + [7.0611863, 1.1524564, 1.0], + [7.2240868, 1.1157393, 1.0], + [2.2516217, 1.0274429, 1.0], + [0.0, 1.0, 1.0], + [4.5282054, 1.1895584, 1.0], + [6.615412, 1.2008698, 1.0], + [7.1562805, 1.1773871, 1.0], + [3.693685, 1.0640011, 1.0], + [0.0, 1.0, 1.0], + [2.989149, 1.1666261, 1.0], + [5.0449367, 1.163257, 1.0], + [5.3947783, 1.1525968, 1.0], + [4.8617563, 1.1140602, 1.0], + [0.0, 1.0, 1.0], + [2.4345527, 1.1707076, 1.0], + [4.030044, 1.1360824, 1.0], + [4.400234, 1.1141994, 1.0], + [3.9981327, 1.0895209, 1.0], + [0.0, 1.0, 1.0], + [1.6249478, 1.1449721, 1.0], + [2.3648932, 1.0950501, 1.0], + [2.5787578, 1.0738068, 1.0], + [1.557968, 1.048907, 1.0], + [0.0, 1.0, 1.0], + [1.1732466, 1.1023636, 1.0], + [1.5519327, 1.0694385, 1.0], + [0.8835925, 1.0562818, 1.0], + [-0.95382243, 1.0234519, 1.0], + [0.0, 1.0, 1.0], + [0.54519135, 1.0728073, 1.0], + [0.7171778, 1.047036, 1.0], + [-0.4340524, 1.0440289, 1.0], + [-3.1891012, 1.0054655, 1.0], + [0.0, 1.0, 1.0], + [-0.81599987, 1.0807294, 1.0], + [-1.1246754, 1.0611808, 1.0], + [-2.2497585, 1.0415246, 1.0], + [-4.411367, 0.973935, 1.0], + [0.0, 1.0, 1.0], + [-1.7861705, 1.0760409, 1.0], + [-2.835779, 1.0522164, 1.0], + [-3.9953759, 1.0148282, 1.0], + [-4.4476047, 0.9319428, 1.0], + [0.0, 1.0, 1.0], + [-2.4466016, 1.0813212, 1.0], + [-3.5611944, 1.0451691, 1.0], + [-4.5130477, 1.011627, 1.0], + [-4.224515, 0.92788553, 1.0], + [0.0, 1.0, 1.0], + [-3.003385, 1.1208798, 1.0], + [-4.1261635, 1.0943259, 1.0], + [-5.189995, 1.0589299, 1.0], + [-6.5665016, 0.95204496, 1.0], + [0.0, 1.0, 1.0], + [-2.5737576, 1.1543715, 1.0], + [-3.583895, 1.1235094, 1.0], + [-4.758327, 1.0856714, 1.0], + [-6.4036903, 0.95784587, 1.0], + [0.0, 1.0, 1.0], + [-1.783091, 1.1985654, 1.0], + [-2.344906, 1.1536651, 1.0], + [-3.4222968, 1.0852684, 1.0], + [-5.1206603, 0.9148598, 1.0], + [0.0, 1.0, 1.0], + [-1.8261324, 1.2225533, 1.0], + [-2.3288019, 1.1683235, 1.0], + [-1.7856505, 1.0476049, 1.0], + [-4.6820045, 0.88435495, 1.0], + [0.0, 1.0, 1.0], + [-1.9707233, 1.2195381, 1.0], + [-0.81782085, 1.122641, 1.0], + [0.9111474, 0.9622587, 1.0], + [-1.1497722, 0.88565606, 1.0], + [0.0, 1.0, 1.0], + [-2.09956, 1.2140585, 1.0], + [0.056621242, 1.0911676, 1.0], + [1.0300432, 0.9330744, 1.0], + [-0.18233444, 0.8674556, 1.0], + [0.0, 1.0, 1.0], + [-0.7154876, 1.144233, 1.0], + [1.4851263, 1.0231369, 1.0], + [0.555909, 0.9251492, 1.0], + [-0.18147656, 0.8636336, 1.0], + [0.0, 1.0, 1.0], + [1.3398509, 1.0599889, 1.0], + [1.6690949, 0.9969751, 1.0], + [0.37584245, 0.92953223, 1.0], + [0.07826649, 0.87178516, 1.0], + [0.0, 1.0, 1.0], + [2.508692, 1.0410949, 1.0], + [1.8833909, 0.99179685, 1.0], + [1.2507737, 0.9335844, 1.0], + [1.6161711, 0.8835863, 1.0], + [0.0, 1.0, 1.0], + [3.1758716, 1.0253911, 1.0], + [2.4910228, 0.9809797, 1.0], + [2.236299, 0.9303089, 1.0], + [2.6537087, 0.8825234, 1.0], + [0.0, 1.0, 1.0], + [3.6103837, 1.0229514, 1.0], + [3.1707804, 0.97286725, 1.0], + [3.3409152, 0.9399243, 1.0], + [3.5829213, 0.9052561, 1.0], + [0.0, 1.0, 1.0], + [4.3082023, 1.0349113, 1.0], + [4.284679, 0.9839242, 1.0], + [4.3494177, 0.9484854, 1.0], + [3.8492424, 0.9255855, 1.0], + [0.0, 1.0, 1.0], + [4.793538, 1.0573108, 1.0], + [5.6492386, 0.98787755, 1.0], + [6.214179, 0.91949385, 1.0], + [3.9530585, 0.92670876, 1.0], + [0.0, 1.0, 1.0], + [5.384946, 1.070319, 1.0], + [6.3971586, 0.98128706, 1.0], + [6.9482284, 0.89684695, 1.0], + [4.2139463, 0.9239156, 1.0], + [0.0, 1.0, 1.0], + [5.509941, 1.109065, 1.0], + [4.31208, 1.0552181, 1.0], + [-1.2347931, 0.9985642, 1.0], + [-0.86304265, 0.9993037, 1.0], + [0.0, 1.0, 1.0], + [5.1984997, 1.162435, 1.0], + [3.29675, 1.1169462, 1.0], + [-2.0880382, 1.0190989, 1.0], + [-0.5265214, 0.99782705, 1.0], + [0.0, 1.0, 1.0], + [4.2004547, 1.2332976, 1.0], + [2.4406395, 1.1688915, 1.0], + [-0.54659694, 1.020866, 1.0], + [-0.039971404, 0.99981636, 1.0], + [0.0, 1.0, 1.0], + [2.9126167, 1.2407047, 1.0], + [-0.152128, 1.1377726, 1.0], + [-2.4389071, 0.9923629, 1.0], + [-0.18998405, 0.99835, 1.0], + [0.0, 1.0, 1.0], + [0.832136, 1.2248999, 1.0], + [-4.439477, 1.0757953, 1.0], + [-6.5581284, 0.94083136, 1.0], + [-1.3619823, 0.98418033, 1.0], + [0.0, 1.0, 1.0], + [-0.8086253, 1.2119304, 1.0], + [-6.9719944, 1.0509604, 1.0], + [-8.899021, 0.9207292, 1.0], + [-2.5299528, 0.97328454, 1.0], + [0.0, 1.0, 1.0], + [-1.2520319, 1.1828909, 1.0], + [-7.6938405, 1.0309292, 1.0], + [-9.733912, 0.9186155, 1.0], + [-2.4881692, 0.97676903, 1.0], + [0.0, 1.0, 1.0], + [-0.6646547, 1.1841325, 1.0], + [-6.6125364, 1.0378011, 1.0], + [-7.26035, 0.942406, 1.0], + [-1.0572171, 0.99080205, 1.0], + [0.0, 1.0, 1.0], + [0.598152, 1.1774639, 1.0], + [-4.97536, 1.0513587, 1.0], + [-3.3090515, 0.9811577, 1.0], + [-0.20941074, 0.9982669, 1.0], + [0.0, 1.0, 1.0], + [0.010846901, 1.1766325, 1.0], + [-5.117737, 1.0593848, 1.0], + [-2.631352, 0.99419326, 1.0], + [-0.11850837, 0.9993512, 1.0], + [0.0, 1.0, 1.0], + [-1.5248753, 1.1829028, 1.0], + [-6.0299883, 1.0577121, 1.0], + [-2.215714, 0.99519426, 1.0], + [-0.067606665, 0.9995176, 1.0], + [0.0, 1.0, 1.0], + [-1.1450118, 1.1907917, 1.0], + [-5.886881, 1.0472964, 1.0], + [-2.5883603, 0.98812264, 1.0], + [-0.085475355, 0.9989431, 1.0], + [0.0, 1.0, 1.0], + [0.39547902, 1.2007595, 1.0], + [-4.610833, 1.0562892, 1.0], + [-1.7052215, 0.99749553, 1.0], + [-0.037703, 0.9996055, 1.0], + [0.0, 1.0, 1.0], + [-0.01975878, 1.2410866, 1.0], + [-1.2528956, 1.080659, 1.0], + [-0.68467104, 1.0020955, 1.0], + [-0.019976415, 0.99990904, 1.0], + [0.0, 1.0, 1.0], + [-0.2921033, 1.2522528, 1.0], + [0.5268386, 1.0997523, 1.0], + [0.03676964, 1.0044748, 1.0], + [-0.0015876454, 1.0000012, 1.0], + [0.0, 1.0, 1.0], + [-2.6588416, 1.1782904, 1.0], + [-0.8304472, 1.0511743, 1.0], + [-0.09593859, 1.0008658, 1.0], + [-0.0012349889, 0.9999909, 1.0], + [0.0, 1.0, 1.0], + [-5.5003, 1.1401259, 1.0], + [-2.4005525, 1.002213, 1.0], + [-0.7121907, 0.98226595, 1.0], + [-0.017461486, 0.999597, 1.0], + [0.0, 1.0, 1.0], + [-5.994106, 1.0949768, 1.0], + [-2.85081, 1.0083107, 1.0], + [-0.30721804, 0.9763625, 1.0], + [-0.0013189864, 0.9991703, 1.0], + [0.0, 1.0, 1.0], + [-5.196782, 1.1068348, 1.0], + [-3.2161317, 1.0495526, 1.0], + [-1.099006, 0.99147415, 1.0], + [-0.024919514, 0.9993193, 1.0], + [0.0, 1.0, 1.0], + [-4.84915, 1.05568, 1.0], + [-3.9007847, 1.03011, 1.0], + [-2.599225, 1.0099269, 1.0], + [-0.11461259, 0.99996513, 1.0], + [0.0, 1.0, 1.0], + [-4.721844, 1.055208, 1.0], + [-3.9854348, 1.0393229, 1.0], + [-2.5744975, 1.0209645, 1.0], + [-0.293057, 1.001174, 1.0], + [0.0, 1.0, 1.0], + [-4.059898, 1.0536826, 1.0], + [-2.5768554, 1.0555562, 1.0], + [-1.39879, 1.0136101, 1.0], + [-0.50610787, 0.9911003, 1.0], + [0.0, 1.0, 1.0], + [-2.9069445, 1.0556097, 1.0], + [-1.5781095, 1.0518695, 1.0], + [-0.8278498, 1.001524, 1.0], + [-0.32421526, 0.98277986, 1.0], + [0.0, 1.0, 1.0], + [-1.4363952, 1.0812076, 1.0], + [0.5487326, 1.0899016, 1.0], + [2.4110415, 1.0404066, 1.0], + [2.9115984, 1.0175672, 1.0], + [0.0, 1.0, 1.0], + [0.008678005, 1.1017222, 1.0], + [1.2356163, 1.1221837, 1.0], + [2.8750088, 1.0839373, 1.0], + [3.1348855, 1.032868, 1.0], + [0.0, 1.0, 1.0], + [0.88217217, 1.091444, 1.0], + [2.0927439, 1.1262348, 1.0], + [3.240285, 1.1331868, 1.0], + [2.67113, 1.0834583, 1.0], + [0.0, 1.0, 1.0], + [2.428248, 1.095952, 1.0], + [3.390544, 1.1449028, 1.0], + [3.5510561, 1.1565588, 1.0], + [2.31717, 1.0987086, 1.0], + [0.0, 1.0, 1.0], + [4.1580663, 1.0814137, 1.0], + [4.4028983, 1.1175768, 1.0], + [3.174666, 1.1427947, 1.0], + [2.0205822, 1.1122999, 1.0], + [0.0, 1.0, 1.0], + [6.2129626, 1.0714386, 1.0], + [6.590155, 1.0948668, 1.0], + [4.619345, 1.1303223, 1.0], + [1.1349491, 1.0543766, 1.0], + [0.0, 1.0, 1.0], + [6.7700667, 1.0417291, 1.0], + [6.674038, 1.0694815, 1.0], + [3.9405415, 1.1325624, 1.0], + [0.40215716, 1.0195748, 1.0], + [0.0, 1.0, 1.0], + [7.373421, 1.0177879, 1.0], + [5.4952292, 1.081058, 1.0], + [2.1721451, 1.1446264, 1.0], + [0.14383824, 1.0215358, 1.0], + [0.0, 1.0, 1.0], + [8.597993, 1.0280006, 1.0], + [4.9888263, 1.084475, 1.0], + [1.9800822, 1.1122874, 1.0], + [-0.23990147, 1.0166304, 1.0], + [0.0, 1.0, 1.0], + [9.2620325, 1.0467869, 1.0], + [6.4645605, 1.0722194, 1.0], + [2.8058643, 1.069191, 1.0], + [0.13071911, 1.005048, 1.0], + [0.0, 1.0, 1.0], + [9.7541, 1.079291, 1.0], + [7.8130198, 1.0582371, 1.0], + [3.0989928, 1.0383492, 1.0], + [0.14017454, 1.0023274, 1.0], + [0.0, 1.0, 1.0], + [9.04703, 1.1110121, 1.0], + [6.10701, 1.0926124, 1.0], + [2.378649, 1.0378062, 1.0], + [0.111506626, 1.001809, 1.0], + [0.0, 1.0, 1.0], + [9.323562, 1.1182009, 1.0], + [7.539196, 1.0773741, 1.0], + [2.3685758, 1.0214494, 1.0], + [0.07044682, 1.0008357, 1.0], + [0.0, 1.0, 1.0], + [8.691661, 1.143956, 1.0], + [7.088831, 1.0745311, 1.0], + [1.8496133, 1.0144777, 1.0], + [0.05676793, 1.0005342, 1.0], + [0.0, 1.0, 1.0], + [7.4111695, 1.2071966, 1.0], + [4.6049232, 1.1096628, 1.0], + [1.9098912, 1.0257343, 1.0], + [0.07917395, 1.0008571, 1.0], + [0.0, 1.0, 1.0], + [7.0088325, 1.2360815, 1.0], + [5.181472, 1.1226107, 1.0], + [2.5577261, 1.0353444, 1.0], + [0.112243205, 1.0013376, 1.0], + [0.0, 1.0, 1.0], + [8.824198, 1.2390603, 1.0], + [7.472616, 1.1490262, 1.0], + [3.6435897, 1.0531034, 1.0], + [0.21466114, 1.00276, 1.0], + [0.0, 1.0, 1.0], + [9.237296, 1.2194712, 1.0], + [7.4227743, 1.131169, 1.0], + [3.8712175, 1.049929, 1.0], + [0.27166268, 1.0031942, 1.0], + [0.0, 1.0, 1.0], + [8.786514, 1.1667371, 1.0], + [6.6639266, 1.1076087, 1.0], + [2.2446363, 1.0388349, 1.0], + [0.102016255, 1.0017875, 1.0], + [0.0, 1.0, 1.0], + [7.9329085, 1.1376165, 1.0], + [6.6409307, 1.1026622, 1.0], + [3.2540584, 1.0458134, 1.0], + [0.23406105, 1.0032705, 1.0], + [0.0, 1.0, 1.0], + [9.150625, 1.1396258, 1.0], + [6.7835217, 1.1053689, 1.0], + [3.3122694, 1.0424184, 1.0], + [0.22282204, 1.0027751, 1.0], + [0.0, 1.0, 1.0], + [9.179195, 1.1203728, 1.0], + [6.1122427, 1.0689247, 1.0], + [2.574998, 1.0149101, 1.0], + [0.17865337, 1.001089, 1.0], + [0.0, 1.0, 1.0], + [8.981582, 1.1198573, 1.0], + [6.162663, 1.0758042, 1.0], + [3.49672, 1.0282632, 1.0], + [0.5296274, 1.0024091, 1.0], + [0.0, 1.0, 1.0], + [7.104293, 1.1063842, 1.0], + [6.60601, 1.0895218, 1.0], + [6.026096, 1.0566804, 1.0], + [1.4251214, 1.0060695, 1.0], + [0.0, 1.0, 1.0], + [5.400258, 1.118055, 1.0], + [8.1627245, 1.1168847, 1.0], + [8.838523, 1.1060567, 1.0], + [3.2699623, 1.0280458, 1.0], + [0.0, 1.0, 1.0], + [4.0916386, 1.0858877, 1.0], + [7.020515, 1.0801034, 1.0], + [8.994645, 1.1054409, 1.0], + [6.3794594, 1.070257, 1.0], + [0.0, 1.0, 1.0], + [2.8969727, 1.0963963, 1.0], + [5.3678546, 1.111281, 1.0], + [7.492073, 1.145503, 1.0], + [6.806583, 1.107631, 1.0], + [0.0, 1.0, 1.0], + [11.097742, 0.7495534, 1.0], + [4.302149, 1.0255402, 1.0], + [4.3949976, 1.078089, 1.0], + [3.0446813, 1.0549035, 1.0], + [0.0, 1.0, 1.0], + [8.670632, 0.7986941, 1.0], + [4.084819, 1.0083629, 1.0], + [4.0668464, 1.0512977, 1.0], + [3.5106099, 1.0471594, 1.0], + [0.0, 1.0, 1.0], + [6.4860106, 0.8172128, 1.0], + [3.818937, 0.93780375, 1.0], + [3.2241101, 1.0080932, 1.0], + [2.3703701, 1.0238551, 1.0], + [0.0, 1.0, 1.0], + [4.833141, 0.8377232, 1.0], + [4.498794, 0.8643455, 1.0], + [3.6683908, 0.9057964, 1.0], + [1.7416582, 0.96007955, 1.0], + [0.0, 1.0, 1.0], + [4.6399913, 0.80761904, 1.0], + [4.839282, 0.8098061, 1.0], + [4.2472334, 0.8426727, 1.0], + [2.3996296, 0.87981254, 1.0], + [0.0, 1.0, 1.0], + [4.0833488, 0.78954774, 1.0], + [4.703831, 0.781375, 1.0], + [4.1265993, 0.8191039, 1.0], + [3.244208, 0.82794446, 1.0], + [0.0, 1.0, 1.0], + [2.2156487, 0.8334128, 1.0], + [2.5546548, 0.82133436, 1.0], + [2.4130013, 0.82352406, 1.0], + [2.1992364, 0.81915104, 1.0], + [0.0, 1.0, 1.0], + [1.719304, 0.8250702, 1.0], + [1.4982271, 0.8221971, 1.0], + [1.3897603, 0.840134, 1.0], + [1.7225292, 0.8336163, 1.0], + [0.0, 1.0, 1.0], + [1.0925283, 0.85481983, 1.0], + [0.7631879, 0.85975116, 1.0], + [0.027124116, 0.87992114, 1.0], + [0.60234094, 0.84217334, 1.0], + [0.0, 1.0, 1.0], + [1.077557, 0.872631, 1.0], + [1.0173641, 0.8593242, 1.0], + [0.46943918, 0.87838364, 1.0], + [2.6384852, 0.79771537, 1.0], + [0.0, 1.0, 1.0], + [0.7770302, 0.9353661, 1.0], + [1.0596069, 0.8860215, 1.0], + [1.2889328, 0.8582148, 1.0], + [4.9600816, 0.74249977, 1.0], + [0.0, 1.0, 1.0], + [0.34926006, 0.98829997, 1.0], + [0.18424937, 0.9600296, 1.0], + [1.1720486, 0.8844167, 1.0], + [2.579216, 0.7862993, 1.0], + [0.0, 1.0, 1.0], + [0.30250847, 0.9603346, 1.0], + [1.107201, 0.928601, 1.0], + [1.9961823, 0.8556583, 1.0], + [-0.3471245, 0.82866865, 1.0], + [0.0, 1.0, 1.0], + [-0.7629411, 1.0180708, 1.0], + [0.610268, 0.974038, 1.0], + [1.429175, 0.8520721, 1.0], + [0.48019677, 0.8095613, 1.0], + [0.0, 1.0, 1.0], + [-0.3008022, 0.9407405, 1.0], + [1.6219236, 0.9161009, 1.0], + [0.49270624, 0.8484004, 1.0], + [-0.12723985, 0.791222, 1.0], + [0.0, 1.0, 1.0], + [0.25840467, 0.85001826, 1.0], + [1.2374158, 0.87433594, 1.0], + [-0.47591653, 0.8426007, 1.0], + [-0.9091751, 0.79707766, 1.0], + [0.0, 1.0, 1.0], + [1.1872596, 0.8374762, 1.0], + [1.1351508, 0.8393883, 1.0], + [-0.09448363, 0.82199603, 1.0], + [-0.22021097, 0.80633336, 1.0], + [0.0, 1.0, 1.0], + [0.99161613, 0.82922834, 1.0], + [0.5485594, 0.83473396, 1.0], + [0.39220774, 0.826323, 1.0], + [0.58646864, 0.81625366, 1.0], + [0.0, 1.0, 1.0], + [0.25968486, 0.823715, 1.0], + [-0.11242166, 0.8438889, 1.0], + [0.41922796, 0.8506193, 1.0], + [1.028925, 0.8475653, 1.0], + [0.0, 1.0, 1.0], + [1.0410757, 0.82169753, 1.0], + [1.1827178, 0.83744586, 1.0], + [1.5406011, 0.8407932, 1.0], + [1.5877901, 0.88981646, 1.0], + [0.0, 1.0, 1.0], + [1.7911158, 0.83056414, 1.0], + [4.6160154, 0.8464025, 1.0], + [5.6431475, 0.8491971, 1.0], + [2.9428084, 0.91466653, 1.0], + [0.0, 1.0, 1.0], + [2.4177535, 0.8561214, 1.0], + [6.020544, 0.8638199, 1.0], + [6.9461846, 0.8605439, 1.0], + [3.500521, 0.92217606, 1.0], + [0.0, 1.0, 1.0], + [2.851688, 0.89191425, 1.0], + [1.421044, 0.9390341, 1.0], + [-1.1085083, 0.9700407, 1.0], + [-0.36786354, 0.9936673, 1.0], + [0.0, 1.0, 1.0], + [2.3683667, 0.9257322, 1.0], + [-0.26451036, 0.95317423, 1.0], + [-2.130682, 0.9368306, 1.0], + [-0.4093729, 0.986178, 1.0], + [0.0, 1.0, 1.0], + [1.5891942, 0.99163103, 1.0], + [0.045109738, 0.966062, 1.0], + [-0.53449297, 0.92987776, 1.0], + [-0.033470135, 0.9932046, 1.0], + [0.0, 1.0, 1.0], + [-0.27134877, 0.99536645, 1.0], + [0.0898188, 0.98402214, 1.0], + [0.27262244, 0.96843773, 1.0], + [0.009789813, 0.9986736, 1.0], + [0.0, 1.0, 1.0], + [-0.7730278, 1.0203122, 1.0], + [-2.9028432, 1.0235066, 1.0], + [-1.0447694, 0.9868708, 1.0], + [-0.04624136, 0.99908286, 1.0], + [0.0, 1.0, 1.0], + [-1.3631408, 1.0156548, 1.0], + [-3.9609292, 1.0409077, 1.0], + [-1.4876856, 0.9928084, 1.0], + [-0.095956564, 0.998979, 1.0], + [0.0, 1.0, 1.0], + [-1.7949396, 0.9771055, 1.0], + [-3.3644142, 1.0089597, 1.0], + [-0.6663962, 0.98575324, 1.0], + [-0.060724735, 0.9990037, 1.0], + [0.0, 1.0, 1.0], + [-2.6404126, 0.9683573, 1.0], + [-3.7188525, 0.9884891, 1.0], + [-0.33517227, 0.9891578, 1.0], + [-0.01776004, 0.99959093, 1.0], + [0.0, 1.0, 1.0], + [-1.5392375, 0.9850047, 1.0], + [-1.9149097, 0.9972289, 1.0], + [0.076658234, 0.99591476, 1.0], + [0.0015469929, 0.99989164, 1.0], + [0.0, 1.0, 1.0], + [-1.5000389, 0.9821583, 1.0], + [-2.8312242, 0.9990609, 1.0], + [-0.28693938, 0.9993521, 1.0], + [-0.003116089, 0.9999806, 1.0], + [0.0, 1.0, 1.0], + [-2.261437, 0.9956475, 1.0], + [-4.1936493, 0.99516, 1.0], + [-0.5251263, 0.99886537, 1.0], + [-0.0044811945, 0.99998087, 1.0], + [0.0, 1.0, 1.0], + [-2.1491625, 1.0218415, 1.0], + [-4.5739403, 1.0102247, 1.0], + [-0.68651015, 0.99985665, 1.0], + [-0.0075566974, 0.99995035, 1.0], + [0.0, 1.0, 1.0], + [-1.2349107, 0.9898649, 1.0], + [-1.9306059, 1.0036258, 1.0], + [-0.31590265, 0.99970144, 1.0], + [-0.0033171063, 0.9999775, 1.0], + [0.0, 1.0, 1.0], + [-1.0307009, 0.99630994, 1.0], + [0.30616513, 1.0119231, 1.0], + [0.06396701, 1.0005364, 1.0], + [0.00027660295, 0.9999968, 1.0], + [0.0, 1.0, 1.0], + [-2.346574, 0.9880379, 1.0], + [-0.8791935, 1.0093088, 1.0], + [-0.016242307, 1.0002874, 1.0], + [0.00025250288, 0.99999845, 1.0], + [0.0, 1.0, 1.0], + [-3.0779517, 0.97170013, 1.0], + [-1.4663464, 0.99664164, 1.0], + [-0.10389895, 0.9996869, 1.0], + [-0.00044551404, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-4.567538, 0.9528182, 1.0], + [-1.9175193, 0.96272826, 1.0], + [-0.24028678, 0.988517, 1.0], + [-0.0031621323, 0.9997933, 1.0], + [0.0, 1.0, 1.0], + [-5.301078, 0.9462786, 1.0], + [-2.0612185, 0.9623441, 1.0], + [-0.22937998, 0.98045844, 1.0], + [-0.0035447287, 0.99954635, 1.0], + [0.0, 1.0, 1.0], + [-5.17369, 0.9347734, 1.0], + [-2.6256938, 0.9702652, 1.0], + [-0.9779252, 0.98185503, 1.0], + [-0.025275169, 0.999532, 1.0], + [0.0, 1.0, 1.0], + [-5.6190476, 0.87789106, 1.0], + [-4.1578727, 0.9216868, 1.0], + [-2.5072808, 0.9612179, 1.0], + [-0.089652225, 0.9986668, 1.0], + [0.0, 1.0, 1.0], + [-6.76994, 0.87418616, 1.0], + [-6.514886, 0.907811, 1.0], + [-4.9078856, 0.9322168, 1.0], + [-0.41067597, 0.99400187, 1.0], + [0.0, 1.0, 1.0], + [-6.1820183, 0.9030464, 1.0], + [-4.7838874, 0.9592428, 1.0], + [-3.901864, 0.9493777, 1.0], + [-2.8153791, 0.9527283, 1.0], + [0.0, 1.0, 1.0], + [-5.788914, 0.91208506, 1.0], + [-3.994215, 0.97003484, 1.0], + [-3.385323, 0.9577372, 1.0], + [-2.9413586, 0.9510517, 1.0], + [0.0, 1.0, 1.0], + [-5.824406, 0.83820003, 1.0], + [-3.1810389, 0.9660887, 1.0], + [-1.5774505, 0.9790124, 1.0], + [-0.8217753, 0.9740097, 1.0], + [0.0, 1.0, 1.0], + [-4.3065467, 0.80589384, 1.0], + [-2.6072268, 0.93019086, 1.0], + [-0.21090473, 0.9885001, 1.0], + [0.43375567, 0.9852358, 1.0], + [0.0, 1.0, 1.0], + [-2.482041, 0.80521226, 1.0], + [-1.9464252, 0.89081764, 1.0], + [-1.3067281, 0.97063005, 1.0], + [-0.08558672, 1.0085713, 1.0], + [0.0, 1.0, 1.0], + [0.005971689, 0.79016274, 1.0], + [0.35732928, 0.85779244, 1.0], + [0.1754206, 0.99188244, 1.0], + [0.22379632, 1.037684, 1.0], + [0.0, 1.0, 1.0], + [2.7745373, 0.8123556, 1.0], + [2.660167, 0.8928497, 1.0], + [1.9056137, 1.0349722, 1.0], + [1.3743136, 1.0741935, 1.0], + [0.0, 1.0, 1.0], + [5.84149, 0.94729656, 1.0], + [5.9234858, 0.99772394, 1.0], + [3.5638256, 1.0254989, 1.0], + [0.789219, 1.0284225, 1.0], + [0.0, 1.0, 1.0], + [6.216669, 0.94323486, 1.0], + [6.1197004, 0.97384584, 1.0], + [3.111261, 1.0015063, 1.0], + [0.13389735, 1.0010753, 1.0], + [0.0, 1.0, 1.0], + [6.8003964, 0.8915141, 1.0], + [6.054007, 0.933874, 1.0], + [2.107739, 1.0027483, 1.0], + [0.0493525, 1.0011154, 1.0], + [0.0, 1.0, 1.0], + [7.9880567, 0.84407914, 1.0], + [5.6231203, 0.93609124, 1.0], + [2.2157097, 1.0120378, 1.0], + [0.032817453, 1.0015986, 1.0], + [0.0, 1.0, 1.0], + [8.595032, 0.8414821, 1.0], + [5.738505, 0.9392406, 1.0], + [1.8269083, 1.0084609, 1.0], + [0.04897936, 1.0008367, 1.0], + [0.0, 1.0, 1.0], + [8.220856, 0.87541443, 1.0], + [5.0150123, 0.95354825, 1.0], + [1.1460975, 0.9991748, 1.0], + [0.023244264, 1.0001849, 1.0], + [0.0, 1.0, 1.0], + [6.352977, 0.90786576, 1.0], + [4.568452, 0.9657601, 1.0], + [1.6529517, 1.000971, 1.0], + [0.03857504, 1.0002371, 1.0], + [0.0, 1.0, 1.0], + [4.984532, 0.9082033, 1.0], + [4.602232, 0.9495377, 1.0], + [1.6734874, 0.9877272, 1.0], + [0.03744144, 0.9998395, 1.0], + [0.0, 1.0, 1.0], + [5.9091544, 0.93606, 1.0], + [5.298362, 0.9841254, 1.0], + [1.5838195, 1.0008063, 1.0], + [0.046534117, 1.0002071, 1.0], + [0.0, 1.0, 1.0], + [5.066354, 0.97590166, 1.0], + [4.5295296, 1.0173208, 1.0], + [1.7539304, 1.013229, 1.0], + [0.053133972, 1.0004649, 1.0], + [0.0, 1.0, 1.0], + [3.9509256, 0.96985763, 1.0], + [2.8036354, 1.0145437, 1.0], + [1.5774267, 1.01402, 1.0], + [0.066340216, 1.0006825, 1.0], + [0.0, 1.0, 1.0], + [5.067796, 0.9293189, 1.0], + [4.159626, 1.0254444, 1.0], + [2.7054405, 1.0338923, 1.0], + [0.16508698, 1.0020102, 1.0], + [0.0, 1.0, 1.0], + [6.1854787, 0.88944936, 1.0], + [4.127846, 0.9825014, 1.0], + [2.468719, 1.0214276, 1.0], + [0.17890404, 1.0017699, 1.0], + [0.0, 1.0, 1.0], + [5.5697684, 0.868272, 1.0], + [3.8859425, 0.95566565, 1.0], + [1.9449633, 1.0140244, 1.0], + [0.15235585, 1.0016147, 1.0], + [0.0, 1.0, 1.0], + [4.9004993, 0.8488444, 1.0], + [3.3493254, 0.9401458, 1.0], + [1.7450691, 1.0157793, 1.0], + [0.2219146, 1.0026209, 1.0], + [0.0, 1.0, 1.0], + [5.1511345, 0.8408119, 1.0], + [2.344393, 0.9062895, 1.0], + [-0.006539325, 0.989216, 1.0], + [-0.08388412, 0.9982721, 1.0], + [0.0, 1.0, 1.0], + [5.9436674, 0.83059716, 1.0], + [2.7960584, 0.8703427, 1.0], + [-0.36575812, 0.97218806, 1.0], + [-0.23437832, 0.9956032, 1.0], + [0.0, 1.0, 1.0], + [6.1871066, 0.7945113, 1.0], + [3.3608866, 0.8636077, 1.0], + [0.7976455, 0.97853297, 1.0], + [0.016725179, 0.99557835, 1.0], + [0.0, 1.0, 1.0], + [6.6724114, 0.8086163, 1.0], + [4.0700526, 0.9031084, 1.0], + [2.1568875, 0.99063873, 1.0], + [0.28391585, 0.99492174, 1.0], + [0.0, 1.0, 1.0], + [7.5037627, 0.8231193, 1.0], + [5.7297187, 0.9803322, 1.0], + [4.538334, 1.0247983, 1.0], + [0.78753865, 0.9985357, 1.0], + [0.0, 1.0, 1.0], + [10.182478, 0.7484477, 1.0], + [4.6068373, 0.93632764, 1.0], + [5.901852, 0.9994183, 1.0], + [1.9283233, 1.0053344, 1.0], + [0.0, 1.0, 1.0], + [11.154881, 0.70661646, 1.0], + [2.7159872, 0.93115985, 1.0], + [4.0488343, 1.0193834, 1.0], + [1.9382095, 1.0209278, 1.0], + [0.0, 1.0, 1.0], + [21.429089, 0.534035, 1.0], + [10.9904585, 0.6681652, 1.0], + [0.0045763263, 0.9355186, 1.0], + [0.13676341, 0.9799069, 1.0], + [0.0, 1.0, 1.0], + [19.529167, 0.5656457, 1.0], + [9.061795, 0.7158103, 1.0], + [1.5038702, 0.90302753, 1.0], + [0.78457785, 0.9086724, 1.0], + [0.0, 1.0, 1.0], + [15.508226, 0.5985953, 1.0], + [7.4871893, 0.7188201, 1.0], + [2.3094695, 0.89489657, 1.0], + [0.73899657, 0.9671641, 1.0], + [0.0, 1.0, 1.0], + [10.39154, 0.65087306, 1.0], + [6.622824, 0.6944927, 1.0], + [5.501718, 0.7407023, 1.0], + [1.7589878, 0.92959625, 1.0], + [0.0, 1.0, 1.0], + [8.076021, 0.62203556, 1.0], + [7.467209, 0.6181576, 1.0], + [7.245981, 0.6409904, 1.0], + [5.4263334, 0.7738036, 1.0], + [0.0, 1.0, 1.0], + [8.469799, 0.5848823, 1.0], + [8.423419, 0.5817134, 1.0], + [8.497577, 0.60529923, 1.0], + [9.111917, 0.69250333, 1.0], + [0.0, 1.0, 1.0], + [7.762021, 0.6242976, 1.0], + [7.8041244, 0.61129224, 1.0], + [8.319156, 0.64303446, 1.0], + [9.280437, 0.71576697, 1.0], + [0.0, 1.0, 1.0], + [5.9923735, 0.63731086, 1.0], + [4.8158092, 0.65566516, 1.0], + [6.311018, 0.6723989, 1.0], + [8.32342, 0.7326516, 1.0], + [0.0, 1.0, 1.0], + [6.2170925, 0.60791266, 1.0], + [5.0867386, 0.6423507, 1.0], + [6.415117, 0.66422945, 1.0], + [8.737183, 0.70679927, 1.0], + [0.0, 1.0, 1.0], + [5.2006907, 0.60027266, 1.0], + [4.8628798, 0.61094993, 1.0], + [6.024442, 0.6481432, 1.0], + [9.87192, 0.66732514, 1.0], + [0.0, 1.0, 1.0], + [4.6975617, 0.6112319, 1.0], + [4.514887, 0.6216314, 1.0], + [6.3849893, 0.63863033, 1.0], + [10.629338, 0.64065015, 1.0], + [0.0, 1.0, 1.0], + [4.5348377, 0.62149215, 1.0], + [3.3658662, 0.67045367, 1.0], + [5.1989746, 0.71183586, 1.0], + [8.826136, 0.6767556, 1.0], + [0.0, 1.0, 1.0], + [3.2873642, 0.6042733, 1.0], + [2.2218688, 0.65636855, 1.0], + [0.86957604, 0.82655025, 1.0], + [0.053957075, 0.88620996, 1.0], + [0.0, 1.0, 1.0], + [2.4001925, 0.6340274, 1.0], + [1.1141202, 0.70201296, 1.0], + [0.50959253, 0.8092494, 1.0], + [0.2311227, 0.8478709, 1.0], + [0.0, 1.0, 1.0], + [1.2452888, 0.66887766, 1.0], + [-0.07834225, 0.74380815, 1.0], + [-0.13215514, 0.7847734, 1.0], + [-0.38037032, 0.7592258, 1.0], + [0.0, 1.0, 1.0], + [-1.1262196, 0.6688091, 1.0], + [-1.8940061, 0.6305882, 1.0], + [-1.4030197, 0.7027344, 1.0], + [-1.2132488, 0.7527478, 1.0], + [0.0, 1.0, 1.0], + [-1.7841728, 0.6271489, 1.0], + [-2.0797975, 0.61307365, 1.0], + [-1.7369123, 0.6998982, 1.0], + [-1.4816345, 0.78196275, 1.0], + [0.0, 1.0, 1.0], + [-1.5889271, 0.6240376, 1.0], + [-1.8163804, 0.6369125, 1.0], + [-0.99799794, 0.7531041, 1.0], + [-0.5732026, 0.82648337, 1.0], + [0.0, 1.0, 1.0], + [-2.0947924, 0.62250054, 1.0], + [-2.1894937, 0.6211645, 1.0], + [-1.2518405, 0.701402, 1.0], + [-0.14743061, 0.8963739, 1.0], + [0.0, 1.0, 1.0], + [-1.3260301, 0.622353, 1.0], + [-1.4357653, 0.6204895, 1.0], + [-0.9074722, 0.7065318, 1.0], + [-0.00092270726, 0.94407517, 1.0], + [0.0, 1.0, 1.0], + [0.53253675, 0.609965, 1.0], + [0.7909907, 0.63592654, 1.0], + [0.8281013, 0.861355, 1.0], + [0.09484549, 0.99166083, 1.0], + [0.0, 1.0, 1.0], + [2.1939044, 0.6141987, 1.0], + [2.304398, 0.68116254, 1.0], + [1.0291927, 0.94084936, 1.0], + [0.07020744, 0.99777913, 1.0], + [0.0, 1.0, 1.0], + [2.544789, 0.6641931, 1.0], + [1.7962058, 0.7804687, 1.0], + [0.024910655, 0.9779748, 1.0], + [-0.006917878, 0.9988935, 1.0], + [0.0, 1.0, 1.0], + [2.512072, 0.6963922, 1.0], + [1.0768867, 0.8578466, 1.0], + [-0.15686627, 0.95935565, 1.0], + [-0.018332472, 0.9965962, 1.0], + [0.0, 1.0, 1.0], + [2.2941554, 0.7241867, 1.0], + [0.49197897, 0.8924251, 1.0], + [-0.07057831, 0.97556025, 1.0], + [0.0007015772, 0.9984997, 1.0], + [0.0, 1.0, 1.0], + [-0.14841639, 0.75324446, 1.0], + [0.3125728, 0.9184302, 1.0], + [0.12940219, 0.99002546, 1.0], + [0.004882609, 0.9996811, 1.0], + [0.0, 1.0, 1.0], + [1.0759145, 0.8147672, 1.0], + [0.36428088, 0.95350146, 1.0], + [0.044762604, 0.9945495, 1.0], + [0.0020008544, 0.99985397, 1.0], + [0.0, 1.0, 1.0], + [1.1844163, 0.82202315, 1.0], + [0.079763375, 0.9658192, 1.0], + [-0.040133536, 0.99962455, 1.0], + [-0.0008112378, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [1.3717666, 0.79799265, 1.0], + [0.041872248, 0.9601765, 1.0], + [-0.031498604, 0.9993519, 1.0], + [-0.00014435564, 0.99999434, 1.0], + [0.0, 1.0, 1.0], + [1.4442534, 0.8161425, 1.0], + [-0.063920654, 0.9636011, 1.0], + [-0.05635313, 0.9993309, 1.0], + [-0.00048342353, 0.99999565, 1.0], + [0.0, 1.0, 1.0], + [0.52015144, 0.8598735, 1.0], + [0.19060682, 0.97779083, 1.0], + [0.0042832624, 0.9997414, 1.0], + [4.660216e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.7497536, 0.84093064, 1.0], + [-0.81031346, 0.97599655, 1.0], + [-0.09859322, 0.99943244, 1.0], + [-0.0006943415, 0.9999969, 1.0], + [0.0, 1.0, 1.0], + [-1.3677365, 0.8722283, 1.0], + [-1.8130565, 0.97921914, 1.0], + [-0.21724299, 0.9991554, 1.0], + [-0.0015388737, 0.99999464, 1.0], + [0.0, 1.0, 1.0], + [-0.91486025, 0.8624227, 1.0], + [-1.8185471, 0.98354757, 1.0], + [-0.18334843, 1.00006, 1.0], + [-0.0010572135, 1.0000012, 1.0], + [0.0, 1.0, 1.0], + [-0.7346696, 0.8424903, 1.0], + [-0.63329214, 0.9792088, 1.0], + [-0.059044026, 0.9998297, 1.0], + [-0.00033109298, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-1.6148448, 0.8629954, 1.0], + [-0.1529285, 0.9853164, 1.0], + [-0.0012836644, 0.99992263, 1.0], + [2.836192e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-2.1700978, 0.8661239, 1.0], + [-0.63642955, 0.98434937, 1.0], + [-0.031005092, 0.99981445, 1.0], + [-0.000120288605, 0.99999934, 1.0], + [0.0, 1.0, 1.0], + [-2.4762814, 0.86192167, 1.0], + [-0.56582534, 0.98256135, 1.0], + [-0.020217514, 0.9998631, 1.0], + [-7.2207775e-05, 0.9999997, 1.0], + [0.0, 1.0, 1.0], + [-3.8507402, 0.82345366, 1.0], + [-0.7367012, 0.96947515, 1.0], + [-0.014731503, 0.9995469, 1.0], + [-6.203018e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-4.3435655, 0.7789662, 1.0], + [-1.2070022, 0.9441542, 1.0], + [-0.06403829, 0.99689096, 1.0], + [-0.0005338597, 0.999972, 1.0], + [0.0, 1.0, 1.0], + [-7.598504, 0.7135656, 1.0], + [-2.8674366, 0.8796157, 1.0], + [-0.2061764, 0.98925316, 1.0], + [-0.0019789634, 0.9999003, 1.0], + [0.0, 1.0, 1.0], + [-8.5659275, 0.68000054, 1.0], + [-6.2886615, 0.7959308, 1.0], + [-1.6636302, 0.9520329, 1.0], + [-0.02707145, 0.99932, 1.0], + [0.0, 1.0, 1.0], + [-9.858409, 0.65844935, 1.0], + [-10.290696, 0.7347493, 1.0], + [-7.691491, 0.82455605, 1.0], + [-0.43717942, 0.9903527, 1.0], + [0.0, 1.0, 1.0], + [-9.475192, 0.660726, 1.0], + [-8.357337, 0.8211995, 1.0], + [-6.577057, 0.9004506, 1.0], + [-2.6555653, 0.960382, 1.0], + [0.0, 1.0, 1.0], + [-9.107314, 0.6639968, 1.0], + [-6.700647, 0.8769558, 1.0], + [-5.7258368, 0.9253376, 1.0], + [-4.3847528, 0.93834984, 1.0], + [0.0, 1.0, 1.0], + [-8.857171, 0.6214388, 1.0], + [-7.408514, 0.77258706, 1.0], + [-5.1308303, 0.92324936, 1.0], + [-3.1422675, 0.95364594, 1.0], + [0.0, 1.0, 1.0], + [-7.9207935, 0.5845634, 1.0], + [-7.1896048, 0.6665354, 1.0], + [-4.057381, 0.85848784, 1.0], + [-0.73692507, 0.97766274, 1.0], + [0.0, 1.0, 1.0], + [-6.263444, 0.5832066, 1.0], + [-5.9315505, 0.6382356, 1.0], + [-6.1455026, 0.72098804, 1.0], + [-1.0433422, 0.96975046, 1.0], + [0.0, 1.0, 1.0], + [-2.4575808, 0.57827437, 1.0], + [-2.964139, 0.62386805, 1.0], + [-4.5475383, 0.7487994, 1.0], + [-1.4768721, 0.9732885, 1.0], + [0.0, 1.0, 1.0], + [1.157196, 0.57085544, 1.0], + [-0.23392056, 0.64966595, 1.0], + [-1.4028531, 0.88008684, 1.0], + [-1.9659572, 0.9766486, 1.0], + [0.0, 1.0, 1.0], + [4.5584664, 0.6198235, 1.0], + [4.1219063, 0.74054, 1.0], + [0.9924724, 0.93495196, 1.0], + [-0.7218012, 0.988608, 1.0], + [0.0, 1.0, 1.0], + [3.719361, 0.6867312, 1.0], + [3.9891121, 0.7887134, 1.0], + [1.6480545, 0.94551146, 1.0], + [0.059591822, 0.99834126, 1.0], + [0.0, 1.0, 1.0], + [4.201495, 0.7431256, 1.0], + [4.526226, 0.8292865, 1.0], + [1.1760861, 0.9666664, 1.0], + [0.023769544, 0.9994029, 1.0], + [0.0, 1.0, 1.0], + [4.170543, 0.76272196, 1.0], + [2.6068974, 0.8673706, 1.0], + [0.39172325, 0.9858963, 1.0], + [0.004990869, 0.9999019, 1.0], + [0.0, 1.0, 1.0], + [4.301262, 0.77241415, 1.0], + [2.272072, 0.8702671, 1.0], + [0.20433316, 0.98867095, 1.0], + [0.0015561674, 0.9999392, 1.0], + [0.0, 1.0, 1.0], + [1.9231374, 0.8245692, 1.0], + [1.1306319, 0.9499702, 1.0], + [0.12337404, 0.9968917, 1.0], + [0.0012145389, 0.9999798, 1.0], + [0.0, 1.0, 1.0], + [0.7749719, 0.84335756, 1.0], + [0.5761641, 0.95042235, 1.0], + [0.15160465, 0.9963909, 1.0], + [0.0022156653, 0.9999764, 1.0], + [0.0, 1.0, 1.0], + [0.24714066, 0.83536404, 1.0], + [0.69087815, 0.93358576, 1.0], + [0.13375169, 0.9901696, 1.0], + [0.00097174913, 0.9998683, 1.0], + [0.0, 1.0, 1.0], + [0.9913264, 0.84575355, 1.0], + [0.6101601, 0.9683786, 1.0], + [0.03825838, 0.9969422, 1.0], + [-0.00015123004, 0.9999521, 1.0], + [0.0, 1.0, 1.0], + [-0.36478883, 0.8678444, 1.0], + [0.0008414984, 0.9753911, 1.0], + [0.040139902, 0.9994379, 1.0], + [0.0008033622, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [1.5517159, 0.860102, 1.0], + [-0.14287986, 0.9731134, 1.0], + [-0.0241953, 0.9983607, 1.0], + [0.00026539815, 0.9999833, 1.0], + [0.0, 1.0, 1.0], + [1.771025, 0.8242807, 1.0], + [0.39131552, 0.9689884, 1.0], + [0.10286286, 1.0004354, 1.0], + [0.0041896314, 1.0000376, 1.0], + [0.0, 1.0, 1.0], + [3.0717576, 0.7639471, 1.0], + [1.06725, 0.9274218, 1.0], + [0.16506667, 0.99773335, 1.0], + [0.0063294014, 0.99998826, 1.0], + [0.0, 1.0, 1.0], + [2.2291703, 0.7308612, 1.0], + [0.49462095, 0.8725961, 1.0], + [-0.10180267, 0.97933596, 1.0], + [-0.0027677065, 0.99942666, 1.0], + [0.0, 1.0, 1.0], + [1.6348059, 0.6752963, 1.0], + [-0.1813347, 0.81271625, 1.0], + [-0.452213, 0.9789637, 1.0], + [-0.02926826, 0.99930775, 1.0], + [0.0, 1.0, 1.0], + [0.977978, 0.63618356, 1.0], + [-0.62426865, 0.7659978, 1.0], + [-1.2976239, 0.96756303, 1.0], + [-0.16725117, 0.9974616, 1.0], + [0.0, 1.0, 1.0], + [2.5557356, 0.63749415, 1.0], + [-0.46793, 0.74725634, 1.0], + [-1.4595929, 0.9653957, 1.0], + [-0.23743904, 0.99673766, 1.0], + [0.0, 1.0, 1.0], + [6.1136274, 0.6158691, 1.0], + [1.4451386, 0.7140542, 1.0], + [-0.7944747, 0.9716655, 1.0], + [-0.17677365, 0.9974942, 1.0], + [0.0, 1.0, 1.0], + [12.495926, 0.6081697, 1.0], + [2.6334763, 0.75307316, 1.0], + [-0.12727743, 0.9822432, 1.0], + [-0.019888313, 0.9994333, 1.0], + [0.0, 1.0, 1.0], + [20.359663, 0.5505554, 1.0], + [5.360571, 0.7478232, 1.0], + [-0.15579183, 0.9834688, 1.0], + [0.0031478892, 0.9996698, 1.0], + [0.0, 1.0, 1.0], + [22.983652, 0.51872075, 1.0], + [11.201727, 0.65178657, 1.0], + [0.02786072, 0.96850365, 1.0], + [0.050246127, 0.9990089, 1.0], + [0.0, 1.0, 1.0], + [22.102345, 0.5159302, 1.0], + [13.516024, 0.6239164, 1.0], + [-0.2264105, 0.9579077, 1.0], + [0.011741261, 0.9989512, 1.0], + [0.0, 1.0, 1.0], + [16.458723, 0.6381099, 1.0], + [2.070874, 0.8461226, 1.0], + [-0.38940287, 0.94793105, 1.0], + [-0.004449404, 0.9940882, 1.0], + [0.0, 1.0, 1.0], + [14.319419, 0.6865272, 1.0], + [2.107183, 0.90325254, 1.0], + [0.08382885, 0.95650256, 1.0], + [0.08427957, 0.95735323, 1.0], + [0.0, 1.0, 1.0], + [11.907165, 0.7272812, 1.0], + [2.3358235, 0.9138314, 1.0], + [0.061927434, 0.9853146, 1.0], + [0.02880717, 0.98940504, 1.0], + [0.0, 1.0, 1.0], + [9.328565, 0.7501317, 1.0], + [3.736033, 0.8165723, 1.0], + [0.886498, 0.9493906, 1.0], + [0.53428864, 0.9778165, 1.0], + [0.0, 1.0, 1.0], + [7.8924174, 0.6558145, 1.0], + [8.357237, 0.58703214, 1.0], + [5.640144, 0.71630454, 1.0], + [2.3299222, 0.9120247, 1.0], + [0.0, 1.0, 1.0], + [8.797466, 0.5970874, 1.0], + [10.189405, 0.5419211, 1.0], + [9.585496, 0.602155, 1.0], + [8.76234, 0.7506968, 1.0], + [0.0, 1.0, 1.0], + [5.77069, 0.73146415, 1.0], + [8.458371, 0.6105788, 1.0], + [9.997233, 0.66665345, 1.0], + [14.185366, 0.72296506, 1.0], + [0.0, 1.0, 1.0], + [4.832553, 0.7476512, 1.0], + [6.631264, 0.64727205, 1.0], + [9.165473, 0.66433716, 1.0], + [12.874936, 0.71633804, 1.0], + [0.0, 1.0, 1.0], + [4.3384695, 0.7561362, 1.0], + [5.5324025, 0.6862774, 1.0], + [6.1772375, 0.7093537, 1.0], + [5.11874, 0.82211673, 1.0], + [0.0, 1.0, 1.0], + [4.3927083, 0.68014836, 1.0], + [5.3236537, 0.617722, 1.0], + [5.153285, 0.7291801, 1.0], + [6.883996, 0.7625799, 1.0], + [0.0, 1.0, 1.0], + [3.9320729, 0.6744753, 1.0], + [4.8258367, 0.61940265, 1.0], + [5.4672303, 0.7232466, 1.0], + [8.657131, 0.7020352, 1.0], + [0.0, 1.0, 1.0], + [1.7061602, 0.8554576, 1.0], + [1.1815847, 0.8894174, 1.0], + [1.0903149, 0.93655825, 1.0], + [2.8275063, 0.8868961, 1.0], + [0.0, 1.0, 1.0], + [0.90848404, 0.85196733, 1.0], + [0.4342817, 0.9318938, 1.0], + [0.04572374, 0.9942397, 1.0], + [0.0133670345, 0.9983886, 1.0], + [0.0, 1.0, 1.0], + [0.4749884, 0.8968156, 1.0], + [0.13983242, 0.95840764, 1.0], + [0.012114237, 0.9945305, 1.0], + [0.004142619, 0.9975343, 1.0], + [0.0, 1.0, 1.0], + [0.2421307, 0.94426733, 1.0], + [0.025308255, 0.97187287, 1.0], + [-0.013660883, 0.9893051, 1.0], + [-0.014684059, 0.9894599, 1.0], + [0.0, 1.0, 1.0], + [-0.15639894, 0.9373672, 1.0], + [-0.3664362, 0.8935984, 1.0], + [-0.15253535, 0.9533504, 1.0], + [-0.039207056, 0.98815924, 1.0], + [0.0, 1.0, 1.0], + [-0.43275413, 0.8943086, 1.0], + [-0.70259815, 0.840497, 1.0], + [-0.252648, 0.937427, 1.0], + [-0.03330618, 0.9936832, 1.0], + [0.0, 1.0, 1.0], + [0.06659569, 0.87807643, 1.0], + [-0.1700009, 0.8497151, 1.0], + [-0.12636127, 0.963959, 1.0], + [-0.013172978, 0.9975136, 1.0], + [0.0, 1.0, 1.0], + [-0.64482135, 0.87148684, 1.0], + [-0.74943525, 0.83117217, 1.0], + [-0.27330378, 0.93200976, 1.0], + [-0.021869717, 0.9946897, 1.0], + [0.0, 1.0, 1.0], + [-1.0401838, 0.8728109, 1.0], + [-0.73823136, 0.8744596, 1.0], + [-0.18287456, 0.95961267, 1.0], + [-0.009951859, 0.9977242, 1.0], + [0.0, 1.0, 1.0], + [-0.22627981, 0.88137406, 1.0], + [0.007839352, 0.9245987, 1.0], + [0.0014544588, 0.99349904, 1.0], + [-2.9291497e-05, 0.99989325, 1.0], + [0.0, 1.0, 1.0], + [-0.18447767, 0.8809199, 1.0], + [0.008496483, 0.9478462, 1.0], + [0.0067973994, 0.9979893, 1.0], + [5.0736588e-05, 0.9999927, 1.0], + [0.0, 1.0, 1.0], + [0.29172453, 0.92011845, 1.0], + [0.15676522, 0.9734236, 1.0], + [0.004252948, 0.99931306, 1.0], + [-1.0232604e-05, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [0.17869076, 0.9557188, 1.0], + [0.08755076, 0.99127334, 1.0], + [0.0010010353, 0.99973154, 1.0], + [-5.539131e-05, 0.9999892, 1.0], + [0.0, 1.0, 1.0], + [0.22946571, 0.9652117, 1.0], + [0.05355996, 0.9938835, 1.0], + [0.0004149077, 0.9998538, 1.0], + [8.350738e-06, 0.9999956, 1.0], + [0.0, 1.0, 1.0], + [-0.037329286, 0.9731236, 1.0], + [0.006478223, 0.9956182, 1.0], + [0.00065915973, 0.9999197, 1.0], + [2.1461079e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [0.038507767, 0.98602444, 1.0], + [0.009278379, 0.9980656, 1.0], + [0.00043359466, 0.9999601, 1.0], + [1.2074154e-05, 0.99999934, 1.0], + [0.0, 1.0, 1.0], + [0.024321932, 0.99058217, 1.0], + [0.0005276345, 0.99881136, 1.0], + [-0.000108261214, 0.99998933, 1.0], + [-2.0593393e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.08471028, 0.98001426, 1.0], + [0.007567339, 0.9977277, 1.0], + [-0.000107173975, 0.9999823, 1.0], + [-1.4792153e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.21450363, 0.97752666, 1.0], + [0.027476575, 0.9972575, 1.0], + [1.0573019e-05, 0.99997705, 1.0], + [-2.127626e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.13710256, 0.98749036, 1.0], + [0.021214448, 0.9985921, 1.0], + [0.00022974156, 0.99998766, 1.0], + [6.995456e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.034353305, 0.9869362, 1.0], + [-0.0033373374, 0.99891263, 1.0], + [-0.0008769339, 0.99998987, 1.0], + [-6.4116425e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.029380038, 0.9895625, 1.0], + [-0.010520089, 0.99891555, 1.0], + [-0.0019082854, 0.9999861, 1.0], + [-1.3866463e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.085345775, 0.9883013, 1.0], + [-0.0024072328, 0.99874765, 1.0], + [-0.001102076, 0.99999017, 1.0], + [-6.650834e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.09333435, 0.9841484, 1.0], + [0.007872991, 0.99843925, 1.0], + [-0.00031172566, 0.9999886, 1.0], + [-2.2500953e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.1495411, 0.9844692, 1.0], + [-0.009347158, 0.9987978, 1.0], + [-4.104763e-05, 0.9999937, 1.0], + [-4.1019636e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13588922, 0.99047935, 1.0], + [-0.011877171, 0.9992225, 1.0], + [-0.00016244329, 0.99999523, 1.0], + [-4.7618443e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18492526, 0.98800945, 1.0], + [-0.01919961, 0.9989189, 1.0], + [-0.00018170738, 0.9999931, 1.0], + [-2.8776438e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.5042778, 0.9758248, 1.0], + [-0.05973959, 0.99744993, 1.0], + [-0.00048369938, 0.99998105, 1.0], + [-3.4633175e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-1.1252605, 0.9543784, 1.0], + [-0.18540663, 0.99284697, 1.0], + [-0.0028871074, 0.99987376, 1.0], + [-9.758851e-06, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [-1.6847491, 0.9297688, 1.0], + [-0.30172828, 0.9871863, 1.0], + [-0.006863096, 0.9996188, 1.0], + [-3.727347e-05, 0.99999744, 1.0], + [0.0, 1.0, 1.0], + [-2.370997, 0.89369446, 1.0], + [-1.2190995, 0.9472215, 1.0], + [-0.12854093, 0.99495983, 1.0], + [-0.0013108054, 0.999956, 1.0], + [0.0, 1.0, 1.0], + [-4.9047966, 0.7847389, 1.0], + [-5.3030334, 0.8162339, 1.0], + [-1.4172684, 0.9577376, 1.0], + [-0.026107062, 0.999312, 1.0], + [0.0, 1.0, 1.0], + [-5.9655056, 0.73402, 1.0], + [-5.2766113, 0.82234603, 1.0], + [-1.8166275, 0.95730686, 1.0], + [-0.073980905, 0.99863815, 1.0], + [0.0, 1.0, 1.0], + [-7.649344, 0.66660523, 1.0], + [-6.673369, 0.77574635, 1.0], + [-3.8847363, 0.9271979, 1.0], + [-0.35958254, 0.9939365, 1.0], + [0.0, 1.0, 1.0], + [-8.770011, 0.6152831, 1.0], + [-8.663781, 0.6730373, 1.0], + [-4.5796795, 0.8964258, 1.0], + [-0.38371933, 0.9933861, 1.0], + [0.0, 1.0, 1.0], + [-9.064724, 0.563459, 1.0], + [-8.883273, 0.5998541, 1.0], + [-3.3382602, 0.8610413, 1.0], + [-0.06820223, 0.9978487, 1.0], + [0.0, 1.0, 1.0], + [-8.522412, 0.5443333, 1.0], + [-10.088787, 0.5584405, 1.0], + [-8.74115, 0.69652474, 1.0], + [-0.46212718, 0.9844496, 1.0], + [0.0, 1.0, 1.0], + [-5.3416877, 0.5573365, 1.0], + [-10.180397, 0.54992926, 1.0], + [-10.34654, 0.65771925, 1.0], + [-2.239636, 0.95445883, 1.0], + [0.0, 1.0, 1.0], + [-0.17010239, 0.6200763, 1.0], + [-3.7829561, 0.64616305, 1.0], + [-7.5884185, 0.8539007, 1.0], + [-7.036888, 0.9037384, 1.0], + [0.0, 1.0, 1.0], + [2.296787, 0.8043461, 1.0], + [1.2797425, 0.88472384, 1.0], + [-2.9147022, 0.94889057, 1.0], + [-2.639398, 0.96286654, 1.0], + [0.0, 1.0, 1.0], + [1.1746049, 0.87596625, 1.0], + [2.4819775, 0.8980377, 1.0], + [0.43790334, 0.9830677, 1.0], + [-0.006361744, 0.9996459, 1.0], + [0.0, 1.0, 1.0], + [1.4755538, 0.92148954, 1.0], + [3.057467, 0.9028049, 1.0], + [0.5373922, 0.9829736, 1.0], + [0.005417032, 0.99982744, 1.0], + [0.0, 1.0, 1.0], + [0.19876552, 0.97760195, 1.0], + [0.26070496, 0.99011695, 1.0], + [0.033617545, 0.9989797, 1.0], + [0.0002978694, 0.99999154, 1.0], + [0.0, 1.0, 1.0], + [0.07401737, 0.9809595, 1.0], + [0.0669684, 0.9947014, 1.0], + [0.0036516946, 0.99973106, 1.0], + [1.5824207e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.24908549, 0.9819961, 1.0], + [-0.027645566, 0.9976866, 1.0], + [-0.00042068172, 0.9999474, 1.0], + [1.7829321e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.45939854, 0.97829735, 1.0], + [-0.06557831, 0.99736834, 1.0], + [-0.0014225548, 0.99993753, 1.0], + [7.041205e-07, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.97001255, 0.94978815, 1.0], + [-0.06016365, 0.9956392, 1.0], + [0.0005834204, 0.9998938, 1.0], + [1.0331767e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.8007346, 0.9450692, 1.0], + [0.061064422, 0.9958661, 1.0], + [0.0004179476, 0.99996316, 1.0], + [-5.38278e-07, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.7554829, 0.97007614, 1.0], + [0.038412966, 0.99764246, 1.0], + [-1.2250363e-05, 0.99998575, 1.0], + [-4.3817803e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.4214841, 0.98421025, 1.0], + [0.018357918, 0.9986999, 1.0], + [-0.0001539443, 0.99998885, 1.0], + [-2.1144313e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.26302785, 0.9877291, 1.0], + [0.016418263, 0.99897736, 1.0], + [0.00017517614, 0.999994, 1.0], + [3.7461887e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.2549705, 0.9871261, 1.0], + [0.019887371, 0.99840975, 1.0], + [-3.7766345e-05, 0.99995524, 1.0], + [-7.270728e-06, 0.9999991, 1.0], + [0.0, 1.0, 1.0], + [0.57600856, 0.9830809, 1.0], + [0.028567612, 0.99657667, 1.0], + [-0.00476558, 0.99963075, 1.0], + [-0.00016184518, 0.9999903, 1.0], + [0.0, 1.0, 1.0], + [0.023581166, 0.9658987, 1.0], + [-0.12978074, 0.9894591, 1.0], + [-0.010897471, 0.999496, 1.0], + [-0.0003179809, 0.9999912, 1.0], + [0.0, 1.0, 1.0], + [-0.48003206, 0.947442, 1.0], + [-0.28566536, 0.98114216, 1.0], + [-0.02372709, 0.99919075, 1.0], + [-0.0012802386, 0.9999792, 1.0], + [0.0, 1.0, 1.0], + [-0.30012533, 0.9344908, 1.0], + [-0.415363, 0.9781973, 1.0], + [-0.04023375, 0.99892974, 1.0], + [-0.002275092, 0.9999678, 1.0], + [0.0, 1.0, 1.0], + [1.2335646, 0.91651773, 1.0], + [-0.35089287, 0.9740984, 1.0], + [-0.03576802, 0.9988748, 1.0], + [-0.0019647575, 0.99997276, 1.0], + [0.0, 1.0, 1.0], + [4.4362745, 0.8536556, 1.0], + [-0.5057072, 0.96044815, 1.0], + [-0.05471109, 0.99809283, 1.0], + [-0.00035249075, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [12.081045, 0.7132523, 1.0], + [0.5422661, 0.9341002, 1.0], + [-0.06538023, 0.99676913, 1.0], + [-0.00047848828, 0.9999808, 1.0], + [0.0, 1.0, 1.0], + [17.529392, 0.60886335, 1.0], + [1.5537803, 0.8626087, 1.0], + [-0.3528088, 0.98406494, 1.0], + [-0.0052189324, 0.99973387, 1.0], + [0.0, 1.0, 1.0], + [18.210768, 0.5879146, 1.0], + [1.4481329, 0.77672154, 1.0], + [-0.9064065, 0.9183754, 1.0], + [-0.024934258, 0.99743956, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..2d4bc001f 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -30,7 +30,8 @@ use lightcraft_raw::{ /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). -pub const LOOK_VERSION: u32 = 5; +/// 6: the ILCA-99M2 profile. +pub const LOOK_VERSION: u32 = 6; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -2257,15 +2258,15 @@ mod tests { assert_eq!(info.camera_tone.is_some(), look.is_some()); } - /// A public ILCE-7CR sample (skipped without the corpus) takes its colour from the bundled ILCE-7CR profile and - /// passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the photo's - /// own fit (the fallback when a profile is rejected) would not. - #[test] - fn corpus_a7cr_uses_the_bundled_profile() { + /// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` + /// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the + /// photo's own fit (the fallback when a profile is rejected) would not. + fn corpus_uses_the_bundled_profile(file: &str, model: &str) { let path = std::env::var_os("LIGHTCRAFT_CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw-sony-a7cr-lossless-l.arw"); + .join("raw") + .join(file); let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -2274,12 +2275,12 @@ mod tests { } Err(e) => panic!("{}: {e}", path.display()), }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "ILCE-7CR").expect("ILCE-7CR is built in"); + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get("ILCE-7CR").expect("ILCE-7CR profile"); - assert_eq!(*used, bundled, "a local ILCE-7CR profile overrides the built-in one; remove it to run this test"); + let used = crate::camera_profiles::get(model).expect("a profile for the model"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); @@ -2291,6 +2292,16 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + #[test] + fn corpus_a7cr_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); + } + + #[test] + fn corpus_a99m2_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 6c35c2a45..518a1da69 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. pub const BUNDLED: &[(&str, &str)] = &[ + ("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")), ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..a0e3c15c1 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -111,7 +111,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -122,6 +122,12 @@ Without a profile, 123 of the 131 photos accept their own fit; the other 8 open The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. +### Sony ILCA-99M2 + +Fitted from 144 usable photos of 150 ILCA-99M2 ARWs (one photographer, seven shooting days, Standard creative style, DRO Auto, ISO 100–1600, compressed ARW, no in-camera distortion correction; Zeiss 24–70 mm F2.8 and 85 mm F1.4, Tamron and Sigma lenses), 501,511 colour pairs; the other six, one dark and nearly colourless indoor series, gave no usable colour pairs. Measured as for the ILCE-7CR. + +Without a profile all 150 photos accept their own fit (ΔE 1.8–13.5). For validation every fifth photo in sorted name order (30) was withheld and a profile fitted to the other 120 (115 usable). On the withheld photos mean ΔE went from 5.11 with per-file fitting to 4.87 with the profile; 25 of 30 improve (by up to 0.8), the largest regression is 0.24. With the final profile all 150 photos stay accepted, mean ΔE 4.62 → 4.45 (in-sample). The one public raw.pixls.us ILCA-99M2 file (Standard, DRO Auto; not used for fitting) is accepted with and without the profile, ΔE 5.57 → 5.73. Seven shooting days are a narrow sample of scenes and light; this is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. diff --git a/docs/gaps.md b/docs/gaps.md index 39e6c6486..93d4af1c0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -15,7 +15,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| -| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | | 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 B | **Original-pixel detail at 100%** (LR-VIEW-ZOOM, P0): Detail, Before/After, Compare and Reference are sharp at 1:1–8:1 (zoom windows, #323). Mask overlays and soft proofing still render the whole frame at the drawn size instead of a window: sharp up to the GPU texture side, but a full-size render per edit at 1:1 | checklist row 🟡 | editing with a mask overlay or soft proof on at 1:1 is slow on large files, and blurry past the texture side | 4–8 | [ui-parity](ui-parity.md) | @@ -62,6 +62,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-11: Sony ILCA-99M2 profile. - 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345), JPEG XL export (#247). - 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), @@ -75,6 +76,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..57af33f3c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled ILCA-99M2 profile with its held-out measurement | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index e3fdf5fcf..48ed0ec92 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -23,7 +23,7 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 83 / 639 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~42%** | estimated, same weights | -| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | +| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | @@ -190,7 +190,7 @@ sample files. A pointer for contributors, not legal advice. | Item | Adobe | LightCraft | |---|---|---| -| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**4 bundled**: ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix | +| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix | | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | @@ -272,6 +272,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | | 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | | 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index f50d36886..53cc8820b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -72,7 +72,7 @@ calibration: M0–M13 took ≈ 25 active hours against an estimate of ≈ 110– | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF all, RW2 every format, PEF, ORF uncompressed and 12.8 bpp, SRW uncompressed, HEIC ✅; CR3 🟡 (3 bodies verified); **camera colour calibration** 🚧 (per-file JPEG fits, 4 bundled profiles, 52 spectral matrices; measured database missing); compressed ORF / SRW, HE NEF, IIQ, X3F ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF all, RW2 every format, PEF, ORF uncompressed and 12.8 bpp, SRW uncompressed, HEIC ✅; CR3 🟡 (3 bodies verified); **camera colour calibration** 🚧 (per-file JPEG fits, 5 bundled profiles, 52 spectral matrices; measured database missing); compressed ORF / SRW, HE NEF, IIQ, X3F ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | 🚧 (faces: detection, recognition, People view ✅; Object / Describe masks via SAM 3 🟡; Subject / Sky / People masks, semantic search ⬜) | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba62abb40..9d4d3e642 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -68,7 +68,7 @@ from the checklist sections and the deep-dive documents. |---|---:|---:|---:|---:|---| | Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | | RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | -| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | +| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 5 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | | Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | | Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | | Masking | 8% | 67% | 45% | 60–110 h | checklist K 67.4%; AI masks heuristic or SAM 3 without mirror | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..119dc7a02 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -383,6 +383,12 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/ILCE-7CM2/DSC02436.ARW", "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e", ), + // ILCA-99M2 (42 MP, compressed): checks that it takes its colour from the bundled ILCA-99M2 profile + ( + "arw-sony-a99m2.arw", + "https://raw.pixls.us/data/Sony/ILCA-99M2/BR_00086.ARW", + "be898847ac6da485874d95469bf274df1f8f77f8b8542460464f0f393981b2d2", + ), // a dusk sky clipped in green behind a poplar: the clipped-highlight colour of issue #523 ( "arw-sony-a7rm4-14bit-compressed.arw", From ab64e9a563bcfbe1ca200f17f466ad20d6bdb998 Mon Sep 17 00:00:00 2001 From: good-news-everyone <likutov.alexey@gmail.com> Date: Tue, 13 Oct 2026 19:55:17 +0300 Subject: [PATCH 115/315] KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it As over Lightroom's values, a press on the number next to a slider's name that moves sideways now changes the value as dragging the thumb would: the pointer's travel over the track's width, a tenth as far with Shift, on the control's steps and range, as one undo step. A press that moves less than 3 points sideways is still a click that opens the value for typing (KEY-SLIDER-TYPE), and the pointer over the value is the horizontal-resize cursor. The tooltip says both ("Click to type a value, drag to change it"), translated in every catalog. Tests: scrub distance and direction, Shift, clamping, steps, one interaction per scrub, no field or draft left behind, a jittery click still types, a disabled slider doesn't scrub. The readout test that drags away from the value now drags straight down, so it still checks that a cancelled press keeps neither a value nor the keyboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/tests_crop_rotate.rs | 2 +- crates/ui-egui/src/widgets.rs | 65 ++++++++++++-- .../src/widgets/tests_numeric_entry.rs | 86 ++++++++++++++++++- docs/parity-checklist.md | 12 +-- 13 files changed, 159 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..8d2361433 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1911,7 +1911,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "Decrease Rating": "Bewertung verringern", "Increase Rating": "Bewertung erhöhen", - "Click to type a value": "Klicken, um einen Wert einzugeben", + "Click to type a value, drag to change it": "Klicken, um einen Wert einzugeben, ziehen, um ihn zu ändern", "Angle": "Winkel", "Video": "Video", "Copyrighted": "Urheberrechtlich geschützt", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..e7f30f677 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1791,7 +1791,7 @@ "Increase Rating": "Aumentar valoración", "Index Faces": "Indexar caras", "Index Faces in the Background": "Indexar caras en segundo plano", - "Click to type a value": "Hacer clic para escribir un valor", + "Click to type a value, drag to change it": "Hacer clic para escribir un valor, arrastrar para cambiarlo", "Angle": "Ángulo", "Video": "Vídeo", "Copyrighted": "Con copyright", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..a0acb8941 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1745,7 +1745,7 @@ "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Appliquer le mappage local des tons enregistré dans le fichier raw (Apple ProRAW), comme le rend l’appareil", "Decrease Rating": "Diminuer la note", "Increase Rating": "Augmenter la note", - "Click to type a value": "Cliquez pour saisir une valeur", + "Click to type a value, drag to change it": "Cliquez pour saisir une valeur, faites glisser pour la modifier", "Angle": "Angle", "Video": "Vidéo", "Copyrighted": "Protégé par copyright", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..0aae78d39 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1755,7 +1755,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "Decrease Rating": "評価を下げる", "Increase Rating": "評価を上げる", - "Click to type a value": "クリックして値を入力", + "Click to type a value, drag to change it": "クリックして値を入力、ドラッグして変更", "Angle": "角度", "Video": "動画", "Copyrighted": "著作権あり", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..b29b0dba5 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1755,7 +1755,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "Decrease Rating": "Diminuir avaliação", "Increase Rating": "Aumentar avaliação", - "Click to type a value": "Clique para digitar um valor", + "Click to type a value, drag to change it": "Clique para digitar um valor, arraste para alterá-lo", "Angle": "Ângulo", "Video": "Vídeo", "Copyrighted": "Protegido por direitos autorais", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..4903f1620 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1428,7 +1428,7 @@ "commercial use subject to GPL conditions": "коммерческое использование на условиях GPL", "Decrease Rating": "Уменьшить оценку", "Increase Rating": "Повысить оценку", - "Click to type a value": "Щёлкните, чтобы ввести значение", + "Click to type a value, drag to change it": "Щёлкните, чтобы ввести значение, или перетащите, чтобы изменить", "Angle": "Угол", "Raw": "RAW", "Video": "Видео", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..2c162ce96 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1939,7 +1939,7 @@ "Recognition models": "Моделі розпізнавання", "Decrease Rating": "Зменшити оцінку", "Increase Rating": "Збільшити оцінку", - "Click to type a value": "Клацніть, щоб ввести значення", + "Click to type a value, drag to change it": "Клацніть, щоб ввести значення, або перетягніть, щоб змінити", "Angle": "Кут", "Video": "Відео", "Copyrighted": "Захищено авторським правом", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..84d36bdbb 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1755,7 +1755,7 @@ "commercial use subject to GPL conditions": "商用须遵循 GPL 条款", "Decrease Rating": "降低星级", "Increase Rating": "提高星级", - "Click to type a value": "单击以输入数值", + "Click to type a value, drag to change it": "单击以输入数值,拖动以更改", "Angle": "角度", "Video": "视频", "Copyrighted": "受版权保护", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..8ddd637a5 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1755,7 +1755,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "Decrease Rating": "降低評分", "Increase Rating": "提高評分", - "Click to type a value": "點擊以輸入數值", + "Click to type a value, drag to change it": "點擊以輸入數值,拖曳以變更", "Angle": "角度", "Video": "影片", "Copyrighted": "受著作權保護", diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index 8e27411eb..f19171ffc 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -121,7 +121,7 @@ fn the_straighten_value_says_it_can_be_typed() { // tooltips wait for the pointer to rest let _ = h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t.contains("type"))); assert!( - h.painted_text().iter().any(|t| t == "Click to type a value"), + h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"), "{:?}", h.painted_text().iter().filter(|t| t.contains("lick")).collect::<Vec<_>>() ); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..ffcaab5c5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -176,9 +176,14 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { if shown == "+0" || shown == "-0" { "0".to_string() } else { shown } } +/// Pointer travel (points, sideways from the press) that turns a press on a slider's value into a +/// scrub rather than a click that types a value. +const SCRUB_START: f32 = 3.0; + /// A Lightroom slider row (label + value above a track with a hollow ring thumb). /// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to -/// type one: Return or clicking away applies it, Esc keeps the old one. +/// type one: Return or clicking away applies it, Esc keeps the old one. Drag the value sideways +/// to scrub it, as if dragging the thumb (Shift: a tenth as far). pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -216,15 +221,39 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ }); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); - // the value: click it to type an exact one (over the label, so it takes the click) + // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it + // takes the click) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); - let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() }); + let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) - let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; + let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp }; let typing_id = id.with("typing"); // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead + // of rounding back to the same step every frame. + let scrub_id = id.with("scrub-pos"); + let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; + let mut scrub_started = false; + if enabled + && scrub.is_none() + && value_resp.dragged() + && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) + && (p.x - origin.x).abs() >= SCRUB_START + { + // a press on the value that moves sideways scrubs: the edit the press opened is dropped + typing = None; + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); + ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + // the travel so far counts, so the value doesn't lag the pointer by the start distance + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0)); + scrub_started = true; + } + if scrub.is_none() { + ui.data_mut(|m| m.remove::<f64>(scrub_id)); + } let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); // Focus on the owned primary press before fast typing; retain the readout's click activation. if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { @@ -242,7 +271,28 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); let mut v = value; - if let Some((mut text, frames, pressed)) = typing.take() { + if let Some(mut pos) = scrub { + // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel + // over the track's width (a tenth with Shift), on the control's steps + if !scrub_started && value_resp.dragged() { + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + let dx = ui.input(|i| i.pointer.delta().x); + pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0); + } + let step = spec.step.max(1e-9); + let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max); + if (nv - value).abs() > 1e-12 { + out.value = Some(nv); + v = nv; + } + out.drag_started = scrub_started; + if value_resp.drag_stopped() { + out.drag_stopped = true; + ui.data_mut(|m| m.remove::<f64>(scrub_id)); + } else { + ui.data_mut(|m| m.insert_temp(scrub_id, pos)); + } + } else if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. // egui gives that release's click to the readout, so the field under it gives up the focus // whatever frame it lands on (issue #1362): the field takes it back after drawing too. @@ -387,8 +437,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c); if typing.is_none() { p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c); - if enabled && value_resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + // the scrub pointer, as over Lightroom's values: a drag here changes the value + if enabled && (value_resp.hovered() || scrub.is_some()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } } let ring = 7.0; diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 5e54ff064..278ff3ba8 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -11,6 +11,7 @@ struct Harness { enabled: bool, time: f64, rect: Rect, + track: Rect, } impl Harness { fn new() -> Self { @@ -21,6 +22,7 @@ impl Harness { enabled: true, time: 0.0, rect: Rect::NOTHING, + track: Rect::NOTHING, }; for _ in 0..3 { h.frame(vec![]); @@ -33,7 +35,10 @@ impl Harness { self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| { out = slider(ui, &self.spec, self.value, self.enabled, None); }); - self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1; + let registry = take_registry(&self.view.ctx); + let find = |name: String| registry.iter().find(|(id, _)| id == &name).map(|(_, r)| *r); + self.rect = find(format!("sliderValue:{}", self.spec.id)).unwrap(); + self.track = find(format!("slider:{}", self.spec.id)).unwrap(); if let Some(v) = out.value { self.value = v; } @@ -64,9 +69,10 @@ fn opening_release_honors_return_and_escape_immediately() { #[test] fn dragging_away_from_readout_does_not_capture_keyboard() { + // straight down: no sideways travel, so the press neither types nor scrubs let mut h = Harness::new(); let inside = h.rect.center(); - let outside = egui::pos2(5.0, 80.0); + let outside = egui::pos2(inside.x, 80.0); let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] { let out = h.frame(events); @@ -94,3 +100,79 @@ fn typing_after_a_released_click_still_edits() { h.frame(vec![Event::Text("2".into())]); assert_eq!(h.key(Key::Enter).value, Some(2.0)); } + +impl Harness { + /// The track's width: a scrub moves the value as far as dragging the thumb would. + fn track_width(&self) -> f32 { + self.track.width() + } + /// Press on the value, move sideways by `dx` in `steps` frames (Shift held when `shift`) and release; + /// the outputs of every frame. + fn scrub(&mut self, dx: f32, steps: usize, shift: bool) -> Vec<SliderOut> { + let modifiers = if shift { Modifiers::SHIFT } else { Modifiers::NONE }; + let start = self.rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let mut outs = vec![self.frame(vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(start)]), self.frame(vec![button(start, true)])]; + for i in 1..=steps { + let pos = start + vec2(dx * i as f32 / steps as f32, 0.0); + outs.push(self.frame(vec![Event::PointerMoved(pos)])); + } + outs.push(self.frame(vec![button(start + vec2(dx, 0.0), false)])); + outs.push(self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)])); + outs + } +} + +#[test] +fn dragging_the_value_sideways_scrubs_it_like_the_thumb() { + let mut h = Harness::new(); + let width = f64::from(h.track_width()); + assert!(width > 100.0, "the slider has a track ({width})"); + // a fifth of the track: a fifth of the range (Exposure −5..+5 → +2 EV) + let outs = h.scrub((width / 5.0) as f32, 10, false); + assert!((h.value - 2.71).abs() < 0.015, "scrubbed to {}", h.value); + assert_eq!(outs.iter().filter(|o| o.drag_started).count(), 1, "one interaction begins"); + assert_eq!(outs.iter().filter(|o| o.drag_stopped).count(), 1, "and ends, so the scrub is one undo step"); + assert!(outs.iter().filter_map(|o| o.value).count() > 1, "the value follows the pointer while it moves"); + assert!(!h.view.ctx.egui_wants_keyboard_input(), "a scrub opens no value field"); + assert!(h.key(Key::Enter).value.is_none(), "and leaves no draft to commit"); +} + +#[test] +fn scrubbing_back_and_with_shift() { + let mut h = Harness::new(); + let width = h.track_width(); + // left lowers the value; on the control's steps and inside its range + h.scrub(-width, 8, false); + assert_eq!(h.value, -5.0, "clamped at the minimum"); + // Shift moves a tenth as far + let mut h = Harness::new(); + h.scrub(width / 5.0, 10, true); + assert!((h.value - 0.91).abs() < 0.015, "fine scrub to {}", h.value); + assert!(((h.value / 0.01).round() * 0.01 - h.value).abs() < 1e-9, "on the 0.01 steps"); +} + +#[test] +fn a_press_that_barely_moves_still_types() { + // a hand's jitter under the scrub distance is a click: the value can be typed + let mut h = Harness::new(); + let pos = h.rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![button(pos, true)]); + h.frame(vec![Event::PointerMoved(pos + vec2(1.5, 0.5))]); + assert_eq!(h.frame(vec![button(pos + vec2(1.5, 0.5), false)]).value, None); + h.frame(vec![]); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click"); + h.frame(vec![Event::Text("2".into())]); + assert_eq!(h.key(Key::Enter).value, Some(2.0)); +} + +#[test] +fn a_disabled_value_does_not_scrub() { + let mut h = Harness::new(); + h.enabled = false; + let width = h.track_width(); + assert!(h.scrub(width / 5.0, 10, false).iter().all(|o| o.value.is_none() && !o.drag_started)); + assert_eq!(h.value, 0.71); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..e18837b24 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (KEY-SLIDER-SCRUB: scrub a slider's value by dragging it; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -51,11 +51,11 @@ cited here still exists. Formerly docs/parity.md. | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | +| Z. Keyboard shortcuts (desktop) | 77 | 3 | 3 | 1 | 49/52 (94%) | 24/25 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 410 | 52 | 72 | 35 | 193/201 (96%) | 147/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.3% of 159 · P2 50.0% of 173. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -669,6 +669,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | | KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | +| KEY-SLIDER-SCRUB | Scrub a slider value — drag the number | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `SCRUB_START`), `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`dragging_the_value_sideways_scrubs_it_like_the_thumb`, `scrubbing_back_and_with_shift`, `a_press_that_barely_moves_still_types`) | drag the value next to any slider's name sideways: it moves as dragging the thumb would (the pointer's travel over the track's width, ⇧ a tenth as far), on the control's steps and range, as one undo step; a press that moves less than 3 points sideways stays a click that types a value (KEY-SLIDER-TYPE); the pointer over the value is the horizontal-resize cursor, as Lightroom's scrubby value | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | @@ -789,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | From 3d14f50d78fcfe71124bf8e50197be64e6befa6b Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:38:07 +0200 Subject: [PATCH 116/315] Drop the root AGENTS.md docs rule; keep the PR to docs/user/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8f917dd09..8cd199ff6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,13 +95,6 @@ of it. Full standard: `../craftrules/standards/never-crash.md` Never weaken this check to make a commit pass — fix the attribution or remove the asset. - When in doubt about an asset's licence: don't use it. -## User docs -User docs live in `docs/user/` and are published at https://getartcraft.com/docs/lightcraft. If a change alters -something a user sees or does (menu or panel labels, import, export, install, errors), update the matching page in -`docs/user/` in the same commit, or add one. Rules: `docs/user/AGENTS.md`. -Pages describe the latest release. For a change that is not released yet, write that it was added after <version> -and is not in a release yet, or update the page when the release ships. - ## Running and looking at the app - `cargo run --release -p lightcraft -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`). - **Never send OS-level synthetic input** (osascript/System Events keystrokes or clicks, `cliclick`, accessibility From e347d5fd8a1f5c67af58cafe28ff27842549b46d Mon Sep 17 00:00:00 2001 From: SarimSyed <a.sarimsyed@gmail.com> Date: Tue, 13 Oct 2026 17:26:08 -0400 Subject: [PATCH 117/315] LC-SAFETY: protect originals when exporting presets --- crates/engine/src/cmd/curves.rs | 7 ++- crates/engine/src/cmd/preset_files.rs | 7 ++- crates/engine/src/tests_export.rs | 63 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 77b061b78..7388f8e57 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result<Value> { if chosen.is_empty() { return Err(bad(C, "no curve presets to export (save one first, or pass names)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(C, e))?; + crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c..0ae615f69 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -69,10 +69,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> { if chosen.is_empty() { return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad("preset.export", e))?; + crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 592f7349d..a13a38dec 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -236,6 +236,69 @@ fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::p (s, id, dir, src, bytes) } +fn preset_export_preserves_originals(command: &str, selection: serde_json::Value, tag: &str) { + let (mut s, _, dir, src, original) = library_with_jpeg(tag); + let sidecar = src.with_extension("xmp"); + let appended_sidecar = dir.join("IMG_1.jpg.xmp"); + std::fs::write(&sidecar, b"existing sidecar").unwrap(); + std::fs::write(&appended_sidecar, b"appended sidecar").unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let mut targets = vec![src.clone(), dir.join("sub/../IMG_1.jpg"), sidecar.clone()]; + targets.push(appended_sidecar.clone()); + #[cfg(unix)] + { + let alias = dir.join("alias.jpg"); + std::os::unix::fs::symlink(&src, &alias).unwrap(); + targets.push(alias); + let alias = dir.join("alias.xmp"); + std::os::unix::fs::symlink(&sidecar, &alias).unwrap(); + targets.push(alias); + } + for path in targets { + let mut params = selection.clone(); + params["path"] = json!(path.to_string_lossy()); + let error = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(error.contains("never writes over an original"), "{error}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert_eq!(std::fs::read(&sidecar).unwrap(), b"existing sidecar"); + assert_eq!(std::fs::read(&appended_sidecar).unwrap(), b"appended sidecar"); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preset_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("preset.export", json!({"ids": ["lc.warm-glow"]}), "preset-export-guard"); +} + +#[test] +fn curve_preset_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard"); +} + +#[test] +fn preset_exports_preserve_previous_files_on_write_failure() { + let dir = temp_dir("preset-export-failure"); + let mut s = Session::new(); + for (command, selection, file) in + [("preset.export", json!({"ids": ["lc.warm-glow"]}), "look.lcpreset"), ("curve.exportPresets", json!({"names": ["Linear"]}), "curve.lccurve")] + { + let path = dir.join(file); + let mut params = selection; + params["path"] = json!(path.to_string_lossy()); + s.execute(command, ¶ms).unwrap(); + let previous = std::fs::read(&path).unwrap(); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(4); + assert!(s.execute(command, ¶ms).is_err(), "{command} must report the failed write"); + } + assert_eq!(std::fs::read(&path).unwrap(), previous, "{command} preserves the prior export"); + assert_eq!(s.execute(command, ¶ms).unwrap()["count"], 1, "replacement still succeeds"); + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain"); + let _ = std::fs::remove_dir_all(dir); +} + fn disk_batch( s: &mut Session, id: lightcraft_catalog::PhotoId, From 0242a75b5a8b33f648cfeebf188ecf1083081058 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Wed, 14 Oct 2026 12:22:50 +1100 Subject: [PATCH 118/315] Faces: run the networks' convolutions on the GPU (wgpu) when it agrees with the CPU and is faster An accelerator hook in the face interpreter takes Conv nodes; lightcraft-gpu implements it with a WGSL implicit-GEMM convolution (any stride, padding, grouping; split-K for small maps; a direct kernel for depthwise layers), each model's weights resident on a device of its own. After loading, detection and recognition check the GPU in the background (agreement within 1e-3 and faster than the CPU) and otherwise stay on the CPU, which remains the reference; the first GPU error drops it. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- Cargo.lock | 1 + crates/engine/src/cmd/face_detect.rs | 28 ++ crates/engine/src/cmd/face_recognize.rs | 2 + crates/faces/src/lib.rs | 3 +- crates/faces/src/net.rs | 215 ++++++++- crates/faces/src/net/kernels.rs | 27 ++ crates/faces/src/runtime.rs | 25 + crates/faces/src/yunet.rs | 22 + crates/gpu/Cargo.toml | 1 + crates/gpu/src/faces.rs | 593 ++++++++++++++++++++++++ crates/gpu/src/lib.rs | 6 +- crates/gpu/src/wgsl/faces_conv.wgsl | 171 +++++++ docs/faces.md | 28 ++ 13 files changed, 1117 insertions(+), 5 deletions(-) create mode 100644 crates/gpu/src/faces.rs create mode 100644 crates/gpu/src/wgsl/faces_conv.wgsl diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..db28a530d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3299,6 +3299,7 @@ dependencies = [ "lightcraft-color", "lightcraft-denoise", "lightcraft-develop", + "lightcraft-faces", "lightcraft-geom", "lightcraft-pipeline", "lightcraft-raster", diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index ffdedbef1..8e706c367 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -56,10 +56,38 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String } let bytes = std::fs::read(&path).map_err(|e| format!("could not read the detector: {e}"))?; let d = Arc::new(Detector::new(&bytes).map_err(|e| format!("{e}: remove the detector in Settings > Faces and download it again"))?); + #[cfg(not(target_arch = "wasm32"))] + { + let net = d.clone(); + offer_gpu("detection", move |gpu| net.accelerate(gpu, true)); + } *loaded = Some((path, d.clone())); Ok(d) } +/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model +/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn offer_gpu( + what: &'static str, + attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static, +) { + let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || { + let gpu = match lightcraft_gpu::faces::accelerator() { + Ok(gpu) => gpu, + Err(why) => return log::info!("faces: {what} runs on the CPU ({why})"), + }; + let device = lightcraft_faces::net::Accelerator::device(&*gpu); + match attach(gpu) { + Ok(()) => log::info!("faces: {what} runs on {device}"), + Err(why) => log::info!("faces: {what} stays on the CPU: {why}"), + } + }); + if let Err(e) = spawned { + log::warn!("faces: could not check the GPU for {what}: {e}"); + } +} + fn face_json(f: &Face) -> Value { json!({ "rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1}, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..094735c6d 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -234,6 +234,8 @@ mod imp { return Ok(e.clone()); } let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?); + let net = embedder.clone(); + super::super::face_detect::offer_gpu("recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string())); let dim = match installed.manifest.output { lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize, _ => return Err(bad(cmd, "the chosen model is not a recognition model")), diff --git a/crates/faces/src/lib.rs b/crates/faces/src/lib.rs index b4b7066d5..030cda088 100644 --- a/crates/faces/src/lib.rs +++ b/crates/faces/src/lib.rs @@ -5,7 +5,8 @@ //! //! Everything here treats model files and manifests as hostile input: sizes are capped, numbers must be //! finite and sane, and a malformed file is an error, never a panic. Detection and recognition run -//! on the same checked, single-thread pure-Rust CPU interpreter. +//! on the same checked, single-thread pure-Rust CPU interpreter; an [`net::Accelerator`] (the GPU, in `lightcraft-gpu`) can +//! take its convolutions once it agrees with it. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 6222314f2..3983404c5 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::rc::Rc; +use std::sync::{Arc, PoisonError, RwLock}; mod kernels; mod shape; @@ -64,9 +65,58 @@ impl Tensor { } } +/// One convolution, as handed to an [`Accelerator`]: batch 1, planar (NCHW) float32, already checked. +/// +/// `out[co][oy][ox] = bias[co] + Σ weights[co][ci][ky][kx] · input[g·cg + ci][oy·sh + ky − pt][ox·sw + kx − pl]` +/// with `g = co / (cout / groups)` and zero outside the input. +#[derive(Clone, Copy, Debug)] +pub struct ConvJob<'a> { + /// The node's index in the graph: the same for every run of a net, so its weights can stay on the device. + pub layer: usize, + pub cin: usize, + pub h: usize, + pub w: usize, + pub cout: usize, + pub groups: usize, + pub kh: usize, + pub kw: usize, + pub sh: usize, + pub sw: usize, + pub pt: usize, + pub pl: usize, + pub oh: usize, + pub ow: usize, + /// `[cout][cin / groups][kh][kw]`. + pub weights: &'a [f32], + /// `[cout]`, if the node has one. + pub bias: Option<&'a [f32]>, + /// `[cin][h][w]`. + pub input: &'a [f32], +} + +impl ConvJob<'_> { + /// Multiply-adds the convolution takes. + pub fn macs(&self) -> usize { + let per_group = (self.cin / self.groups.max(1)).saturating_mul(self.kh).saturating_mul(self.kw); + self.cout.saturating_mul(per_group).saturating_mul(self.oh).saturating_mul(self.ow) + } +} + +/// Something that can run a network's convolutions faster than the CPU kernels (the GPU, in `lightcraft-gpu`). +/// +/// The CPU interpreter stays the reference: a net only uses an accelerator that agreed with it on a test input +/// ([`Net::attach_checked`]), and the first error makes the net drop it and run on the CPU again. +pub trait Accelerator: Send + Sync { + /// What it runs on, for the user ("AMD Radeon RX 7900 (Vulkan)"). + fn device(&self) -> String; + /// `[cout][oh][ow]`, or `Ok(None)` to leave this one to the CPU (too small to be worth a dispatch, say). + fn conv(&self, job: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String>; +} + /// A loaded network, ready to run. pub struct Net { graph: Graph, + accelerator: RwLock<Option<Arc<dyn Accelerator>>>, } /// Validate semantics rather than silently ignoring unknown or mistyped attributes. @@ -80,7 +130,75 @@ impl Net { for n in &graph.nodes { check(n, &graph)?; } - Ok(Net { graph }) + Ok(Net { graph, accelerator: RwLock::new(None) }) + } + + /// The accelerator in use, if any. + pub fn accelerator(&self) -> Option<Arc<dyn Accelerator>> { + self.accelerator.read().unwrap_or_else(PoisonError::into_inner).clone() + } + + /// Use `accelerator` for this net's convolutions, but only after it gave the CPU's outputs on `probe` (every + /// output value within `tolerance` of the CPU's, relative to that output's largest magnitude) and, with + /// `require_faster`, ran it faster than the CPU did (a GPU's per-layer round trip can cost more than a small + /// model's layers). Returns why it was not used, so the caller can say so; the net then stays on the CPU. + pub fn attach_checked( + &self, + accelerator: Arc<dyn Accelerator>, + probe: &Tensor, + tolerance: f32, + require_faster: bool, + ) -> std::result::Result<(), String> { + self.detach(); + let started = web_time::Instant::now(); + let cpu = self.run(probe.clone()).map_err(|e| e.to_string())?; + let cpu_time = started.elapsed(); + *self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = Some(accelerator); + // the first run also uploads the weights and builds the kernels: time a second one + let fast = self.run(probe.clone()).map_err(|e| e.to_string()); + let mut verdict = match fast { + Err(e) => Err(e), + Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()), + Ok(fast) => agree(&cpu, &fast, tolerance), + }; + if verdict.is_ok() && require_faster { + let started = web_time::Instant::now(); + let again = self.run(probe.clone()).map_err(|e| e.to_string()); + let fast_time = started.elapsed(); + verdict = match again { + Err(e) => Err(e), + Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()), + Ok(_) if fast_time >= cpu_time => Err(format!( + "the device is not faster for this model ({:.0} ms against {:.0} ms on the CPU)", + fast_time.as_secs_f64() * 1000.0, + cpu_time.as_secs_f64() * 1000.0 + )), + Ok(_) => Ok(()), + }; + } + if verdict.is_err() { + self.detach(); + } + verdict + } + + /// Run on the CPU only from now on. + pub fn detach(&self) { + *self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = None; + } + + /// A convolution on the accelerator, if one is attached and takes it. A failing accelerator is dropped. + fn accelerated_conv(&self, layer: usize, node: &Node, x: &Tensor) -> Result<Option<Tensor>> { + let Some(accelerator) = self.accelerator() else { return Ok(None) }; + let job = kernels::conv_job(layer, node, x, &self.graph)?; + match accelerator.conv(&job) { + Ok(Some(out)) if out.len() == job.cout * job.oh * job.ow => Ok(Some(Tensor::new(vec![1, job.cout, job.oh, job.ow], out)?)), + Ok(None) => Ok(None), + Ok(Some(_)) | Err(_) => { + self.detach(); + Ok(None) + } + } } /// The shape the model declares for its input (0 for a dynamic size). @@ -127,7 +245,13 @@ impl Net { }; let started = web_time::Instant::now(); let out = match node.op.as_str() { - "Conv" => kernels::conv(node, &*get(0)?, g, &mut scratch)?, + "Conv" => { + let x = get(0)?; + match self.accelerated_conv(i, node, &x)? { + Some(out) => out, + None => kernels::conv(node, &x, g, &mut scratch)?, + } + } "Relu" => map(&*get(0)?, |v| v.max(0.0)), "Sigmoid" => map(&*get(0)?, |v| 1.0 / (1.0 + (-v).exp())), "Add" | "Sub" | "Mul" => kernels::binary(node.op.as_str(), &*get(0)?, &*get(1)?)?, @@ -168,6 +292,25 @@ impl Net { } } +/// Whether two runs' outputs agree: same names and shapes, values within `tolerance` of each other relative to the +/// output's largest magnitude. +fn agree(a: &[(String, Tensor)], b: &[(String, Tensor)], tolerance: f32) -> std::result::Result<(), String> { + if a.len() != b.len() { + return Err("the device gave a different number of outputs".into()); + } + for ((name, x), (other, y)) in a.iter().zip(b) { + if name != other || x.shape != y.shape { + return Err(format!("the device gave `{other}` a different shape")); + } + let scale = x.data.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6); + let worst = x.data.iter().zip(&y.data).fold(0f32, |m, (p, q)| m.max((p - q).abs())); + if worst.is_nan() || worst > tolerance * scale { + return Err(format!("the device's `{name}` differs from the CPU's by {:.2e} (of {scale:.2e})", worst)); + } + } + Ok(()) +} + fn map(x: &Tensor, f: impl Fn(f32) -> f32) -> Tensor { Tensor { shape: x.shape.clone(), data: x.data.iter().map(|v| f(*v)).collect() } } @@ -534,6 +677,74 @@ mod tests { assert_eq!(rs.shape, vec![1, 6, 2]); } + /// An accelerator built on the slow reference convolution, optionally wrong or failing. + struct Fake { + skew: f32, + fail_after: std::sync::atomic::AtomicUsize, + } + impl Accelerator for Fake { + fn device(&self) -> String { + "test".into() + } + fn conv(&self, j: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> { + if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 { + return Err("device lost".into()); + } + assert!(j.pt == j.pl && j.sh == j.sw); + let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap(); + let zero = vec![0.0; j.cout]; + let dims = [j.cout, j.cin / j.groups, j.kh, j.kw]; + let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups); + Ok(Some(out.data.iter().map(|v| v + self.skew).collect())) + } + } + + fn two_convs() -> (Graph, Tensor) { + let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))]; + let mut a = node("Conv", &["x", "w1", "b1"], &attrs); + a.outputs = vec!["h".into()]; + let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]); + let mut g = graph( + vec![a, b], + vec![ + ("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())), + ("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])), + ("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())), + ], + ); + g.inputs = vec![("x".into(), vec![1, 3, 9, 8])]; + (g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1)) + } + + #[test] + fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() }); + net.attach_checked(good, &x, 1e-4, false).unwrap(); + assert!(net.accelerator().is_some()); + assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape); + let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() }); + assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err()); + assert!(net.accelerator().is_none(), "a device that disagrees is not used"); + assert_eq!(net.run(x).unwrap(), cpu); + } + + #[test] + fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + // agrees on the probe (two convs), then fails on the second conv of the next run + net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap(); + let out = net.run(x).unwrap(); + assert!(net.accelerator().is_none()); + for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) { + assert!((a - b).abs() < 1e-4); + } + } + #[test] fn unsupported_and_inconsistent_models_are_errors() { let x = input(1, 3, 3, |i| i as f32); diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 73cc72b93..6d7c1dbb9 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -59,6 +59,33 @@ fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, p } Ok(()) } +/// The checked geometry of a convolution node, for an accelerator. +pub(super) fn conv_job<'a>(layer: usize, n: &Node, x: &'a Tensor, g: &'a Graph) -> Result<ConvJob<'a>> { + let (cin, h, w) = chw(x)?; + let wt = shape::weight(n, g, 1)?; + let weights = shape::floats(n, g, 1)?; + let [cout, cg, kh, kw] = wt.dims.as_slice() else { + return shape_err("invalid convolution weights"); + }; + let (cout, cg, kh, kw) = (*cout, *cg, *kh, *kw); + let groups = usize::try_from(n.int("group").unwrap_or(1)).map_err(|_| NetError::Shape("invalid group".into()))?; + let (sh, sw) = pair(n, "strides", 1)?; + let (pt, pl, pb, pr) = pads(n)?; + let ph = h.checked_add(pt).and_then(|v| v.checked_add(pb)).ok_or_else(|| NetError::Shape("padding overflow".into()))?; + let pw = w.checked_add(pl).and_then(|v| v.checked_add(pr)).ok_or_else(|| NetError::Shape("padding overflow".into()))?; + if ph < kh || pw < kw || groups == 0 || cg == 0 || kh == 0 || kw == 0 || cin != groups * cg || cout % groups != 0 { + return shape_err("invalid convolution shape"); + } + if cout.checked_mul(cg).and_then(|v| v.checked_mul(kh * kw)) != Some(weights.len()) { + return shape_err("short convolution weights"); + } + let bias = if n.inputs.get(2).is_some_and(|s| !s.is_empty()) { Some(shape::floats(n, g, 2)?) } else { None }; + if bias.is_some_and(|b| b.len() != cout) { + return shape_err("the convolution bias does not match"); + } + let (oh, ow) = ((ph - kh) / sh + 1, (pw - kw) / sw + 1); + Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input: &x.data }) +} pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result<Tensor> { let (cin, h, w) = chw(x)?; let wt = shape::weight(n, g, 1)?; diff --git a/crates/faces/src/runtime.rs b/crates/faces/src/runtime.rs index bbb1457eb..7a8ab948a 100644 --- a/crates/faces/src/runtime.rs +++ b/crates/faces/src/runtime.rs @@ -99,6 +99,31 @@ impl Embedder { }) } + /// The network, e.g. to see which device it runs on. + pub fn net(&self) -> &Net { + &self.plan + } + + /// Run the model's convolutions on `accelerator` if it gives the CPU's embedding on a test face (and, with + /// `require_faster`, runs it faster than the CPU); otherwise (or on + /// its first error) recognition stays on the CPU. Returns why it was not used. + pub fn accelerate(&self, accelerator: Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), RuntimeError> { + let (mean, std) = (self.manifest.input.mean, self.manifest.input.std); + let plane = self.width * self.height; + let data: Vec<f32> = crate::yunet::probe_pixels(3 * plane) + .into_iter() + .enumerate() + .map(|(i, v)| { + let c = (i / plane.max(1)).min(2); + (v - mean.get(c).copied().unwrap_or(0.0)) / std.get(c).copied().filter(|s| *s != 0.0).unwrap_or(1.0) + }) + .collect(); + let probe = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?; + guarded("checking the device", || { + self.plan.attach_checked(accelerator, &probe, crate::yunet::ACCELERATOR_TOLERANCE, require_faster).map_err(RuntimeError::Run) + }) + } + /// The size of the aligned face picture the model wants (width, height). pub fn input_size(&self) -> (usize, usize) { (self.width, self.height) diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..d1c62f75c 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -60,6 +60,14 @@ pub struct Face { pub landmarks: [(f32, f32); 5], } +/// How far an accelerator's outputs may stray from the CPU's on the test input, relative to each output's largest value. +pub const ACCELERATOR_TOLERANCE: f32 = 1e-3; + +/// A deterministic, photo-like test input (smooth gradients plus texture, in 0..255). +pub fn probe_pixels(n: usize) -> Vec<f32> { + (0..n).map(|i| ((i % 640) as f32 * 0.3 + ((i * 7919) % 97) as f32 + (i / 640 % 480) as f32 * 0.2) % 255.0).collect() +} + pub struct Detector { net: Net, /// Side of the square network input. @@ -90,6 +98,20 @@ impl Detector { Ok(Detector { net, side }) } + /// The network, e.g. to see which device it runs on. + pub fn net(&self) -> &Net { + &self.net + } + + /// Run the network's convolutions on `accelerator` if it gives the CPU's outputs on a test picture (and, with `require_faster`, + /// runs it faster than the CPU); otherwise + /// (or on its first error) detection stays on the CPU. Returns why it was not used. + pub fn accelerate(&self, accelerator: std::sync::Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), String> { + let side = self.side; + let probe = Tensor::new(vec![1, 3, side, side], probe_pixels(3 * side * side)).map_err(|e| e.to_string())?; + self.net.attach_checked(accelerator, &probe, ACCELERATOR_TOLERANCE, require_faster) + } + /// The square input side, used to check an installed manifest. pub fn input_side(&self) -> usize { self.side diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..060abb112 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -14,6 +14,7 @@ lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } +lightcraft-faces = { workspace = true } lightcraft-pipeline = { workspace = true } log = { workspace = true } diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs new file mode 100644 index 000000000..ae982d30b --- /dev/null +++ b/crates/gpu/src/faces.rs @@ -0,0 +1,593 @@ +//! Face networks on the GPU: an [`lightcraft_faces::net::Accelerator`] that runs the interpreter's convolutions (most of +//! YuNet's, SFace's and AuraFace's time) with a WGSL kernel (`wgsl/faces_conv.wgsl`). Everything else in the graph +//! stays on the CPU interpreter, which is the reference: a net attaches this only after it agreed with the CPU on a +//! test input (`Net::attach_checked`), and drops it on the first error. +//! +//! The device is a separate one (like denoise's), so a face scan and the interactive renders never share a queue, and +//! it respects the same switches as GPU rendering (`LIGHTCRAFT_GPU`, `LIGHTCRAFT_GPU_BACKEND`, the GPU preference) +//! and the crash sentinel around device creation. A model's weights are uploaded once, on first use, and stay on the +//! device while the accelerator (one per loaded model) lives. Every failure is an `Err`, never a panic. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use bytemuck::{Pod, Zeroable}; +use lightcraft_faces::net::{Accelerator, ConvJob}; +use wgpu::util::DeviceExt; + +/// Longest wait for one convolution before the device is given up on. +const TIMEOUT: Duration = Duration::from_secs(30); +/// Below this many multiply-adds a convolution stays on the CPU: the upload, dispatch and read-back cost more. +const MIN_MACS: usize = 1 << 20; +/// Workgroups a convolution should make at least, cutting its K into slices when its tiles are too few. +const TARGET_WORKGROUPS: usize = 256; +/// Weights one model may keep on the device (AuraFace's are about 250 MB). +const MAX_RESIDENT: u64 = 768 << 20; + +struct Dev { + device: wgpu::Device, + queue: wgpu::Queue, + info: wgpu::AdapterInfo, + limits: wgpu::Limits, + layout: wgpu::BindGroupLayout, + tiled: wgpu::ComputePipeline, + direct: wgpu::ComputePipeline, + reduce: wgpu::ComputePipeline, +} + +static DEV: OnceLock<Result<Dev, String>> = OnceLock::new(); +static BROKEN: AtomicBool = AtomicBool::new(false); +static BROKEN_WHY: Mutex<Option<String>> = Mutex::new(None); + +fn mark_broken(why: &str) { + let mut w = BROKEN_WHY.lock().unwrap_or_else(PoisonError::into_inner); + if w.is_none() { + *w = Some(why.to_string()); + } + BROKEN.store(true, Ordering::Relaxed); +} + +/// Why the GPU stopped being used for faces (a device error), if it did. +pub fn broken_reason() -> Option<String> { + BROKEN.load(Ordering::Relaxed).then(|| BROKEN_WHY.lock().unwrap_or_else(PoisonError::into_inner).clone().unwrap_or_else(|| "device error".into())) +} + +fn dev() -> Result<&'static Dev, String> { + if let Some(why) = broken_reason() { + return Err(format!("stopped after a GPU failure: {why}")); + } + if let Some(why) = crate::switched_off() { + return Err(why); + } + let _work = match DEV.get() { + Some(_) => None, + None => Some(crate::exit::enter().ok_or("the app is closing")?), + }; + DEV.get_or_init(create_device).as_ref().map_err(|e| e.clone()) +} + +fn create_device() -> Result<Dev, String> { + let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + crate::backend::with_init_marker(backends, || { + std::panic::catch_unwind(|| make_device(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) + }) +} + +fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + } +} + +fn make_device(backends: wgpu::Backends) -> Result<Dev, String> { + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + desc.backend_options = crate::backend::backend_options(); + desc.flags = crate::backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); + let adapter = match &wanted { + Some(w) => pollster::block_on(instance.enumerate_adapters(backends)) + .into_iter() + .find(|a| { + let i = a.get_info(); + i.device_type != wgpu::DeviceType::Cpu && i.name.to_lowercase().contains(w.as_str()) + }) + .ok_or_else(|| format!("no GPU adapter named like `{w}`"))?, + None => pollster::block_on( + instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), + ) + .map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?, + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu { + return Err(format!("software adapter ({}) skipped: the CPU is faster", info.name)); + } + let limits = adapter.limits(); + if limits.max_storage_buffers_per_shader_stage < 4 + || limits.max_compute_invocations_per_workgroup < 256 + || limits.max_compute_workgroup_size_x < 256 + || limits.max_compute_workgroup_storage_size < 8 * 1024 + { + return Err(format!("{} is too limited for the face kernels", info.name)); + } + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("lightcraft-faces"), + required_limits: limits.clone(), + ..Default::default() + })) + .map_err(|e| format!("{}: device creation failed ({e})", info.name))?; + device.on_uncaptured_error(Arc::new(|e| { + log::error!("gpu (faces): {e}"); + mark_broken(&format!("device error: {e}")); + })); + device.set_device_lost_callback(|reason, msg| { + log::error!("gpu (faces): device lost ({reason:?}): {msg}"); + mark_broken(&format!("device lost ({reason:?}): {msg}")); + }); + use wgpu::BufferBindingType::{Storage, Uniform}; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("faces conv"), + entries: &[ + entry(0, Uniform), + entry(1, Storage { read_only: true }), + entry(2, Storage { read_only: true }), + entry(3, Storage { read_only: true }), + entry(4, Storage { read_only: false }), + ], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("faces conv"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_conv.wgsl").into()), + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipe = |entry: &str| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&pl), + module: &module, + entry_point: Some(entry), + // both kernels write all of their workgroup memory before reading it + compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() }, + cache: None, + }) + }; + let (tiled, direct, reduce) = (pipe("tiled"), pipe("direct"), pipe("reduce")); + log::info!("gpu (faces): {} ({:?})", info.name, info.backend); + Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce }) +} + +/// The adapter face scans would use ("AMD Radeon RX 7900 XTX (Vulkan)"), or why they cannot use one. Creates the +/// device on first use. +pub fn adapter() -> Result<String, String> { + dev().map(|d| format!("{} ({:?})", d.info.name, d.info.backend)) +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Params { + m: u32, + n: u32, + k: u32, + cg: u32, + h: u32, + w: u32, + ow: u32, + kh: u32, + kw: u32, + sh: u32, + sw: u32, + pt: u32, + pl: u32, + bias: u32, + total: u32, + row: u32, + splits: u32, + kchunk: u32, + _a: u32, + _b: u32, +} + +/// A layer's weights and bias on the device. +struct Resident { + weights: wgpu::Buffer, + bias: wgpu::Buffer, +} + +/// The GPU accelerator for one loaded face model: its weights stay on the device while it lives. +pub struct FaceGpu { + dev: &'static Dev, + layers: Mutex<HashMap<usize, Arc<Resident>>>, + resident: AtomicU64, +} + +/// A GPU accelerator for one face model, or why there is none (GPU off, missing or broken). +pub fn accelerator() -> Result<Arc<FaceGpu>, String> { + Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) +} + +fn u32_of(v: usize) -> Result<u32, String> { + u32::try_from(v).ok().filter(|v| *v < (1 << 31)).ok_or_else(|| "a layer too large for the GPU kernel".to_string()) +} + +impl FaceGpu { + /// Bytes of weights this model keeps on the device. + pub fn resident_bytes(&self) -> u64 { + self.resident.load(Ordering::Relaxed) + } + + fn resident(&self, job: &ConvJob) -> Result<Option<Arc<Resident>>, String> { + let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(r) = layers.get(&job.layer) { + return Ok(Some(r.clone())); + } + let bytes = (job.weights.len() as u64 + job.cout as u64) * 4; + if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { + return Ok(None); + } + let d = self.dev; + let usage = wgpu::BufferUsages::STORAGE; + let weights = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces weights"), + contents: bytemuck::cast_slice(job.weights), + usage, + }); + let zero = [0f32; 1]; + let bias = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces bias"), + contents: bytemuck::cast_slice(job.bias.unwrap_or(&zero)), + usage, + }); + let r = Arc::new(Resident { weights, bias }); + self.resident.fetch_add(bytes, Ordering::Relaxed); + layers.insert(job.layer, r.clone()); + Ok(Some(r)) + } + + fn run(&self, job: &ConvJob) -> Result<Option<Vec<f32>>, String> { + let d = self.dev; + let groups = job.groups.max(1); + let (m, cg) = (job.cout / groups, job.cin / groups); + let n = job.oh.checked_mul(job.ow).ok_or("a layer too large")?; + let k = cg.checked_mul(job.kh).and_then(|v| v.checked_mul(job.kw)).ok_or("a layer too large")?; + let total = job.cout.checked_mul(n).ok_or("a layer too large")?; + let direct = m < 8; + // Too few 64 x 64 tiles to keep the GPU busy (a 7 x 7 map of 512 channels makes 8): cut K into slices that + // run side by side, and sum them in a second pass. + let tiles = n.div_ceil(64) * m.div_ceil(64) * groups; + let splits = if direct { 1 } else { (TARGET_WORKGROUPS / tiles.max(1)).clamp(1, (k / 128).max(1)) }; + let kchunk = k.div_ceil(splits).div_ceil(16) * 16; + let splits = k.div_ceil(kchunk.max(1)).max(1); + let partial = total.checked_mul(splits).ok_or("a layer too large")?; + let largest = [job.input.len(), job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; + if largest > d.limits.max_storage_buffer_binding_size.min(d.limits.max_buffer_size) { + return Ok(None); + } + let flat = |count: usize| { + let blocks = count.div_ceil(256); + let row = blocks.clamp(1, 65_535); + (row, blocks.div_ceil(row), 1, row) + }; + let (wx, wy, wz, row) = if direct { flat(total) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits, flat(total).3) }; + let (rx, ry, _, _) = flat(total); + let limit = d.limits.max_compute_workgroups_per_dimension as usize; + if [wx, wy, wz, rx, ry].into_iter().any(|v| v > limit) { + return Ok(None); + } + let Some(res) = self.resident(job)? else { return Ok(None) }; + let params = Params { + m: u32_of(m)?, + n: u32_of(n)?, + k: u32_of(k)?, + cg: u32_of(cg)?, + h: u32_of(job.h)?, + w: u32_of(job.w)?, + ow: u32_of(job.ow)?, + kh: u32_of(job.kh)?, + kw: u32_of(job.kw)?, + sh: u32_of(job.sh)?, + sw: u32_of(job.sw)?, + pt: u32_of(job.pt)?, + pl: u32_of(job.pl)?, + bias: u32::from(job.bias.is_some()), + total: u32_of(total)?, + row: u32_of(row)?, + splits: u32_of(splits)?, + kchunk: u32_of(kchunk)?, + _a: 0, + _b: 0, + }; + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let scopes = [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)]; + let uniform = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces params"), + contents: bytemuck::bytes_of(¶ms), + usage: wgpu::BufferUsages::UNIFORM, + }); + let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(job.input), + usage: wgpu::BufferUsages::STORAGE, + }); + let bytes = total as u64 * 4; + let output = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces output"), + size: bytes, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let partials = (splits > 1).then(|| { + d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces partial sums"), + size: partial as u64 * 4, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }) + }); + let staging = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces read-back"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: input.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: partials.as_ref().unwrap_or(&output).as_entire_binding() }, + ], + }); + let summing = partials.as_ref().map(|partials| { + d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: partials.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: output.as_entire_binding() }, + ], + }) + }); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces conv"), timestamp_writes: None }); + pass.set_pipeline(if direct { &d.direct } else { &d.tiled }); + pass.set_bind_group(0, &bind, &[]); + pass.dispatch_workgroups(u32_of(wx)?, u32_of(wy)?, u32_of(wz)?); + if let Some(summing) = &summing { + pass.set_pipeline(&d.reduce); + pass.set_bind_group(0, summing, &[]); + pass.dispatch_workgroups(u32_of(rx)?, u32_of(ry)?, 1); + } + } + enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + let index = d.queue.submit([enc.finish()]); + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let waited = d.device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: Some(TIMEOUT) }); + let [oom, validation] = scopes; + let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + if let Some(e) = oom { + return Err(format!("the GPU ran out of memory: {e}")); + } + if let Some(e) = validation { + mark_broken(&format!("device error: {e}")); + return Err(format!("the GPU reported an error: {e}")); + } + if let Err(e) = waited { + mark_broken(&format!("the GPU did not finish a convolution: {e}")); + return Err(format!("the GPU did not finish: {e}")); + } + match rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("reading the result back failed: {e}")), + Err(e) => return Err(format!("reading the result back failed: {e}")), + } + let out = { + let data = slice.get_mapped_range().map_err(|e| format!("reading the result back failed: {e:?}"))?; + data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::<Vec<f32>>() + }; + staging.unmap(); + Ok(Some(out)) + } +} + +impl Accelerator for FaceGpu { + fn device(&self) -> String { + format!("{} ({:?})", self.dev.info.name, self.dev.info.backend) + } + + fn conv(&self, job: &ConvJob) -> Result<Option<Vec<f32>>, String> { + if let Some(why) = broken_reason() { + return Err(format!("the GPU is not used any more: {why}")); + } + if crate::switched_off().is_some() || job.macs() < MIN_MACS { + return Ok(None); + } + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run(job))).unwrap_or_else(|_| { + mark_broken("a face convolution panicked"); + Err("the GPU runner gave up".into()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gpu() -> Option<Arc<FaceGpu>> { + match accelerator() { + Ok(a) => Some(a), + Err(e) => { + eprintln!("no GPU for the face kernels, skipping: {e}"); + None + } + } + } + + /// The plain definition of a convolution, to check the kernels against. + fn reference(j: &ConvJob) -> Vec<f32> { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; j.cout * j.oh * j.ow]; + for co in 0..j.cout { + let g = co / m; + for oy in 0..j.oh { + for ox in 0..j.ow { + let mut v = j.bias.map_or(0.0, |b| b[co]); + for ci in 0..cg { + for ky in 0..j.kh { + for kx in 0..j.kw { + let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { + v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] + * j.input[((g * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + + #[test] + fn the_gpu_convolution_gives_what_the_definition_gives() { + let Some(gpu) = gpu() else { return }; + // (cin, cout, groups, k, stride, pad, h, w): dense, strided, depthwise, grouped, pointwise, odd sizes + for (layer, (cin, cout, groups, k, s, p, h, w)) in [ + (3, 16, 1, 3, 2, 1, 41, 37), + (64, 64, 1, 3, 1, 1, 28, 28), + (32, 32, 32, 3, 1, 1, 30, 31), + (16, 32, 4, 3, 2, 1, 33, 20), + (48, 72, 1, 1, 1, 0, 50, 45), + (8, 130, 1, 5, 1, 2, 23, 19), + ] + .into_iter() + .enumerate() + { + let cg = cin / groups; + let weights: Vec<f32> = (0..cout * cg * k * k).map(|i| ((i * 37 % 23) as f32 - 11.0) * 0.03).collect(); + let bias: Vec<f32> = (0..cout).map(|i| i as f32 * 0.01 - 0.3).collect(); + let input: Vec<f32> = (0..cin * h * w).map(|i| ((i * 13 % 29) as f32 - 14.0) * 0.07).collect(); + let (oh, ow) = ((h + 2 * p - k) / s + 1, (w + 2 * p - k) / s + 1); + let job = ConvJob { + layer, + cin, + h, + w, + cout, + groups, + kh: k, + kw: k, + sh: s, + sw: s, + pt: p, + pl: p, + oh, + ow, + weights: &weights, + bias: Some(&bias), + input: &input, + }; + let got = gpu.run(&job).unwrap().unwrap(); + let want = reference(&job); + let worst = got.iter().zip(&want).fold(0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(worst < 1e-3, "case {layer}: off by {worst}"); + } + assert!(gpu.resident_bytes() > 0, "the weights stay on the device"); + } + + /// The real models on the GPU agree with the CPU interpreter: `LC_FACE_MODELS=<folder>` holding + /// `face_detection_yunet_2023mar.onnx`, `face_recognition_sface_2021dec.onnx` and (optionally) `glintr100.onnx`. + #[test] + fn real_face_models_on_the_gpu_agree_with_the_cpu() { + let Some(dir) = std::env::var_os("LC_FACE_MODELS").map(std::path::PathBuf::from) else { + eprintln!("LC_FACE_MODELS is not set: skipping"); + return; + }; + if gpu().is_none() { + return; + } + let det = lightcraft_faces::yunet::Detector::new(&std::fs::read(dir.join("face_detection_yunet_2023mar.onnx")).unwrap()).unwrap(); + let (w, h) = (900, 700); + let rgb: Vec<u8> = (0..w * h * 3).map(|i| ((i / 3 % w) as f32 * 0.25 + (i / 3 / w) as f32 * 0.3 + (i * 7919 % 41) as f32) as u8).collect(); + let opts = lightcraft_faces::yunet::Options { score: 0.0, ..Default::default() }; + let cpu = det.detect(&rgb, w, h, &opts).unwrap(); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = det.detect(&rgb, w, h, &opts).unwrap(); + assert!(det.net().accelerator().is_some()); + assert_eq!(cpu.len(), fast.len()); + for (a, b) in cpu.iter().zip(&fast) { + assert!((a.score - b.score).abs() < 1e-3, "{} vs {}", a.score, b.score); + } + for (file, sha) in [ + ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), + ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), + ] { + let path = dir.join(file); + if !path.exists() { + continue; + } + let e = lightcraft_faces::runtime::Embedder::load(&path, &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); + let (ew, eh) = e.input_size(); + let face: Vec<u8> = (0..ew * eh * 3).map(|i| ((i * 13) % 251) as u8).collect(); + let cpu = e.embed(&face).unwrap(); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = e.embed(&face).unwrap(); + let cosine: f32 = cpu.iter().zip(&fast).map(|(a, b)| a * b).sum(); + assert!(cosine > 0.9999, "{file}: cosine {cosine}"); + } + } + + /// Timings, CPU against GPU: `LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. + #[test] + #[ignore = "a benchmark: needs LC_FACE_MODELS and a GPU"] + fn timings() { + let Some(dir) = std::env::var_os("LC_FACE_MODELS").map(std::path::PathBuf::from) else { return }; + let Some(_) = gpu() else { return }; + let time = |runs: usize, f: &mut dyn FnMut()| { + f(); + let t = std::time::Instant::now(); + for _ in 0..runs { + f(); + } + t.elapsed().as_secs_f64() * 1000.0 / runs as f64 + }; + let det = lightcraft_faces::yunet::Detector::new(&std::fs::read(dir.join("face_detection_yunet_2023mar.onnx")).unwrap()).unwrap(); + let (w, h) = (1024, 768); + let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let opts = lightcraft_faces::yunet::Options::default(); + let cpu = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + for (file, sha) in [ + ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), + ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), + ] { + let e = lightcraft_faces::runtime::Embedder::load(&dir.join(file), &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); + let (ew, eh) = e.input_size(); + let face: Vec<u8> = (0..ew * eh * 3).map(|i| (i * 13 % 251) as u8).collect(); + let cpu = time(3, &mut || drop(e.embed(&face).unwrap())); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = time(3, &mut || drop(e.embed(&face).unwrap())); + eprintln!("{file}: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fb9785ba6..9c1de0f99 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -35,6 +35,8 @@ pub mod backend; mod ctx; #[cfg(not(target_arch = "wasm32"))] mod exit; +#[cfg(not(target_arch = "wasm32"))] +pub mod faces; #[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub mod nn; #[cfg(not(target_arch = "wasm32"))] @@ -239,9 +241,9 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool { } } -/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the +/// Why GPU compute that has a device of its own (denoise, faces) is switched off by the user's settings, or `None`: the /// environment switches and the rendering preference, but not a render-device failure (the devices are separate). -#[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn switched_off() -> Option<String> { if env_disabled() { return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); diff --git a/crates/gpu/src/wgsl/faces_conv.wgsl b/crates/gpu/src/wgsl/faces_conv.wgsl new file mode 100644 index 000000000..8384394e9 --- /dev/null +++ b/crates/gpu/src/wgsl/faces_conv.wgsl @@ -0,0 +1,171 @@ +// Convolution for the face networks (lightcraft-faces), planar (NCHW) float32, batch 1, any stride, padding and grouping. +// +// `tiled` is an implicit matrix product per group: rows are the group's output channels (M), columns its output +// pixels (N), and K runs over input channel x kernel tap. The input is gathered (im2col) straight into workgroup +// memory, so the lowered matrix is never made. 256 threads make a 64 x 64 block, 4 x 4 values each. +// `direct` gives one output value per thread, for depthwise convolutions (one output channel per group), where a +// 64-row tile would be almost all waste. +// +// out[co][p] = bias[co] + sum_k W[co][k] * X[k][p] X[k][p] = input[g*cg + k/taps][oy*sh + ky - pt][ox*sw + kx - pl] + +struct P { + m: u32, // output channels per group + n: u32, // output pixels (oh * ow) + k: u32, // cg * kh * kw + cg: u32, // input channels per group + h: u32, + w: u32, + ow: u32, + kh: u32, + kw: u32, + sh: u32, + sw: u32, + pt: u32, + pl: u32, + bias: u32, // 1 when there is a bias + total: u32, // cout * n + row: u32, // workgroups along x, for `direct` and `reduce` (so a large layer can use y too) + splits: u32, // K is cut into this many slices (z = group * splits + slice), summed by `reduce` + kchunk: u32, // K values per slice, a multiple of BK + _a: u32, + _b: u32, +} + +@group(0) @binding(0) var<uniform> p: P; +@group(0) @binding(1) var<storage, read> src: array<f32>; +@group(0) @binding(2) var<storage, read> wgt: array<f32>; +@group(0) @binding(3) var<storage, read> bias: array<f32>; +@group(0) @binding(4) var<storage, read_write> dst: array<f32>; + +const BM: u32 = 64u; +const BN: u32 = 64u; +const BK: u32 = 16u; + +var<workgroup> As: array<f32, 1024>; // [BK][BM] +var<workgroup> Bs: array<f32, 1024>; // [BK][BN] + +fn gather(g: u32, k: u32, pix: u32) -> f32 { + let taps = p.kh * p.kw; + let ci = g * p.cg + k / taps; + let tap = k % taps; + let oy = pix / p.ow; + let ox = pix % p.ow; + let iy = i32(oy * p.sh + tap / p.kw) - i32(p.pt); + let ix = i32(ox * p.sw + tap % p.kw) - i32(p.pl); + if (iy < 0 || ix < 0 || iy >= i32(p.h) || ix >= i32(p.w)) { + return 0.0; + } + return src[(ci * p.h + u32(iy)) * p.w + u32(ix)]; +} + +@compute @workgroup_size(256) +fn tiled(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) { + let n0 = wg.x * BN; + let m0 = wg.y * BM; + let g = wg.z / p.splits; + let slice = wg.z % p.splits; + let k_lo = slice * p.kchunk; + let k_hi = min(p.k, k_lo + p.kchunk); + let tx = tid % 16u; + let ty = tid / 16u; + var acc: array<array<f32, 4>, 4>; + for (var i = 0u; i < 4u; i++) { + for (var j = 0u; j < 4u; j++) { + acc[i][j] = 0.0; + } + } + for (var k0 = k_lo; k0 < k_hi; k0 += BK) { + // 1024 values of each tile, 4 per thread; every slot has exactly one writer + for (var e = tid; e < 1024u; e += 256u) { + // weights: neighbouring threads read neighbouring k of one row + let mm = e / BK; + let ka = k0 + e % BK; + let m = m0 + mm; + var a = 0.0; + if (ka < k_hi && m < p.m) { + a = wgt[(g * p.m + m) * p.k + ka]; + } + As[(e % BK) * BM + mm] = a; + // input: neighbouring threads read neighbouring pixels + let kk = e / BN; + let k = k0 + kk; + let pix = n0 + e % BN; + var b = 0.0; + if (k < k_hi && pix < p.n) { + b = gather(g, k, pix); + } + Bs[e] = b; + } + workgroupBarrier(); + for (var kk = 0u; kk < BK; kk++) { + var a: array<f32, 4>; + var b: array<f32, 4>; + for (var i = 0u; i < 4u; i++) { + a[i] = As[kk * BM + ty * 4u + i]; + b[i] = Bs[kk * BN + tx + 16u * i]; + } + for (var i = 0u; i < 4u; i++) { + for (var j = 0u; j < 4u; j++) { + acc[i][j] += a[i] * b[j]; + } + } + } + workgroupBarrier(); + } + for (var i = 0u; i < 4u; i++) { + let m = m0 + ty * 4u + i; + if (m >= p.m) { + continue; + } + let co = g * p.m + m; + var b0 = 0.0; + if (p.bias != 0u && p.splits == 1u) { + b0 = bias[co]; + } + // with slices, each writes a partial sum of its own: [slice][cout][n] + let base = slice * p.total + co * p.n; + for (var j = 0u; j < 4u; j++) { + // columns tx, tx + 16, tx + 32, tx + 48: neighbouring threads write neighbouring pixels + let pix = n0 + tx + 16u * j; + if (pix < p.n) { + dst[base + pix] = acc[i][j] + b0; + } + } + } +} + +@compute @workgroup_size(256) +fn direct(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) { + let at = (wg.y * p.row + wg.x) * 256u + tid; + if (at >= p.total) { + return; + } + let co = at / p.n; + let pix = at % p.n; + let g = co / p.m; + var v = 0.0; + if (p.bias != 0u) { + v = bias[co]; + } + for (var k = 0u; k < p.k; k++) { + v += wgt[co * p.k + k] * gather(g, k, pix); + } + dst[at] = v; +} + +// The sum of the K slices' partial results, plus the bias. `src` holds [splits][cout][n]. +@compute @workgroup_size(256) +fn reduce(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) { + let at = (wg.y * p.row + wg.x) * 256u + tid; + if (at >= p.total) { + return; + } + var v = 0.0; + if (p.bias != 0u) { + v = bias[at / p.n]; + } + for (var s = 0u; s < p.splits; s++) { + v += src[s * p.total + at]; + } + dst[at] = v; +} diff --git a/docs/faces.md b/docs/faces.md index 5c8e54317..f4f4a4757 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -143,6 +143,34 @@ SFace's builtin speed metadata now uses the measured 6.9× ratio. User `catalog. Older published tables for MobileFaceNet/R50/R100 came from a different runtime and are not measurements of this runner; architecture names alone do not guarantee the same speed, and those additional model files were not verified in this task. +### On the GPU + +On a machine with a usable GPU, the convolutions of the detector and the recogniser (most of their time) run on it +through wgpu (`crates/gpu/src/faces.rs`, `wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input +straight into workgroup memory, cuts K into slices when a layer's map is small, and a direct kernel for depthwise +layers). Everything else in the graph stays on the CPU interpreter, which remains the reference. After a model loads, +a background check runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when +every output is within 1e-3 of the CPU's (relative to the output's largest value) **and** it was faster; until then, +and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference), missing, +wrong or slower, the model runs on the CPU. The first GPU error drops it for that model. The weights stay on a device of +the scan's own (separate from the editor's) while the model is loaded, at most 768 MB per model. The log says which +device each model runs on. + +Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under heavy load from other +jobs (so the absolute numbers are noisy; the ratios held across three runs): + +| Model | CPU | GPU | +| --- | ---: | ---: | +| YuNet (640², one photo) | 33–34 ms | 35–39 ms | +| SFace 2021dec | 25–28 ms | 22–27 ms | +| AuraFace v1 | 204–208 ms | 81–103 ms | + +Each layer still goes to the GPU and back: for YuNet and SFace that round trip costs about what the layer saves, so on +most machines they stay on the CPU by the speed check, while AuraFace runs 2–2.5× faster. Keeping activations on the GPU +between layers (the remaining operators as kernels) and batching faces per dispatch are the next steps. +`LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks +agreement on the real models and prints these timings. + ### Agreement and regression coverage The public-crop embeddings match the reference runtime's outputs: From 61b4007c2e5b08a4e48a938236ee91d3567f3e4b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 03:37:37 +0200 Subject: [PATCH 119/315] LRC-LIB-PEOPLE: a double click on an unnamed face opens its photo in the loupe (part of #1664) --- crates/ui-egui/src/headless.rs | 50 ++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 4 +-- crates/ui-egui/src/panels/person.rs | 2 +- crates/ui-egui/src/panels/unnamed.rs | 8 ++++- crates/ui-egui/src/state.rs | 5 +-- docs/parity-checklist.md | 2 +- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 3ce1331a2..630b2aa76 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1441,6 +1441,56 @@ mod tests { assert_eq!(named(&h, ids[1], 0), None); } + /// A double click on an unnamed face opens its photo in the loupe, as in the grid (#1664); a single click still only + /// selects it, and Escape from that photo goes back to the People view. + #[test] + fn double_clicking_an_unnamed_face_opens_its_photo() { + use crate::state::ViewMode; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + for id in &ids { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0].0]}}), t); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + h.step(); + let tile = format!("unnamed-face:{}:0", ids[1].0); + // one click selects the face and stays in People + let r = h.request("ui.clickWidget", json!({"id": tile}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::People, Some(ids[0]))); + assert_eq!(h.app.ui.unnamed_selected.len(), 1); + h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); + // half a second of frames, so egui does not count the next two clicks as the third and fourth of one burst + for _ in 0..30 { + h.step(); + } + // two open the photo the face is in + let r = h.request("ui.clickWidget", json!({"id": tile, "count": 2}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); + assert_eq!(h.app.session.catalog.photo(ids[1]).unwrap().meta.regions[0].name, None, "nothing is named"); + // Escape goes back to the faces, not to the grid + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); + } + /// The name field under the unnamed faces is a shared text field: right-click ▸ Paste puts the /// clipboard's name in, and Esc leaves the field and clears the selection, as Clear does. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 141d6d782..7d66b09d4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -568,11 +568,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { app.ui.view = ViewMode::Detail; } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page goes back to that page, any other to the grid + // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid match app.ui.person_from.take() { Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { app.ui.view = ViewMode::People; - app.ui.person_page = Some(name); + app.ui.person_page = name; } _ => app.ui.view = ViewMode::PhotoGrid, } diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..d63074903 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -209,7 +209,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let _ = app.run("library.select", json!({"ids": [f.photo], "active": f.photo})); let _ = app.run("view.detail", json!({})); // Escape comes back to this page; the People button to everyone, with this person next to its title - app.ui.person_from = Some((shown_name.clone(), f.photo)); + app.ui.person_from = Some((Some(shown_name.clone()), f.photo)); app.ui.last_person = Some(shown_name.clone()); app.ui.person_page = None; } diff --git a/crates/ui-egui/src/panels/unnamed.rs b/crates/ui-egui/src/panels/unnamed.rs index 0b730db78..ff7939ee5 100644 --- a/crates/ui-egui/src/panels/unnamed.rs +++ b/crates/ui-egui/src/panels/unnamed.rs @@ -266,7 +266,13 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize name(app, &[key], who); return; } - if resp.clicked() { + // a double click opens the photo, as in the grid; its second click must not undo the first one's selection + if resp.double_clicked() { + let _ = app.run("library.select", json!({"ids": [f.photo], "active": f.photo})); + let _ = app.run("view.detail", json!({})); + // Escape comes back to the faces + app.ui.person_from = Some((None, f.photo)); + } else if resp.clicked() { let shift = ui.input(|i| i.modifiers.shift); click(app, list, i, shift); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..ff94aa1f4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -575,9 +575,10 @@ pub struct UiState { /// The person whose page the People view shows (their faces and the faces that look like them); `None`: everyone. #[serde(skip)] pub person_page: Option<String>, - /// The person page a photo in the loupe was opened from, and that photo: Escape goes back to the page (not the grid). + /// The person page a photo in the loupe was opened from (`None`: the People view's unnamed faces), and that photo: + /// Escape goes back there (not to the grid). #[serde(skip)] - pub person_from: Option<(String, u64)>, + pub person_from: Option<(Option<String>, u64)>, /// The last person page left: the People view offers a way back to it next to its title. #[serde(skip)] pub last_person: Option<String>, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..cb15d6d86 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -706,7 +706,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step; a double click opens the face's photo in the loupe, and Escape comes back. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | From ac677a26244b4ed00d6dfe4f47bbff66706e42a0 Mon Sep 17 00:00:00 2001 From: Alex Chambers <alexchambers91@gmail.com> Date: Mon, 12 Oct 2026 04:42:22 +0200 Subject: [PATCH 120/315] LR-EDIT-OPTICS-PROFILE: decode Sony shading, CA and distortion tables with settings and provenance; apply nothing new Sony ARWs record shading, lateral chromatic aberration and distortion as a camera setting plus a radial table in the raw image IFD (0x7031-0x7037), with copies in the encrypted SR2SubIFD (0x797c-0x7982). They are now decoded for every body that records them and carried through RawInfo and RawImage as `lens_metadata.sony` (types in crates/raw/src/lens_metadata.rs, decoder in crates/raw/src/vendor/arw_lens.rs): - Raw-IFD fields are the ones the TIFF reader parsed, which is what the image itself uses. A scan of the directory adds diagnostics only: repeated tags, and why an entry the reader skipped was dropped (values outside the file, an unknown type, past the reader's limits). SR2SubIFD fields are read from their first entry. Only the exact layouts are accepted: SHORT[1] or non-negative SSHORT[1] settings, SSHORT[17] headed by 16 for shading and distortion, SSHORT[33] headed by 32 for CA. Nothing is allocated from a count in the file. - The raw IFD's copy wins. The SR2SubIFD copy fills in only for a tag the raw IFD lacks, never for a malformed one, and the two copies' agreement is recorded. - Settings keep their number and their documented meaning, Unknown(value) otherwise (never Off). - Framing evidence (stored frame, Sony and default crops, reference rectangle, coding, 3:2) and a per-component status. Only Applied components become opcodes. Applied corrections are unchanged: the ILCE-7RM4A distortion conversion receives exactly the table it received before, and no other body or component gains a correction. The ILCE-7CR distortion misses its camera JPEG at 24 mm, and shading and CA semantics are not validated on any body; see docs/sony-lens-corrections.md. Locating the SR2SubIFD, now done for every ARW that has one, selects the SR2Private offset, length and key with the TIFF reader's own rules, which lightcraft-tiff now exposes and builds its reader on (directory_entries, entry_span, ParseOptions::budget_for, decode_value). Only each field's first value is decoded, so the directory's other entries cost no memory sized by the file: a 16 MiB file whose SR2Private holds eight fields aliasing it peaks at 37 kB. DirectoryEntries keeps its layout private, and its positions() iterator uses checked arithmetic. Tests: synthetic decoder tests; a differential test of the SR2Private selection against the TIFF reader's in both byte orders (0 to 8,193 filler entries, unreadable and unusual first occurrences of each field, a used-up value budget); a dhat peak-heap test; public-corpus checks against the values ExifTool prints, with pinned and anchored SHA-256s (LIGHTCRAFT_CORPUS_REQUIRED=1 makes absent files fail); and an opt-in local inventory check (crates/raw/tests/sony_lens_inventory.rs). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/engine/src/camera_preview.rs | 3 + crates/engine/src/tests_xmp.rs | 1 + crates/merge/src/output.rs | 1 + crates/raw/Cargo.toml | 2 + crates/raw/src/binned.rs | 1 + crates/raw/src/color.rs | 1 + crates/raw/src/dng.rs | 1 + crates/raw/src/lens_metadata.rs | 187 ++++ crates/raw/src/lib.rs | 12 + crates/raw/src/saturation.rs | 1 + crates/raw/src/vendor/arw.rs | 48 +- crates/raw/src/vendor/arw_lens.rs | 1101 ++++++++++++++++++++++- crates/raw/src/vendor/cr2.rs | 1 + crates/raw/src/vendor/cr2/sraw.rs | 1 + crates/raw/src/vendor/cr3.rs | 1 + crates/raw/src/vendor/kodak.rs | 1 + crates/raw/src/vendor/nef.rs | 1 + crates/raw/src/vendor/orf.rs | 1 + crates/raw/src/vendor/pef.rs | 1 + crates/raw/src/vendor/raf.rs | 1 + crates/raw/src/vendor/rw2.rs | 1 + crates/raw/src/vendor/sr2.rs | 270 +++++- crates/raw/src/vendor/srw.rs | 1 + crates/raw/tests/corpus.rs | 223 +++++ crates/raw/tests/dng.rs | 1 + crates/raw/tests/robust.rs | 1 + crates/raw/tests/sony_lens_inventory.rs | 351 ++++++++ crates/raw/tests/sr2_heap.rs | 79 ++ crates/tiff/README.md | 4 + crates/tiff/src/lib.rs | 2 +- crates/tiff/src/reader.rs | 178 +++- docs/parity-checklist.md | 3 +- docs/sony-lens-corrections.md | 206 ++++- 35 files changed, 2536 insertions(+), 154 deletions(-) create mode 100644 crates/raw/src/lens_metadata.rs create mode 100644 crates/raw/tests/sony_lens_inventory.rs create mode 100644 crates/raw/tests/sr2_heap.rs diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..65dffed24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3418,6 +3418,7 @@ dependencies = [ name = "lightcraft-raw" version = "0.7.1" dependencies = [ + "dhat", "jpeg-encoder", "jxl-oxide", "lightcraft-color", diff --git a/Cargo.toml b/Cargo.toml index 7effbe0d9..1bf57fec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" libc = "0.2" # dev-only: process CPU time in benches -dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds +dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds # formats quick-xml = "0.38" diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..5e70b58a9 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1659,6 +1659,7 @@ mod tests { linearized: true, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), + lens_metadata: Default::default(), }; let proxy = sensor_proxy(&raw, 2, 384).unwrap(); assert_eq!((proxy.width, proxy.height), (32, 32)); @@ -2002,6 +2003,7 @@ mod tests { linearized: false, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + lens_metadata: Default::default(), } } @@ -3128,6 +3130,7 @@ mod tests { linearized: true, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + lens_metadata: Default::default(), }; assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..41927181b 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -586,6 +586,7 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xm linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap() } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index f8b7e7173..e15448646 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -113,6 +113,7 @@ pub fn write_linear_dng( linearized: false, opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() }, metadata: meta, + lens_metadata: Default::default(), }; let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).map_err(|e| MergeError::Output(e.to_string())) diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..7b32cfe03 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -33,3 +33,5 @@ proptest = { workspace = true } zune-jpegxl = { workspace = true } # Pinned CC0 corpus identities; not a product dependency. sha2 = { version = "0.10", default-features = false, features = ["std"] } +# Peak-heap regression tests (tests/sr2_heap.rs); not a product dependency. +dhat = { workspace = true } diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..5eb99422b 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -165,6 +165,7 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..12c509190 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -289,6 +289,7 @@ mod tests { linearized: false, opcodes: Default::default(), metadata: Default::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 026aae90d..b076c8bc3 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -340,6 +340,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R linearized, opcodes, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/lens_metadata.rs b/crates/raw/src/lens_metadata.rs new file mode 100644 index 000000000..58fd76452 --- /dev/null +++ b/crates/raw/src/lens_metadata.rs @@ -0,0 +1,187 @@ +//! Lens-correction data a raw file records, kept with where it came from and separately from what LightCraft +//! applies. +//! +//! A readable table is not a correction. Each component carries a [`SonyLensStatus`]: only +//! [`SonyLensStatus::Applied`] components were converted into the image's `OpcodeList3`, and that is decided by +//! independent validation per model, coding and component (see `docs/sony-lens-corrections.md`), never by the mere +//! presence of a table. Everything else here is metadata for diagnostics and later validation work. + +use crate::Rect; +use serde::{Deserialize, Serialize}; + +/// Vendor lens-correction metadata of a raw file (see the module docs). Empty for formats that record none, or +/// whose tables are read straight into opcodes. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LensMetadata { + /// Sony ARW: the raw image IFD's shading, lateral chromatic aberration and distortion records, with their + /// encrypted `SR2SubIFD` copies. `None` when neither directory holds any of them. + #[serde(default)] + pub sony: Option<SonyLensCorrections>, +} + +/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table. +/// +/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032` +/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration), +/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting +/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings +/// in every file inspected. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensCorrections { + /// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not + /// established. Opaque signed data. + pub shading: SonyComponent<[i16; 16]>, + /// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue, + /// relative to green; that is not validated here. + pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>, + /// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation). + pub distortion: SonyComponent<[i16; 16]>, + /// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare. + pub sr2_read: bool, + /// The framing and coding the tables have to be interpreted against. + pub geometry: SonyLensGeometry, +} + +/// One correction component: the camera's setting, its table, and whether LightCraft applies it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyComponent<T> { + pub setting: SonyField<SonySetting>, + /// The table's samples after the leading sample count, which must equal their number. + pub table: SonyField<T>, + pub status: SonyLensStatus, +} + +/// One field read from the raw IFD and from the `SR2SubIFD`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyField<T> { + /// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD + /// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is + /// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes + /// the image parsed (its first occurrence, within the reader's limits). + pub value: Option<T>, + pub source: Option<SonySource>, + pub raw_ifd: SonyCopy, + pub sr2: SonyCopy, + /// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ. + pub copies_agree: Option<bool>, + /// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used, + /// as the TIFF reader does (the raw IFD's: the first one whose values it could read). + #[serde(default)] + pub raw_ifd_repeated: bool, + #[serde(default)] + pub sr2_repeated: bool, +} + +/// Where a value was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySource { + /// The plain raw image IFD. + RawIfd, + /// The encrypted `SR2SubIFD`. + Sr2SubIfd, +} + +/// How one copy of a field was found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyCopy { + /// Not in that directory (or the `SR2SubIFD` could not be read). + Absent, + /// Present with exactly the supported type and layout. + Valid, + /// Present, but refused. + Rejected(SonyFieldError), +} + +/// Why a present field was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyFieldError { + /// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting. + FieldType(u16), + /// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting). + Count(u64), + /// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the + /// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting. + CountPrefix(i16), + /// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`). + OutsideData, + /// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a + /// total value budget), so the image doesn't use it either. + PastReaderLimits, + /// A setting stored as a negative SSHORT. + NegativeSetting(i16), +} + +/// A camera correction setting: the stored number and its documented meaning. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SonySetting { + pub value: u16, + pub meaning: SonySettingMeaning, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole; +/// they are not read as bit masks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySettingMeaning { + /// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent. + Off, + /// Shading 257 or 272, CA 1, distortion 1. + Auto, + /// Distortion 17: Auto, fixed by the lens. + ForcedAuto, + /// "No correction params available": shading 511, CA 255, distortion 255. + Unavailable, + /// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off. + Unknown(u16), +} + +/// Whether a component is applied, and if not, why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyLensStatus { + /// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A Bayer distortion in native 3:2 framing). + Applied, + /// No usable table. + NoTable, + /// Decoded only: no independent validation covers this component's rendering semantics on any body (shading, + /// lateral CA). + NotValidatedComponent, + /// Decoded only: the component is validated on other camera models, not on this one. + NotValidatedModel, + /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a + /// table found only in the `SR2SubIFD`. + NotValidatedMode, + /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. + Neutral, + /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate + /// crop, or a fitted map that misses the table or folds). + Rejected, +} + +/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensGeometry { + /// The stored raw frame, including any padding (lossless L files are padded). + pub storage_width: usize, + pub storage_height: usize, + /// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded. + pub sony_crop: Option<Rect>, + /// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded. + pub default_crop: Option<Rect>, + /// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size). + pub image_crop: Rect, + /// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when + /// the file has one, else the image crop. Clipped to the frame. + pub reference: Rect, + /// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6). + pub compression: u16, + /// Downsized lossless M/S data (linear YCbCr, not sensor CFA). + pub linear_ycbcr: bool, + /// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated. + pub native_3_2: bool, +} + +impl<T> SonyField<T> { + /// Whether either directory holds the tag (well-formed or not). + pub fn is_present(&self) -> bool { + self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0f366648c..29ace7025 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -35,6 +35,7 @@ pub mod highlight; #[cfg(feature = "jxl")] mod jxl; pub mod kelvin_presets; +pub mod lens_metadata; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -50,6 +51,7 @@ mod vendor; pub use demosaic::{Method, demosaic}; pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; +pub use lens_metadata::LensMetadata; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; @@ -415,6 +417,9 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]). + #[serde(default)] + pub lens_metadata: LensMetadata, } impl RawInfo { @@ -648,6 +653,10 @@ pub struct RawImage { pub linearized: bool, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]); only + /// validated components are in `opcodes`. + #[serde(default)] + pub lens_metadata: LensMetadata, } /// Black-subtracted, white-normalised data covering the active area. @@ -679,6 +688,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + lens_metadata: self.lens_metadata.clone(), } } @@ -698,6 +708,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + lens_metadata: self.lens_metadata, } } @@ -862,6 +873,7 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), }; let direct = raw.develop(Method::Bilinear).unwrap(); let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..261d5023b 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -137,6 +137,7 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..7a4e0cc45 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -44,8 +44,9 @@ //! through the generic TIFF path. use super::arw6; +use crate::lens_metadata::SonyLensGeometry; use crate::tiffraw::{Packing, check_image, read_image_in}; -use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use crate::{BlackLevel, Cfa, ColorData, LensMetadata, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes}; use lightcraft_tiff::tags::{self as t, photometric}; @@ -552,6 +553,14 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option<Rect> { None } +/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`. +fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option<Rect> { + let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None }; + let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None }; + let n = |v: u64| usize::try_from(v).ok(); + Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?)) +} + /// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style /// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). /// @@ -748,6 +757,25 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); + // The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has + // one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and + // the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area + // coordinates, so it stays on the optical centre whichever crop frames the image. + let reference = dng_default_crop(raw, w, h).unwrap_or(crop); + let geometry = SonyLensGeometry { + storage_width: w, + storage_height: h, + sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE), + default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE), + image_crop: crop, + reference, + compression: info.compression, + linear_ycbcr: linear_rgb, + native_3_2: super::arw_lens::is_3_2(crop) && super::arw_lens::is_3_2(reference), + }; + // decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by + // reading only three `SR2Private` entries (see `sr2::SubIfd::read`) + let (sony_lens, warp) = super::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry); let img = RawImage { format: RawFormat::Arw, width: w, @@ -767,22 +795,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, - // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A - // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was - // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so - // it stays on the optical centre whichever crop frames the image. - opcodes: OpcodeLists { - // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. - // Both the image framing and the table geometry must stay within the validated 3:2 scope. - list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { - Vec::new() - } else { - let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); - super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), geometry).into_iter().collect() - }, - ..Default::default() - }, + // only validated components become opcodes (see `arw_lens`) + opcodes: OpcodeLists { list3: warp.into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: LensMetadata { sony: sony_lens }, }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index f4e02d69d..aa7313e23 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -1,28 +1,348 @@ -//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline. +//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one +//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline. //! -//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts). -//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md. -//! No lens database or external decoder code. The unrelated MakerNote table has a different scale. +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and +//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags +//! `0x797c`, `0x797f` and `0x7981` sit next to those tables, unnamed there; they equal the raw IFD's settings in every +//! file inspected. Geometry inferred independently from files and Sony Imaging Edge exports; see +//! docs/sony-lens-corrections.md. No lens database and no external decoder code. The MakerNote's similarly named +//! tables have another scale and layout and are never read here. +//! +//! A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses (its first occurrence, within +//! the reader's limits). The directory is also scanned, for diagnostics only: whether a tag is repeated, and why an +//! entry the reader left out was dropped (values outside the file, an unknown type, or past the reader's limits). +//! `SR2SubIFD` fields are read straight from their first entry. Only the exact layouts are accepted: SHORT\[1\] (or +//! nonnegative SSHORT\[1\]) settings, SSHORT\[17\] headed by 16 for shading and distortion, SSHORT\[33\] headed by +//! 32 for CA. Nothing is allocated from a count. The raw IFD's copy wins; the `SR2SubIFD`'s is used only when the raw +//! IFD lacks the tag, and a malformed raw-IFD copy is reported, not replaced. Decoding grants nothing: only components +//! whose interpretation was independently validated for the model and mode are converted ([`SonyLensStatus`]), and +//! the conversion receives exactly the table the decoder applied before this record existed. +use super::sr2::SubIfd; +use crate::lens_metadata::{ + SonyComponent, SonyCopy, SonyField, SonyFieldError, SonyLensCorrections, SonyLensGeometry, SonyLensStatus, SonySetting, SonySettingMeaning, + SonySource, +}; use crate::{Opcode, Rect}; -use lightcraft_tiff::{Ifd, Value}; +use lightcraft_tiff::{ByteOrder, FieldType, Ifd, Value}; -const DISTORTION: u16 = 0x7037; +/// Each component's setting and table tags, `(raw IFD, SR2SubIFD)`. +const SHADING: Tags = Tags { setting: (0x7031, 0x797c), table: (0x7032, 0x797d) }; +const CA: Tags = Tags { setting: (0x7034, 0x797f), table: (0x7035, 0x7980) }; +const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x7982) }; +/// TIFF field types. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; +/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A +/// are distinct models, not interchangeable aliases. +const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"]; +/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. +const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; -/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts -/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md. -/// A camera setting of Off does not remove the lens data: the user can enable it in the developer. -pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> { +#[derive(Clone, Copy)] +struct Tags { + setting: (u16, u16), + table: (u16, u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Shading, + Ca, + Distortion, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation), compared whole. +fn meaning(kind: Kind, value: u16) -> SonySettingMeaning { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + match (kind, value) { + (Shading, 256) | (Ca | Distortion, 0) => Off, + (Shading, 257 | 272) | (Ca | Distortion, 1) => Auto, + (Distortion, 17) => ForcedAuto, + (Shading, 511) | (Ca | Distortion, 255) => Unavailable, + _ => Unknown(value), + } +} + +/// One copy of a field. +#[derive(Clone, Debug, PartialEq)] +enum Read<T> { + Absent, + Valid(T), + Rejected(SonyFieldError), +} + +impl<T> Read<T> { + fn map<U>(self, f: impl FnOnce(T) -> U) -> Read<U> { + match self { + Read::Absent => Read::Absent, + Read::Valid(v) => Read::Valid(f(v)), + Read::Rejected(e) => Read::Rejected(e), + } + } + fn copy(&self) -> SonyCopy { + match self { + Read::Absent => SonyCopy::Absent, + Read::Valid(_) => SonyCopy::Valid, + Read::Rejected(e) => SonyCopy::Rejected(*e), + } + } +} + +/// One copy of a field, and whether its directory holds the tag more than once. +struct Found<T> { + read: Read<T>, + repeated: bool, +} + +impl<T> Found<T> { + fn map<U>(self, f: impl FnOnce(T) -> U) -> Found<U> { + Found { read: self.read.map(f), repeated: self.repeated } + } +} + +/// A TIFF directory whose out-of-line values sit at `offset - base` in `data`: the raw IFD in the file (`base` 0), or +/// the decrypted `SR2SubIFD`, whose offsets are file offsets into the block itself. +struct Directory<'a> { + /// From the entry count on. + entries: &'a [u8], + data: &'a [u8], + base: usize, + order: ByteOrder, + /// BigTIFF layout: 8-byte entry count, 20-byte entries with an 8-byte count and value field. + big: bool, +} + +impl<'a> Directory<'a> { + /// The first entry of `tag`, and whether another follows. Only the first counts, as in the TIFF reader and + /// `sr2.rs`. Reads at most the entries the data holds, and at most [`MAX_ENTRIES`]. + fn entry(&self, tag: u16) -> (Option<&'a [u8]>, bool) { + let o = self.order; + let (n, head, size) = if self.big { (o.read_u64(self.entries, 0), 8, 20) } else { (o.read_u16(self.entries, 0).map(u64::from), 2, 12) }; + let Some(n) = n else { return (None, false) }; + let mut found = None; + for i in 0..usize::try_from(n).unwrap_or(usize::MAX).min(MAX_ENTRIES) { + let at = i.checked_mul(size).and_then(|at| at.checked_add(head)); + let Some(e) = at.and_then(|at| self.entries.get(at..at.checked_add(size)?)) else { break }; + if o.read_u16(e, 0) == Some(tag) { + if found.is_some() { + return (found, true); + } + found = Some(e); + } + } + (found, false) + } + + /// Field type and count of entry `e`. + fn field(&self, e: &[u8]) -> (u16, u64) { + let o = self.order; + let count = if self.big { o.read_u64(e, 4) } else { o.read_u32(e, 4).map(u64::from) }; + (o.read_u16(e, 2).unwrap_or(0), count.unwrap_or(u64::MAX)) + } + + /// `len` value bytes of entry `e`: inline when they fit the value field, else at the entry's offset. + fn values(&self, e: &'a [u8], len: usize) -> Option<&'a [u8]> { + let (field, width) = if self.big { (12, 8) } else { (8, 4) }; + if len <= width { + return e.get(field..field + len); + } + let offset = if self.big { self.order.read_u64(e, field)? } else { u64::from(self.order.read_u32(e, field)?) }; + let at = usize::try_from(offset).ok()?.checked_sub(self.base)?; + self.data.get(at..at.checked_add(len)?) + } + + /// Why the TIFF reader left out entry `e`, which the directory holds: a field type it doesn't know, values outside + /// the data, or else its own limits (the entries it reads per directory, its value budget). + fn unread(&self, e: &'a [u8]) -> SonyFieldError { + let (kind, count) = self.field(e); + let Some(size) = FieldType::from_u16(kind).map(FieldType::size) else { return SonyFieldError::FieldType(kind) }; + match count.checked_mul(size as u64).and_then(|len| usize::try_from(len).ok()).and_then(|len| self.values(e, len)) { + Some(_) => SonyFieldError::PastReaderLimits, + None => SonyFieldError::OutsideData, + } + } + + /// A radial table of exactly `N` samples in entry `e`: SSHORT\[N + 1\], the first value `N`. + fn table<const N: usize>(&self, e: &'a [u8]) -> Read<[i16; N]> { + let (kind, count) = self.field(e); + if kind != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(kind)); + } + if count != (N + 1) as u64 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(bytes) = self.values(e, 2 * (N + 1)) else { return Read::Rejected(SonyFieldError::OutsideData) }; + let at = |i: usize| self.order.read_u16(bytes, 2 * i).map_or(0, |v| v as i16); + let prefix = at(0); + if usize::try_from(prefix).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(prefix)); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) + } + + /// A setting in entry `e`: SHORT\[1\], or SSHORT\[1\] when not negative. + fn setting(&self, e: &'a [u8], kind: Kind) -> Read<SonySetting> { + let (field_type, count) = self.field(e); + if field_type != SHORT && field_type != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(field_type)); + } + if count != 1 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(value) = self.values(e, 2).and_then(|b| self.order.read_u16(b, 0)) else { + return Read::Rejected(SonyFieldError::OutsideData); + }; + if field_type == SSHORT && (value as i16) < 0 { + return Read::Rejected(SonyFieldError::NegativeSetting(value as i16)); + } + Read::Valid(SonySetting { value, meaning: meaning(kind, value) }) + } +} + +/// A table as the TIFF reader parsed it: SSHORT\[N + 1\], the first value `N`. +fn parsed_table<const N: usize>(value: &Value) -> Read<[i16; N]> { + let Value::SShort(v) = value else { return Read::Rejected(SonyFieldError::FieldType(value.field_type() as u16)) }; + if v.len() != N + 1 { + return Read::Rejected(SonyFieldError::Count(v.len() as u64)); + } + let at = |i: usize| v.get(i).copied().unwrap_or(0); + if usize::try_from(at(0)).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(at(0))); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) +} + +/// A setting as the TIFF reader parsed it: SHORT\[1\], or SSHORT\[1\] when not negative (the documented and the stored +/// type differ). +fn parsed_setting(value: &Value, kind: Kind) -> Read<SonySetting> { + let number = match value { + Value::Short(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::SShort(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::Short(v) => v.first().copied(), + Value::SShort(v) => match v.first().copied() { + Some(n) if n < 0 => return Read::Rejected(SonyFieldError::NegativeSetting(n)), + n => n.map(|n| n as u16), + }, + other => return Read::Rejected(SonyFieldError::FieldType(other.field_type() as u16)), + }; + number.map_or(Read::Rejected(SonyFieldError::Count(0)), |value| Read::Valid(SonySetting { value, meaning: meaning(kind, value) })) +} + +/// The raw IFD's copy of `tag`: the one the TIFF reader parsed, which is what the image itself uses. The directory +/// itself only adds diagnostics: whether the tag is repeated, and why an entry the reader left out was dropped. +fn raw_copy<T>(ifd: &Ifd, dir: Option<&Directory<'_>>, tag: u16, read: impl Fn(&Value) -> Read<T>) -> Found<T> { + let (entry, repeated) = dir.map_or((None, false), |d| d.entry(tag)); + let read = match (ifd.get(tag), entry.zip(dir)) { + (Some(parsed), _) => read(&parsed.value), + (None, Some((e, d))) => Read::Rejected(d.unread(e)), + (None, None) => Read::Absent, + }; + Found { read, repeated } +} + +/// The `SR2SubIFD`'s copy of `tag`, read straight from its first entry. +fn sr2_copy<'a, T>(dir: Option<&Directory<'a>>, tag: u16, read: impl Fn(&Directory<'a>, &'a [u8]) -> Read<T>) -> Found<T> { + let Some(d) = dir else { return Found { read: Read::Absent, repeated: false } }; + let (entry, repeated) = d.entry(tag); + Found { read: entry.map_or(Read::Absent, |e| read(d, e)), repeated } +} + +/// One field from its two copies: the raw IFD's when well-formed, else the `SR2SubIFD`'s when the raw IFD lacks the +/// tag. +fn combine<T: PartialEq>(raw: Found<T>, sr2: Found<T>) -> SonyField<T> { + let (raw_ifd, sr2_copy) = (raw.read.copy(), sr2.read.copy()); + let copies_agree = match (&raw.read, &sr2.read) { + (Read::Valid(a), Read::Valid(b)) => Some(a == b), + _ => None, + }; + let (value, source) = match (raw.read, sr2.read) { + (Read::Valid(v), _) => (Some(v), Some(SonySource::RawIfd)), + (Read::Absent, Read::Valid(v)) => (Some(v), Some(SonySource::Sr2SubIfd)), + _ => (None, None), + }; + SonyField { value, source, raw_ifd, sr2: sr2_copy, copies_agree, raw_ifd_repeated: raw.repeated, sr2_repeated: sr2.repeated } +} + +/// Whether `r` has the native 3:2 framing, within 0.5 %. +pub(super) fn is_3_2(r: Rect) -> bool { + r.height > 0 && (r.width as f64 / r.height as f64 - 1.5).abs() <= 0.005 +} + +/// Decode the file's record and the validated distortion warp. `raw` is the raw image IFD as the TIFF reader parsed +/// it (in a BigTIFF file when `big`), `sr2` the decrypted `SR2SubIFD`. The record is `None` when neither directory +/// holds any of the six fields. +pub(super) fn corrections( + bytes: &[u8], + order: ByteOrder, + (raw, big): (&Ifd, bool), + sr2: Option<&SubIfd>, + model: &str, + geometry: SonyLensGeometry, +) -> (Option<SonyLensCorrections>, Option<Opcode>) { + let plain = usize::try_from(raw.offset).ok().and_then(|at| Some(Directory { entries: bytes.get(at..)?, data: bytes, base: 0, order, big })); + let encrypted = sr2.map(|s| { + let (block, start) = s.block(); + Directory { entries: block, data: block, base: start, order: s.order(), big: false } + }); + let (p, s) = (plain.as_ref(), encrypted.as_ref()); + // CA: two contiguous 16-sample curves after the count + let split = |v: [i16; 32]| -> [[i16; 16]; 2] { std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) }; + let table16 = |tags: (u16, u16)| combine(raw_copy(raw, p, tags.0, parsed_table::<16>), sr2_copy(s, tags.1, |d, e| d.table::<16>(e))); + let shading_table = table16(SHADING.table); + let distortion_table = table16(DISTORTION.table); + let ca_table = combine(raw_copy(raw, p, CA.table.0, parsed_table::<32>).map(split), sr2_copy(s, CA.table.1, |d, e| d.table::<32>(e)).map(split)); + let setting = |tags: Tags, kind: Kind| { + combine(raw_copy(raw, p, tags.setting.0, |v| parsed_setting(v, kind)), sr2_copy(s, tags.setting.1, |d, e| d.setting(e, kind))) + }; + let settings = [setting(SHADING, Kind::Shading), setting(CA, Kind::Ca), setting(DISTORTION, Kind::Distortion)]; + if !shading_table.is_present() && !ca_table.is_present() && !distortion_table.is_present() && !settings.iter().any(SonyField::is_present) { + return (None, None); + } + let [shading_setting, ca_setting, distortion_setting] = settings; + // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer + // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags + // select an aspect crop). + let validated_model = DISTORTION_MODELS.contains(&model); + let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd); + let (w, h) = (geometry.storage_width, geometry.storage_height); + let warp = match &distortion_table.value { + Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + _ => None, + }; + let distortion_status = match &distortion_table.value { + None => SonyLensStatus::NoTable, + Some(_) if warp.is_some() => SonyLensStatus::Applied, + Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, + Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, + Some(_) => SonyLensStatus::Rejected, + }; + // No independent validation of shading or CA semantics exists on any body: decoded, never applied. + let unvalidated = |present: bool| if present { SonyLensStatus::NotValidatedComponent } else { SonyLensStatus::NoTable }; + let record = SonyLensCorrections { + shading: SonyComponent { status: unvalidated(shading_table.value.is_some()), setting: shading_setting, table: shading_table }, + chromatic_aberration: SonyComponent { status: unvalidated(ca_table.value.is_some()), setting: ca_setting, table: ca_table }, + distortion: SonyComponent { setting: distortion_setting, table: distortion_table, status: distortion_status }, + sr2_read: sr2.is_some(), + geometry, + }; + (Some(record), warp) +} + +/// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated +/// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. +/// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it +/// in the developer. +fn distortion(model: &str, table: &[i16; 16], active: Rect, crop: Rect) -> Option<Opcode> { // A matching table shape does not establish its scale or radial normalization on another body. - // Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. - if model != "ILCE-7RM4A" { + if !DISTORTION_MODELS.contains(&model) { return None; } - let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; - let [16, table @ ..] = values.as_slice() else { return None }; - let table: &[i16; 16] = table.try_into().ok()?; if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { return None; } @@ -122,22 +442,720 @@ fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { #[cfg(test)] mod tests { use super::*; - use lightcraft_tiff::Entry; + use crate::lens_metadata::SonyCopy::{Absent, Rejected, Valid}; + use crate::lens_metadata::SonyFieldError::*; + use crate::{LensMetadata, Mode, RawImage}; + use lightcraft_tiff::ByteOrder::{Big, Little}; + use lightcraft_tiff::{IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; - fn ifd(value: Value) -> Ifd { - Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() } - } - fn table(values: &[i16]) -> Ifd { - ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect())) - } const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). + const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + + /// A CA table with negative and positive samples and two different curves. + fn ca() -> [i16; 32] { + std::array::from_fn(|i| (i as i16 - 16) * 128) + } + fn ca_curves() -> [[i16; 16]; 2] { + let v = ca(); + std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) + } + fn sshort(prefix: i16, samples: &[i16]) -> Value { + Value::SShort(std::iter::once(prefix).chain(samples.iter().copied()).collect()) + } + /// The six raw-IFD fields: shading 272 (Auto), CA 1 (Auto), and the distortion `setting` and `table`. + fn plain(table: &[i16; 16], setting: u16) -> Vec<(u16, Value)> { + vec![ + (SHADING.setting.0, Value::Short(vec![272])), + (SHADING.table.0, sshort(16, &SHADING_T)), + (CA.setting.0, Value::Short(vec![1])), + (CA.table.0, sshort(32, &ca())), + (DISTORTION.setting.0, Value::Short(vec![setting])), + (DISTORTION.table.0, sshort(16, table)), + ] + } + /// The same fields as `SR2SubIFD` entries `(tag, type, values)`. + fn encrypted(table: &[i16; 16], setting: u16) -> Vec<(u16, u16, Vec<u16>)> { + let t = |prefix: i16, v: &[i16]| std::iter::once(prefix).chain(v.iter().copied()).map(|x| x as u16).collect::<Vec<u16>>(); + vec![ + (SHADING.setting.1, SHORT, vec![272]), + (SHADING.table.1, SSHORT, t(16, &SHADING_T)), + (CA.setting.1, SHORT, vec![1]), + (CA.table.1, SSHORT, t(32, &ca())), + (DISTORTION.setting.1, SHORT, vec![setting]), + (DISTORTION.table.1, SSHORT, t(16, table)), + ] + } + fn set(fields: &mut Vec<(u16, Value)>, tag: u16, value: Value) { + fields.retain(|(t, _)| *t != tag); + fields.push((tag, value)); + } + fn word(order: ByteOrder, v: u32) -> Vec<u8> { + let mut out = Vec::new(); + order.put_u32(&mut out, v); + out + } + + /// A small uncompressed Sony CFA file (the raw IFD is IFD0) with the lens fields `plain` in its raw IFD and, when + /// `sr2` is set, an encrypted `SR2SubIFD` holding those entries, reached as Sony stores it: `DNGPrivateData` -> + /// `SR2Private` IFD -> block. + struct Arw { + model: &'static str, + order: ByteOrder, + bigtiff: bool, + size: (u32, u32), + sony_crop: Option<[u32; 4]>, + default_crop: Option<[u32; 4]>, + plain: Vec<(u16, Value)>, + sr2: Option<Vec<(u16, u16, Vec<u16>)>>, + /// The key stored in `SR2SubIFDKey`; the block is always encrypted with the usual one. + stored_key: [u8; 4], + /// An edit to the decrypted `SR2SubIFD` before it is encrypted. + sr2_patch: Option<Box<dyn Fn(&mut [u8])>>, + } + + impl Arw { + fn new(model: &'static str, order: ByteOrder) -> Arw { + Arw { + model, + order, + bigtiff: false, + size: (32, 24), + sony_crop: Some([2, 2, 30, 20]), + default_crop: Some([2, 2, 30, 20]), + plain: plain(&TELE, 0), + sr2: Some(encrypted(&TELE, 0)), + stored_key: KEY, + sr2_patch: None, + } + } + + fn build(&self) -> Vec<u8> { + let (w, h) = self.size; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(self.model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + if let Some([x, y, cw, ch]) = self.sony_crop { + raw.set(0x74c7, Value::Long(vec![x, y])); + raw.set(0x74c8, Value::Long(vec![cw, ch])); + } + if let Some([x, y, cw, ch]) = self.default_crop { + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![x, y])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![cw, ch])); + } + if self.sr2.is_some() { + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + } + for (tag, value) in &self.plain { + raw.set(*tag, value.clone()); + } + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0; (w * h * 2) as usize]] }); + let mut file = TiffWriter::new(self.order, self.bigtiff).write(&[raw]).unwrap(); + let Some(entries) = &self.sr2 else { return file }; + let o = self.order; + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let refs: Vec<(u16, u16, &[u16])> = entries.iter().map(|(tag, kind, v)| (*tag, *kind, v.as_slice())).collect(); + let mut block = super::super::sr2::tests::block(start, &refs, o); + if let Some(patch) = &self.sr2_patch { + super::super::sr2::tests::encrypt(&mut block); + patch(&mut block); + super::super::sr2::tests::encrypt(&mut block); + } + o.put_u16(&mut file, 3); + for (tag, value) in [(0x7200u16, start as u32), (0x7201, block.len() as u32)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, 4); + o.put_u32(&mut file, 1); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend(self.stored_key); + o.put_u32(&mut file, 0); + file.extend(block); + file[at..at + 4].copy_from_slice(&word(o, private as u32)); + file + } + } + + /// Byte position of IFD0's entry for `tag` in a classic TIFF `file`. + fn entry_at(file: &[u8], order: ByteOrder, tag: u16) -> usize { + let ifd = order.read_u32(file, 4).unwrap() as usize; + let n = usize::from(order.read_u16(file, ifd).unwrap()); + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| order.read_u16(file, e) == Some(tag)).unwrap() + } + + /// Header and full decode of `file`, which must describe the same image; the full one. + fn decode(file: &[u8]) -> RawImage { + let header = super::super::arw::decode(file, Mode::Header).unwrap(); + let full = super::super::arw::decode(file, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + full + } + fn sony(img: &RawImage) -> &SonyLensCorrections { + img.lens_metadata.sony.as_ref().expect("a Sony lens record") + } + /// (has a value, source, raw-IFD copy, SR2SubIFD copy, agreement) of one field. + fn prov<T>(f: &SonyField<T>) -> (bool, Option<SonySource>, SonyCopy, SonyCopy, Option<bool>) { + (f.value.is_some(), f.source, f.raw_ifd, f.sr2, f.copies_agree) + } + /// [`prov`] of the three settings, then the three tables. + fn provenance(s: &SonyLensCorrections) -> [(bool, Option<SonySource>, SonyCopy, SonyCopy, Option<bool>); 6] { + [ + prov(&s.shading.setting), + prov(&s.chromatic_aberration.setting), + prov(&s.distortion.setting), + prov(&s.shading.table), + prov(&s.chromatic_aberration.table), + prov(&s.distortion.table), + ] + } + + #[test] + fn decodes_all_three_components_with_settings_and_provenance() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + for bigtiff in [false, true] { + for (model, warps, distortion_status) in + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + { + let case = format!("{model} {order:?} bigtiff={bigtiff}"); + let mut arw = Arw::new(model, order); + arw.bigtiff = bigtiff; + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(img.opcodes.list3.len(), warps, "{case}"); + assert!(s.sr2_read, "{case}"); + assert_eq!(provenance(s), [(true, raw, Valid, Valid, Some(true)); 6], "{case}"); + assert_eq!(s.shading.table.value, Some(SHADING_T), "{case}"); + assert_eq!(s.chromatic_aberration.table.value, Some(ca_curves()), "{case}"); + assert_eq!(s.distortion.table.value, Some(TELE), "{case}"); + assert_eq!(s.shading.setting.value, Some(SonySetting { value: 272, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.chromatic_aberration.setting.value, Some(SonySetting { value: 1, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + // readable shading and CA tables are not corrections + assert_eq!(s.shading.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.chromatic_aberration.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.distortion.status, distortion_status, "{case}"); + let crop = Rect::new(2, 2, 30, 20); + let geometry = SonyLensGeometry { + storage_width: 32, + storage_height: 24, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 1, + linear_ycbcr: false, + native_3_2: true, + }; + assert_eq!(s.geometry, geometry, "{case}"); + // the applied warp is the validated conversion of the raw IFD's table, as before + let reference: Vec<Opcode> = distortion(model, &TELE, Rect::new(0, 0, 32, 24), crop).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference, "{case}"); + // DNG export keeps the warp and invents no Sony record + let dng = crate::write_dng(&img, &Default::default()).unwrap(); + let back = crate::decode(&dng).unwrap(); + assert_eq!(back.opcodes.list3, img.opcodes.list3, "{case}"); + assert_eq!(back.lens_metadata, LensMetadata::default(), "{case}"); + } + } + } + } + + #[test] + fn settings_keep_their_number_and_unknown_values_are_never_off() { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + for (kind, value, expected) in [ + (Shading, 256, Off), + (Shading, 257, Auto), + (Shading, 272, Auto), + (Shading, 511, Unavailable), + (Shading, 0, Unknown(0)), + (Shading, 1, Unknown(1)), + (Shading, 255, Unknown(255)), + (Shading, 258, Unknown(258)), + (Shading, 273, Unknown(273)), + (Ca, 0, Off), + (Ca, 1, Auto), + (Ca, 255, Unavailable), + (Ca, 2, Unknown(2)), + (Ca, 17, Unknown(17)), + (Ca, 256, Unknown(256)), + (Distortion, 0, Off), + (Distortion, 1, Auto), + (Distortion, 17, ForcedAuto), + (Distortion, 255, Unavailable), + (Distortion, 2, Unknown(2)), + (Distortion, 16, Unknown(16)), + (Distortion, 256, Unknown(256)), + (Distortion, u16::MAX, Unknown(u16::MAX)), + ] { + assert_eq!(meaning(kind, value), expected, "{kind:?} {value}"); + } + for order in [Little, Big] { + let read = |value: Value| { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + set(&mut arw.plain, DISTORTION.setting.0, value); + sony(&decode(&arw.build())).distortion.setting.clone() + }; + assert_eq!(read(Value::Short(vec![17])).value, Some(SonySetting { value: 17, meaning: ForcedAuto })); + assert_eq!(read(Value::Short(vec![9])).value, Some(SonySetting { value: 9, meaning: Unknown(9) })); + // the documented editor type (SSHORT) is accepted when not negative + assert_eq!(read(Value::SShort(vec![1])).value, Some(SonySetting { value: 1, meaning: Auto })); + for (value, error) in [ + (Value::SShort(vec![-1]), NegativeSetting(-1)), + (Value::SShort(vec![i16::MIN]), NegativeSetting(i16::MIN)), + (Value::Long(vec![1]), FieldType(4)), + (Value::Ascii("1".into()), FieldType(2)), + (Value::Short(vec![1, 1]), Count(2)), + (Value::Short(vec![0; 40]), Count(40)), + ] { + let f = read(value.clone()); + assert_eq!(prov(&f), (false, None, Rejected(error), Absent, None), "{value:?} {order:?}"); + } + } + } + + #[test] + fn malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy() { + let d = DISTORTION.table.0; + let cases = [ + (d, sshort(16, &[]), Count(1)), + (d, Value::SShort(vec![16, 0]), Count(2)), + (d, sshort(16, &TELE[..15]), Count(16)), + (d, Value::SShort(std::iter::once(16).chain(TELE).chain([0]).collect()), Count(18)), + (d, Value::SShort(vec![15; 17]), CountPrefix(15)), + (d, sshort(-16, &TELE), CountPrefix(-16)), + (d, sshort(0, &TELE), CountPrefix(0)), + (d, Value::Short(vec![16; 17]), FieldType(3)), + (d, Value::Long(vec![16; 17]), FieldType(4)), + (d, Value::Double(vec![f64::NAN; 17]), FieldType(12)), + (d, Value::Undefined(vec![16; 34]), FieldType(7)), + (SHADING.table.0, sshort(32, &[0; 32]), Count(33)), + (SHADING.table.0, Value::Short(std::iter::once(16).chain([0; 16]).collect()), FieldType(3)), + (CA.table.0, sshort(16, &ca()[..16]), Count(17)), + (CA.table.0, sshort(16, &ca()), CountPrefix(16)), + (CA.table.0, sshort(33, &ca()), CountPrefix(33)), + ]; + for order in [Little, Big] { + for (tag, value, error) in &cases { + let mut arw = Arw::new("ILCE-7RM4A", order); + set(&mut arw.plain, *tag, value.clone()); + let img = decode(&arw.build()); + let s = sony(&img); + let field = if *tag == SHADING.table.0 { + prov(&s.shading.table) + } else if *tag == CA.table.0 { + prov(&s.chromatic_aberration.table) + } else { + prov(&s.distortion.table) + }; + // the SR2SubIFD's good copy does not hide the corruption + assert_eq!(field, (false, None, Rejected(*error), Valid, None), "{tag:#x} {value:?} {order:?}"); + // the validated distortion needs its own raw-IFD table; a bad unrelated table doesn't remove it + assert_eq!(img.opcodes.list3.len(), usize::from(*tag != d), "{tag:#x} {value:?}"); + assert_eq!(s.distortion.status == SonyLensStatus::NoTable, *tag == d); + } + } + } + + #[test] + fn counts_and_offsets_beyond_the_data_are_reported_without_reading_them() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let e = entry_at(&good, order, DISTORTION.table.0); + let len = good.len() as u32; + for (field, value, error) in [ + // counts: the TIFF reader drops an entry whose values would pass the end of the file + (4, 0x7fff_ffff, OutsideData), + (4, u32::MAX, OutsideData), + (4, 0, Count(0)), + (4, 16, Count(16)), + // value offsets past the end, straddling it, at it + (8, u32::MAX, OutsideData), + (8, len - 10, OutsideData), + (8, len, OutsideData), + ] { + let mut file = good.clone(); + file[e + field..e + field + 4].copy_from_slice(&word(order, value)); + let img = decode(&file); + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(error), Valid, None), "{field} {value} {order:?}"); + assert!(img.opcodes.list3.is_empty()); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + assert_eq!(s.shading.table.value, Some(SHADING_T)); + } + } + } + + #[test] + fn a_table_past_the_tiff_readers_entry_limit_is_not_applied() { + // The TIFF reader that decodes the image reads 8,192 entries of a directory; with 8,200 filler entries ahead + // of them the image never sees the lens fields, so they must not become a correction (main applies none). + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.size, arw.sony_crop, arw.default_crop, arw.sr2) = ((30, 20), None, None, None); + let reference = decode(&arw.build()); + assert_eq!(reference.opcodes.list3.len(), 1, "control: the same file without filler is corrected"); + arw.plain = (0..8200u16).map(|i| (0x5000 + i, Value::Byte(vec![0]))).chain(plain(&TELE, 0)).collect(); + let img = decode(&arw.build()); + assert!(img.opcodes.list3.is_empty(), "{order:?}: a table the TIFF reader never read was applied"); + // the directory scan still finds and reports it + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(PastReaderLimits), Absent, None)); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + } + } + + #[test] + fn a_repeated_tag_keeps_the_copy_the_tiff_reader_keeps() { + let crop = Rect::new(2, 2, 30, 20); + let warp = |table: &[i16; 16]| -> Vec<Opcode> { distortion("ILCE-7RM4A", table, Rect::new(0, 0, 32, 24), crop).into_iter().collect() }; + for order in [Little, Big] { + // a second distortion table (WIDE) right after the first (TELE), under a placeholder tag renamed to 0x7037 + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.push((0x7040, sshort(16, &WIDE))); + let good = arw.build(); + let mut tag = Vec::new(); + order.put_u16(&mut tag, DISTORTION.table.0); + let mut twice = good.clone(); + let e = entry_at(&twice, order, 0x7040); + twice[e..e + 2].copy_from_slice(&tag); + // both valid: the first one, as the TIFF reader keeps it and main applies it + let img = decode(&twice); + assert_eq!(img.opcodes.list3, warp(&TELE), "{order:?}: both copies valid"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + // reported as a diagnostic, which changes nothing else + let f = &sony(&img).distortion.table; + assert_eq!((f.raw_ifd_repeated, f.sr2_repeated, f.raw_ifd, f.copies_agree), (true, false, Valid, Some(true))); + assert_eq!(sony(&img).distortion.status, SonyLensStatus::Applied); + assert!(!sony(&decode(&good)).distortion.table.raw_ifd_repeated); + // the first one's values outside the file: the TIFF reader drops it and keeps the second + let mut dropped = twice.clone(); + let first = entry_at(&dropped, order, DISTORTION.table.0); + dropped[first + 8..first + 12].copy_from_slice(&word(order, u32::MAX)); + let img = decode(&dropped); + assert_eq!(img.opcodes.list3, warp(&WIDE), "{order:?}: first copy unreadable"); + assert_eq!(sony(&img).distortion.table.value, Some(WIDE)); + assert!(sony(&img).distortion.table.raw_ifd_repeated); + // the first one malformed (the setting's SHORT entry renamed): the TIFF reader keeps it, nothing applies + let mut short = good.clone(); + let e = entry_at(&short, order, DISTORTION.setting.0); + short[e..e + 2].copy_from_slice(&tag); + let img = decode(&short); + assert!(img.opcodes.list3.is_empty(), "{order:?}: first copy malformed"); + assert_eq!(sony(&img).distortion.table.raw_ifd, Rejected(FieldType(SHORT))); + } + } + + #[test] + fn sr2_copies_fill_in_only_missing_raw_ifd_fields_and_are_never_applied() { + let (raw, sr2) = (Some(SonySource::RawIfd), Some(SonySource::Sr2SubIfd)); + for order in [Little, Big] { + // nothing in the raw IFD: every field from the SR2SubIFD, decoded, not applied even on the validated + // model (only the raw IFD's table was validated) + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(provenance(s), [(true, sr2, Absent, Valid, None); 6]); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(SHADING_T), Some(ca_curves()), Some(TELE)) + ); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + assert_eq!(s.distortion.status, SonyLensStatus::NotValidatedMode); + assert!(img.opcodes.list3.is_empty()); + // only the distortion table in the raw IFD: that one is used and applied, the rest comes from the SR2SubIFD + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.retain(|(tag, _)| *tag == DISTORTION.table.0); + let img = decode(&arw.build()); + let s = sony(&img); + let p = provenance(s); + assert_eq!(p[5], (true, raw, Valid, Valid, Some(true))); + assert!(p[..5].iter().all(|f| *f == (true, sr2, Absent, Valid, None)), "{p:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::Applied, 1)); + // the raw IFD's tag numbers inside the SR2SubIFD are not its tables + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain.clear(); + let plain_tags = [SHADING.setting.0, SHADING.table.0, CA.setting.0, CA.table.0, DISTORTION.setting.0, DISTORTION.table.0]; + arw.sr2 = Some(encrypted(&TELE, 0).into_iter().zip(plain_tags).map(|((_, kind, v), tag)| (tag, kind, v)).collect()); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + } + } + + #[test] + fn disagreeing_copies_are_reported_and_the_raw_ifd_wins() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sr2 = Some(encrypted(&WIDE, 17)); + let img = decode(&arw.build()); + let s = sony(&img); + let raw = Some(SonySource::RawIfd); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.distortion.setting), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + assert_eq!(s.distortion.table.value, Some(TELE)); + assert_eq!(s.distortion.setting.value.map(|v| v.value), Some(0)); + // the validated conversion is unchanged: the raw IFD's table + let reference: Vec<Opcode> = distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 32, 24), Rect::new(2, 2, 30, 20)).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference); + } + } + + #[test] + fn unreadable_or_hostile_sr2_subifds_give_no_copies() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + // stored with another key: the block decrypts to noise, no copy is found, nothing falls back on it + let mut arw = Arw::new("ILCE-7CR", order); + arw.stored_key = [1, 2, 3, 4]; + let img = decode(&arw.build()); + let s = sony(&img); + assert!(provenance(s).iter().all(|&(value, source, plain, sr2, agree)| value + && source == raw + && plain == Valid + && sr2 != Valid + && agree.is_none())); + arw.plain.clear(); + let img = decode(&arw.build()); + assert!(img.lens_metadata.sony.as_ref().is_none_or(|s| provenance(s).iter().all(|f| !f.0 && f.3 != Valid))); + // no SR2SubIFD; DNGPrivateData pointing past the file or into the image + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + let s = sony(&decode(&arw.build())).clone(); + assert!(!s.sr2_read); + assert_eq!(provenance(&s), [(true, raw, Valid, Absent, None); 6]); + let good = Arw::new("ILCE-7CR", order).build(); + let at = Tiff::parse(&good).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + for pointer in [u32::MAX, good.len() as u32, 8] { + let mut file = good.clone(); + file[at..at + 4].copy_from_slice(&word(order, pointer)); + let img = decode(&file); + assert!(sony(&img).distortion.table.sr2 != Valid, "{pointer}"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + } + // hostile entries in a well-encrypted block (the distortion table is its sixth entry): another type, huge + // count, a value offset before the block or past it + let sixth = 2 + 12 * 5; + let put = move |at: usize, bytes: Vec<u8>| move |b: &mut [u8]| b[at..at + bytes.len()].copy_from_slice(&bytes); + let mut short = Vec::new(); + order.put_u16(&mut short, SHORT); + for (patch, error) in [ + (Box::new(put(sixth + 2, short.clone())) as Box<dyn Fn(&mut [u8])>, FieldType(SHORT)), + (Box::new(put(sixth + 4, word(order, u32::MAX))), Count(u64::from(u32::MAX))), + (Box::new(put(sixth + 4, word(order, 1))), Count(1)), + (Box::new(put(sixth + 8, word(order, 0))), OutsideData), + (Box::new(put(sixth + 8, word(order, u32::MAX))), OutsideData), + ] { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(patch); + let s = sony(&decode(&arw.build())).clone(); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Rejected(error), None), "{error:?}"); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + } + // an entry count past the end of the block: the scan stops where the block does + let mut count = Vec::new(); + order.put_u16(&mut count, u16::MAX); + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(Box::new(put(0, count))); + assert_eq!(provenance(sony(&decode(&arw.build()))), [(true, raw, Valid, Valid, Some(true)); 6]); + } + } + + #[test] + fn framing_and_coding_evidence_gate_the_validated_distortion() { + use SonyLensStatus::*; + let r = Rect::new; + for order in [Little, Big] { + let run = |arw: &Arw| { + let img = decode(&arw.build()); + let s = sony(&img).clone(); + (s, img) + }; + // Sony's crop at x = 0 and the default crop at x = 2 (the A7R IVA's 0 versus 32): the image keeps Sony's + // crop, the table is centred on and normalised to the default crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sony_crop = Some([0, 2, 30, 20]); + let (s, img) = run(&arw); + let g = &s.geometry; + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(r(0, 2, 30, 20)), Some(r(2, 2, 30, 20)), r(0, 2, 30, 20), r(2, 2, 30, 20)) + ); + assert_eq!((img.crop, s.distortion.status), (r(0, 2, 30, 20), Applied)); + let Some(Opcode::WarpRectilinear { center, .. }) = img.opcodes.list3.first() else { panic!("warp") }; + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + // agreeing crops (the A7CR's case), in a frame padded beyond them (lossless L): the storage is recorded, + // the reference stays the crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.size = (40, 28); + let (s, _) = run(&arw); + assert_eq!((s.geometry.storage_width, s.geometry.storage_height, s.geometry.reference), (40, 28, r(2, 2, 30, 20))); + assert_eq!(s.distortion.status, Applied); + // without a default crop the reference is the image crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.default_crop = None; + let (s, _) = run(&arw); + assert_eq!((s.geometry.default_crop, s.geometry.reference, s.distortion.status), (None, r(2, 2, 30, 20), Applied)); + // non-3:2 framing: an aspect crop in Sony's tags, in the default crop, or a 4:3 frame without crop tags + for (sony_crop, default_crop) in + [(Some([2, 2, 30, 16]), Some([2, 2, 30, 20])), (Some([2, 2, 30, 20]), Some([2, 2, 30, 16])), (None, None)] + { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (sony_crop, default_crop); + let (s, img) = run(&arw); + assert!(!s.geometry.native_3_2, "{sony_crop:?} {default_crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (NotValidatedMode, 0), "{sony_crop:?} {default_crop:?}"); + } + // neutral, out-of-range and folding tables in the validated scope; models outside it + for (model, table, status) in [ + ("ILCE-7RM4A", [0; 16], Neutral), + ("ILCE-7RM4A", [i16::MIN; 16], Rejected), + ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), + ("ILCE-7RM4", TELE, NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedModel), + ("", TELE, NotValidatedModel), + ] { + let mut arw = Arw::new(model, order); + arw.plain = plain(&table, 1); + let (s, img) = run(&arw); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (status, 0), "{model} {table:?}"); + assert_eq!(s.distortion.table.value, Some(table)); + } + } + } + + #[test] + fn out_of_range_crop_tags_are_recorded_and_never_applied() { + for order in [Little, Big] { + // past the frame, enormous, empty, partly outside + for crop in [[99999, 0, 30, 20], [u32::MAX; 4], [2, 2, 0, 0], [30, 2, 30, 20]] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (Some(crop), Some(crop)); + let img = decode(&arw.build()); + let s = sony(&img); + let [x, y, w, h] = crop.map(|v| v as usize); + assert_eq!((s.geometry.sony_crop, s.geometry.default_crop), (Some(Rect::new(x, y, w, h)), Some(Rect::new(x, y, w, h))), "{crop:?}"); + assert!(!s.geometry.native_3_2, "{crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::NotValidatedMode, 0), "{crop:?}"); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + } + + #[test] + fn exif_orientation_does_not_change_the_record() { + // the tables and the warp are in sensor coordinates; the engine orients the warp (`engine::files::embedded_lens`) + let reference = decode(&Arw::new("ILCE-7RM4A", Little).build()); + for orientation in 1..=8u16 { + let mut arw = Arw::new("ILCE-7RM4A", Little); + arw.plain.push((t::ORIENTATION, Value::Short(vec![orientation]))); + let img = decode(&arw.build()); + assert_eq!(img.orientation, crate::Orientation::from_exif(orientation)); + assert_eq!((&img.lens_metadata, &img.opcodes), (&reference.lens_metadata, &reference.opcodes), "{orientation}"); + } + } + + #[test] + fn linear_ycbcr_files_are_decoded_but_not_corrected() { + // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags + for order in [Little, Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7RM4A".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(0x7010, Value::Short(vec![0; 4])); + raw.set(0x74c7, Value::Long(vec![0, 0])); + raw.set(0x74c8, Value::Long(vec![3, 2])); + for (tag, value) in plain(&TELE, 17) { + raw.set(tag, value); + } + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![crate::ljpeg::tests::fixture_420()] }); + let img = decode(&TiffWriter::new(order, false).write(&[raw]).unwrap()); + let s = sony(&img); + assert!(s.geometry.linear_ycbcr && s.geometry.native_3_2); + assert_eq!((s.geometry.compression, s.distortion.status, img.opcodes.list3.len()), (7, SonyLensStatus::NotValidatedMode, 0)); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 17, meaning: SonySettingMeaning::ForcedAuto })); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + + #[test] + fn files_without_lens_fields_have_no_record() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + arw.sr2 = None; + let img = decode(&arw.build()); + assert_eq!((img.lens_metadata.clone(), img.opcodes.list3.len()), (LensMetadata::default(), 0)); + // an SR2SubIFD holding other tags only + arw.sr2 = Some(vec![(0x7310, SHORT, vec![512; 4])]); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + // one field anywhere is a record + arw.sr2 = Some(vec![(DISTORTION.setting.1, SHORT, vec![255])]); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 255, meaning: SonySettingMeaning::Unavailable })); + assert_eq!( + (s.distortion.status, s.shading.status, s.chromatic_aberration.status), + (SonyLensStatus::NoTable, SonyLensStatus::NoTable, SonyLensStatus::NoTable) + ); + } + } + + #[test] + fn corrupt_files_never_panic() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let tiff = Tiff::parse(&good).unwrap(); + let strip = tiff.ifds[0].image().unwrap().chunks(good.len() as u64)[0]; + let image = strip.offset as usize..(strip.offset + strip.len) as usize; + let check = |file: &[u8]| { + let header = super::super::arw::decode(file, Mode::Header); + let full = super::super::arw::decode(file, Mode::Full); + if let (Ok(h), Ok(f)) = (header, full) { + assert_eq!((h.lens_metadata, h.opcodes), (f.lens_metadata, f.opcodes)); + } + }; + for len in 0..good.len() { + check(&good[..len]); + } + for at in (0..good.len()).filter(|at| !image.contains(at)) { + for v in [0, 0xff, good[at] ^ 0x80, good[at] ^ 0x01] { + let mut file = good.clone(); + file[at] = v; + check(&file); + } + } + } + } #[test] fn matches_independent_sony_reference_geometry() { // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. - let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &TELE, AREA, AREA).unwrap() else { panic!("warp") }; assert_eq!(center, [0.5, 0.5]); let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; for i in 0..=100 { @@ -151,8 +1169,8 @@ mod tests { fn barrel_framing_and_offset_crop_keep_the_same_geometry() { let crop = Rect::new(32, 20, 9504, 6336); let active = Rect::new(0, 0, 9600, 6376); - let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; - let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &WIDE, AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &WIDE, active, crop).unwrap() else { panic!("warp") }; assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); @@ -162,37 +1180,28 @@ mod tests { } } + /// The conversion's own refusals; missing and malformed layouts are refused by the decoder before it (see + /// `malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy`). #[test] - fn refuses_missing_unknown_malformed_or_unusable_tables() { - for bad in [ - Ifd::default(), - table(&[]), - table(&TELE[..15]), - table(&[0; 16]), - table(&[i16::MIN; 16]), - table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - ifd(Value::SShort(vec![15; 17])), - ifd(Value::Short(vec![16; 17])), - ifd(Value::Double(vec![f64::NAN; 17])), - ] { + fn refuses_unusable_tables() { + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); } - assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(99999, 0, 10, 10)).is_none()); } + #[test] fn refuses_unvalidated_models_even_with_a_valid_table() { - let valid = table(&TELE); - assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some()); - for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] { - assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some()); + for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR"] { + assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}"); } } #[test] fn header_and_full_decode_preserve_correction_through_dng() { - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { for (height, expected) in [(20, expected), (16, 0)] { for order in [ByteOrder::Little, ByteOrder::Big] { @@ -211,7 +1220,7 @@ mod tests { raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. raw.set(0x74c8, Value::Long(vec![30, height])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + raw.set(DISTORTION.table.0, Value::SShort(std::iter::once(16).chain(TELE).collect())); // Zero pixels have the same byte representation in both orders. raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..f6bc1798a 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -251,6 +251,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..e1b2988e9 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -393,6 +393,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..2c37c99d3 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -255,6 +255,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; image.validate_for(mode)?; Ok(image) diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..61c238740 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -117,6 +117,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 7d5b24a4f..4343afe3f 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -219,6 +219,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..834be3c62 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -331,6 +331,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 86db33540..840761f30 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -326,6 +326,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..0ff69cd19 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -214,6 +214,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..67f0ff10c 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -618,6 +618,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs index 212533add..10fc15074 100644 --- a/crates/raw/src/vendor/sr2.rs +++ b/crates/raw/src/vendor/sr2.rs @@ -45,7 +45,7 @@ //! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry //! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`). -use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; +use lightcraft_tiff::{ByteOrder, EntrySpan, Ifd, ParseOptions, decode_value, directory_entries, entry_span, tags as t}; /// `SR2Private` tags. const SUBIFD_OFFSET: u16 = 0x7200; @@ -136,11 +136,8 @@ impl SubIfd { /// [`MAX_LEN`]. pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option<Self> { let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; - let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; - let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; - let key: [u8; 4] = private.bytes(SUBIFD_KEY)?.try_into().ok()?; - let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; - let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; + let fields = private_fields(bytes, u64::from(at), order); + let (start, len, key) = (fields.offset?, fields.length?, fields.key?); if len > MAX_LEN { return None; } @@ -165,6 +162,13 @@ impl SubIfd { self.order } + /// The decrypted block (the directory from its entry count on) and the file offset it was stored at, which its + /// value offsets count from: for readers that check each entry's exact type and count themselves + /// (`arw_lens`). + pub(crate) fn block(&self) -> (&[u8], usize) { + (&self.plain, self.start) + } + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type /// or its values lie outside the block. pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { @@ -193,9 +197,69 @@ impl SubIfd { } } +/// The `SR2Private` fields that locate the block. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PrivateFields { + offset: Option<usize>, + length: Option<usize>, + key: Option<[u8; 4]>, +} + +/// The block's file offset, length and key from the `SR2Private` directory at `at`, exactly as the TIFF reader would +/// give them (`parse_ifd_at` with its default limits, then its `u64` and `bytes` lookups), without decoding the +/// directory. The selection uses the reader's own rules: its directory layout and entry limit +/// ([`directory_entries`]), which entries it can read ([`entry_span`]) and its value budget; each field is its first +/// readable occurrence. Of that entry only the first value is decoded (the key's first five bytes, which settle +/// whether it is four bytes), so a hostile directory can't make this allocate memory sized by its other entries, +/// which could each alias the whole file, before the block's own [`MAX_LEN`] is checked. +fn private_fields(bytes: &[u8], at: u64, order: ByteOrder) -> PrivateFields { + let opts = ParseOptions::default(); + let Ok(dir) = directory_entries(bytes, at, order, false, opts.max_entries) else { return PrivateFields::default() }; + let mut budget = opts.budget_for(bytes.len()); + let (mut offset, mut length, mut key) = (None, None, None); + for p in dir.positions() { + let Some(span) = entry_span(bytes, p, order, false, 0) else { continue }; + if span.len > budget { + continue; + } + budget -= span.len; + match span.tag { + SUBIFD_OFFSET if offset.is_none() => offset = Some(first_number(bytes, &span, order)), + SUBIFD_LENGTH if length.is_none() => length = Some(first_number(bytes, &span, order)), + SUBIFD_KEY if key.is_none() => key = Some(four_bytes(bytes, &span, order)), + _ => {} + } + } + PrivateFields { offset: offset.flatten(), length: length.flatten(), key: key.flatten() } +} + +/// The entry's first value as the reader's `u64` lookup reads it (an unsigned or non-negative integer), as a `usize`. +fn first_number(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<usize> { + if span.count == 0 { + return None; + } + let start = usize::try_from(span.start).ok()?; + let first = bytes.get(start..start.checked_add(span.field_type.size())?)?; + usize::try_from(decode_value(order, span.field_type, first).get_u64(0)?).ok() +} + +/// The entry's bytes as the reader's `bytes` lookup gives them, when they are exactly four. Decoding the first five +/// value bytes settles it: BYTE and UNDEFINED give all their bytes, ASCII its text before the first NUL, which +/// decoding never makes shorter. +fn four_bytes(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<[u8; 4]> { + let start = usize::try_from(span.start).ok()?; + let head = bytes.get(start..start.checked_add(usize::try_from(span.len).ok()?.min(5))?)?; + <[u8; 4]>::try_from(decode_value(order, span.field_type, head).as_bytes()?).ok() +} + #[cfg(test)] pub(crate) mod tests { use super::*; + /// TIFF field types. + const BYTE: u16 = 1; + const LONG: u16 = 4; + const UNDEFINED: u16 = 7; + const IFD: u16 = 13; use lightcraft_tiff::ByteOrder::{Big, Little}; /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values @@ -345,6 +409,200 @@ pub(crate) mod tests { } } + /// An `SR2Private` directory of `entries` `(tag, type, count, value field)` at file offset 0. + fn private(entries: &[(u16, u16, u32, [u8; 4])], order: ByteOrder) -> Vec<u8> { + let mut out = Vec::new(); + order.put_u16(&mut out, entries.len() as u16); + for &(tag, kind, count, value) in entries { + order.put_u16(&mut out, tag); + order.put_u16(&mut out, kind); + order.put_u32(&mut out, count); + out.extend(value); + } + order.put_u32(&mut out, 0); + out + } + + /// `(offset, length, key)` as one of the readers selects them. + type Selected = (Option<usize>, Option<usize>, Option<[u8; 4]>); + + /// What `SubIfd::read` on main selects: the TIFF reader's parse of the directory with main's options, then the + /// directory's `u64` and `bytes` lookups. + fn reader_selects(bytes: &[u8], at: usize, order: ByteOrder) -> Selected { + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let Ok((private, _)) = lightcraft_tiff::parse_ifd_at(bytes, at as u64, order, 0, false, &opts) else { return (None, None, None) }; + let n = |tag: u16| private.u64(tag).and_then(|v| usize::try_from(v).ok()); + (n(SUBIFD_OFFSET), n(SUBIFD_LENGTH), private.bytes(SUBIFD_KEY).and_then(|b| <[u8; 4]>::try_from(b).ok())) + } + + /// Synthetic `SR2Private` directories at offset 0, with what each tests: filler entries ahead of the fields, and + /// unreadable, unusual or valid first occurrences of each field ahead of a valid second one. + fn differential_cases(order: ByteOrder) -> Vec<(String, Vec<u8>)> { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let h = |a: u16, b: u16| { + let mut out = Vec::new(); + order.put_u16(&mut out, a); + order.put_u16(&mut out, b); + [out[0], out[1], out[2], out[3]] + }; + let valid = [(SUBIFD_OFFSET, LONG, 1, w(4096)), (SUBIFD_LENGTH, LONG, 1, w(64)), (SUBIFD_KEY, UNDEFINED, 4, KEY)]; + let mut cases = Vec::new(); + for fillers in [0usize, 1, 255, 256, 257, 8189, 8190, 8191, 8192, 8193] { + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + entries.extend(valid); + cases.push((format!("{fillers} fillers"), private(&entries, order))); + } + // a first occurrence ahead of the valid one; pointer values point into a 32-byte tail after the directory + let tail_at = 2 + 12 * 4 + 4; + let mut tail: Vec<u8> = (1..=32).collect(); + tail[4..8].copy_from_slice(b"\x11\x22\x33\x44"); + tail[8] = 0; + let numeric: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type 0", 0, 1, w(8)), + ("unknown type 14", 14, 1, w(8)), + ("unknown type 0xffff", 0xffff, 1, w(8)), + ("LONG[2] past the file", LONG, 2, w(u32::MAX)), + ("LONG[2] straddling the end", LONG, 2, w(tail_at as u32 + 28)), + ("LONG[u32::MAX]", LONG, u32::MAX, w(tail_at as u32)), + ("DOUBLE[u32::MAX]", 12, u32::MAX, w(tail_at as u32)), + ("LONG[0]", LONG, 0, w(0)), + ("LONG[2] in the file", LONG, 2, w(tail_at as u32)), + ("SHORT[1]", SHORT, 1, h(9, 0)), + ("SHORT[2] inline", SHORT, 2, h(9, 10)), + ("BYTE[3]", BYTE, 3, [5, 6, 7, 0]), + ("SSHORT -1", SSHORT, 1, h(0xffff, 0)), + ("SSHORT 5", SSHORT, 1, h(5, 0)), + ("SLONG -1", 9, 1, w(u32::MAX)), + ("LONG8 at a pointer", 16, 1, w(tail_at as u32)), + ("RATIONAL", 5, 1, w(tail_at as u32)), + ("ASCII", 2, 4, *b"abc\0"), + ("IFD", IFD, 1, w(4096)), + ]; + for (tag, other) in [(SUBIFD_OFFSET, [valid[1], valid[2]]), (SUBIFD_LENGTH, [valid[0], valid[2]])] { + for (what, kind, count, field) in &numeric { + let valid_one = if tag == SUBIFD_OFFSET { valid[0] } else { valid[1] }; + let mut d = private(&[(tag, *kind, *count, *field), valid_one, other[0], other[1]], order); + d.extend(&tail); + cases.push((format!("{tag:#x}: first {what}"), d)); + } + } + let keys: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type", 0, 4, KEY), + ("UNDEFINED[8] past the file", UNDEFINED, 8, w(u32::MAX)), + ("UNDEFINED[5] in the file", UNDEFINED, 5, w(tail_at as u32)), + ("UNDEFINED[3]", UNDEFINED, 3, KEY), + ("UNDEFINED[0]", UNDEFINED, 0, KEY), + ("BYTE[4]", BYTE, 4, [1, 2, 3, 4]), + ("SBYTE[4]", 6, 4, KEY), + ("ASCII[4]", 2, 4, KEY), + ("ASCII[4] with NUL", 2, 4, [0x41, 0x42, 0, 0x43]), + ("ASCII[4] invalid UTF-8", 2, 4, [0xff, 0x41, 0, 0]), + ("ASCII[4] two invalid bytes", 2, 4, [0xc3, 0x28, 0x41, 0]), + ("ASCII[9] at a pointer, NUL at 4", 2, 9, w(tail_at as u32 + 4)), + ("ASCII[8] at a pointer, no NUL", 2, 8, w(tail_at as u32 + 12)), + ("LONG[1]", LONG, 1, KEY), + ]; + for (what, kind, count, field) in &keys { + let mut d = private(&[(SUBIFD_KEY, *kind, *count, *field), valid[0], valid[1], valid[2]], order); + d.extend(&tail); + cases.push((format!("key: first {what}"), d)); + } + // the reader's value budget (8 × the data's length + 1 MiB), used up by entries aliasing the data + for aliases in [23u32, 24] { + let mut entries = vec![(0x7300, UNDEFINED, 1 << 16, w(0)); aliases as usize]; + entries.extend(valid); + let mut d = private(&entries, order); + d.resize(1 << 16, 0); + cases.push((format!("{aliases} entries using the value budget"), d)); + } + // no entries; more entries declared than the data holds + cases.push(("empty directory".into(), private(&[], order))); + let mut long = private(&valid, order); + long[..2].copy_from_slice(&if order == Little { 10u16.to_le_bytes() } else { 10u16.to_be_bytes() }); + long.truncate(2 + 12 * 3); + cases.push(("10 entries declared, 3 present".into(), long)); + cases + } + + #[test] + fn private_fields_match_the_tiff_readers_selection() { + let mut differ = Vec::new(); + for order in [Little, Big] { + for (case, d) in differential_cases(order) { + let f = private_fields(&d, 0, order); + let (ours, reader) = ((f.offset, f.length, f.key), reader_selects(&d, 0, order)); + if ours != reader { + differ.push(format!("{order:?} {case}: {ours:?}, reader {reader:?}")); + } + } + assert_eq!(private_fields(&[], 0, order), PrivateFields::default()); + assert_eq!(private_fields(&private(&[], order), u64::MAX, order), PrivateFields::default()); + } + assert!(differ.is_empty(), "{} cases differ from the TIFF reader:\n{}", differ.len(), differ.join("\n")); + // what the reader (and so this) gives in the cases the review named: the limit is 8,192 entries, a later + // copy stands in for an unreadable first one, and a used-up value budget hides the fields + let found = |d: &[u8]| private_fields(d, 0, Little) == PrivateFields { offset: Some(4096), length: Some(64), key: Some(KEY) }; + let cases = differential_cases(Little); + let case = |name: &str| cases.iter().find(|(c, _)| c == name).map(|(_, d)| d.clone()).unwrap(); + for name in [ + "0 fillers", + "256 fillers", + "8189 fillers", + "0x7200: first LONG[2] past the file", + "0x7201: first unknown type 14", + "key: first UNDEFINED[8] past the file", + "23 entries using the value budget", + ] { + assert!(found(&case(name)), "{name}"); + } + for name in ["8190 fillers", "8192 fillers", "24 entries using the value budget", "0x7200: first ASCII", "key: first UNDEFINED[3]"] { + assert!(!found(&case(name)), "{name}"); + } + } + + /// The case that broke on review: valid entries ahead of the three fields, and an unreadable first offset ahead of + /// a valid one. Main's reader finds the block; so must this one (else the black level, white balance and crop it + /// holds are lost). + #[test] + fn the_block_is_found_behind_filler_and_unreadable_entries() { + for order in [Little, Big] { + for (fillers, unreadable_first) in [(256usize, false), (300, false), (0, true), (256, true)] { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let n = fillers + 3 + usize::from(unreadable_first); + let start = 2 + 12 * n + 4; + let block = block(start, &[(0x7310, SHORT, &[800; 4])], order); + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + if unreadable_first { + entries.push((SUBIFD_OFFSET, LONG, 2, w(u32::MAX))); + } + entries.extend([ + (SUBIFD_OFFSET, LONG, 1, w(start as u32)), + (SUBIFD_LENGTH, LONG, 1, w(block.len() as u32)), + (SUBIFD_KEY, UNDEFINED, 4, KEY), + ]); + let mut file = private(&entries, order); + assert_eq!(file.len(), start); + file.extend(&block); + let mut pointer = Vec::new(); + order.put_u32(&mut pointer, 0); + let ifd0 = Ifd { + entries: vec![lightcraft_tiff::Entry { tag: t::DNG_PRIVATE_DATA, value: lightcraft_tiff::Value::Byte(pointer), offset: 0 }], + ..Default::default() + }; + let black = SubIfd::read(&file, &ifd0, order).and_then(|s| s.shorts(0x7310)); + assert_eq!(black, Some(vec![800.0; 4]), "{order:?}: {fillers} fillers, unreadable first offset {unreadable_first}"); + } + } + } + #[test] fn hostile_directories_give_none() { let start = 4096; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..7d5a70aaf 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -186,6 +186,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index a96165b5e..98cbe6d2d 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1053,6 +1053,229 @@ fn nikon_in_file_distortion_is_limited_to_validated_bodies() { eprintln!("Nikon distortion boundary: {checked} corpus files checked"); } +/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/main.rs`) with their pinned SHA-256. +const SONY_SAMPLES: &[(&str, &str)] = &[ + ("arw-sony-a7rm4a-compressed.arw", "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67"), + ("arw-sony-a9m2-compressed.arw", "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97"), + ("arw-sony-a7m3-compressed.arw", "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f"), + ("arw-sony-a7m3-uncompressed.arw", "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd"), + ("arw-sony-a7m4-14bit.arw", "639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2"), + ("arw-sony-a7m4-lossless-l.arw", "851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6"), + ("arw-sony-a7m4-lossless-m.arw", "d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571"), + ("arw-sony-a7m4-lossless-s.arw", "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223"), + ("arw-sony-a7cr-lossless-l.arw", "3e1642f3a1ae7c9f93228c5f09f27e362608e6a4ca1b3e5d8dd9eb8fae576997"), + ("arw-sony-a7cr-lossless-m.arw", "5bcba4acc52a5b902074581b4a2f5fb3c79ec56bd0fdc1746b9e2c0f9d3e9c88"), + ("arw-sony-a7cr-compressed.arw", "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5"), + ("arw-sony-a7cm2-lossless-l.arw", "342d893bd71fd050c273df181e3039d3f12d88947b0662de5de5325c84cc449b"), + ("arw-sony-a7cm2-compressed-m.arw", "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e"), + ("arw-sony-a7rm4-14bit-compressed.arw", "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a"), + ("arw-sony-rx100m3.arw", "cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138"), + ("arw-sony-rx100.arw", "579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b"), + ("arw-sony-a7rm2-12bit-uncompressed.arw", "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39"), + ("arw-sony-a500.arw", "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead"), + ("arw-sony-a700.arw", "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40"), + ("arw-sony-a33.arw", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), + ("arw-sony-a3500-5600k.arw", "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8"), + ("arw-sony-a7s-shade.arw", "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2"), + ("arw-sony-a7sm2-16x9.arw", "46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db"), + ("arw-sony-a580-16x9.arw", "5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709"), + ("arw-sony-a900-packed12.arw", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50"), +]; + +/// SHA-256 of [`SONY_SAMPLES`] as `name sha256` lines sorted by name: anchors the pins independently of the +/// manifest, so editing both together (as a drift or a mistake) still fails `sony_pins_match_the_corpus_manifest`. +/// All 25 pins were checked against the downloaded files when it was set. +const SONY_PINS_SHA256: &str = "4cfc349194278146356b463c59ede500331c6ca5fc50aec4e92d7cd847b17b68"; + +/// A Sony corpus file's bytes, checked against its pinned SHA-256 before anything decodes them; `None` (with a +/// note) when the file is absent, unless `LIGHTCRAFT_CORPUS_REQUIRED=1` asks for every pinned file. A file that +/// differs from its pin fails the test. +fn pinned_sony(root: &Path, name: &str) -> Option<Vec<u8>> { + use sha2::{Digest, Sha256}; + let expected = SONY_SAMPLES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{name}: no pinned SHA-256")).1; + let bytes = match std::fs::read(root.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + assert!(std::env::var_os("LIGHTCRAFT_CORPUS_REQUIRED").is_none_or(|v| v != "1"), "{name}: absent, and LIGHTCRAFT_CORPUS_REQUIRED=1"); + eprintln!("skip: {name} absent"); + return None; + } + Err(e) => panic!("{name}: {e}"), + }; + let actual: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual, expected, "{name}: corpus file differs from its pinned SHA-256"); + Some(bytes) +} + +#[test] +fn sony_pins_match_the_corpus_manifest() { + use sha2::{Digest, Sha256}; + let mut lines: Vec<String> = SONY_SAMPLES.iter().map(|(name, sha)| format!("{name} {sha}\n")).collect(); + lines.sort(); + let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); + let manifest = include_str!("../../../xtask/src/main.rs"); + for (name, sha) in SONY_SAMPLES { + let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); + assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); + } +} + +#[test] +#[should_panic(expected = "differs from its pinned SHA-256")] +fn a_sony_fixture_that_differs_from_its_pin_fails() { + struct Gone(PathBuf); + impl Drop for Gone { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = Gone(std::env::temp_dir().join(format!("lightcraft-sony-pin-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0).unwrap(); + std::fs::write(dir.0.join("arw-sony-a7cr-compressed.arw"), b"not the pinned file").unwrap(); + pinned_sony(&dir.0, "arw-sony-a7cr-compressed.arw"); +} + +/// Sony's lens-correction records (`lightcraft_raw::lens_metadata`, `crates/raw/src/vendor/arw_lens.rs`) on the public +/// samples: shading, CA and distortion decoded from the raw IFD with their settings, equal to their `SR2SubIFD` copies, +/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A distortion. Expected +/// tables and settings: what ExifTool, run as a black-box tool, prints for these files. Every file is checked against +/// its pinned SHA-256 before it is decoded; absent files are skipped. +#[test] +fn sony_lens_records_of_the_public_samples() { + use lightcraft_raw::Rect; + use lightcraft_raw::lens_metadata::{SonyCopy, SonyLensStatus, SonySettingMeaning as M, SonySource}; + let root = corpus_root().join("raw"); + // ILCE-7CR (Sigma 24-70mm F2.8 DG DN Art at 49.5 mm): one scene in three codings, identical tables + let a7cr_shading = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + let a7cr_ca = [ + [-256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -128], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -128, -128, -256, -256], + ]; + let a7cr_distortion = [4, 1, 1, 3, 7, 13, 22, 35, 51, 72, 98, 132, 173, 223, 279, 348]; + // ILCE-7RM4A (FE 24-105mm F4 G OSS at 24 mm) + let a7rm4a_shading = [0, 32, 112, 256, 448, 688, 976, 1296, 1712, 2336, 3120, 4080, 5104, 6112, 7024, 7904]; + let a7rm4a_ca = [ + [1408, 1280, 1152, 1024, 1024, 1024, 1024, 1024, 896, 896, 896, 768, 768, 640, 512, 384], + [512, 512, 384, 384, 384, 384, 384, 512, 512, 512, 512, 640, 640, 640, 768, 640], + ]; + let a7rm4a_distortion = [14, 0, -29, -65, -113, -171, -239, -311, -392, -471, -555, -634, -713, -782, -843, -891]; + let a7cr_crop = Rect::new(32, 20, 9504, 6336); + // (file, settings, tables, distortion status, storage, compression, linear YCbCr, Sony crop, default crop) + let cases = [ + ( + "arw-sony-a7cr-compressed.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9600, 6376), + 32767, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-l.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9728, 6656), + 7, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-m.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (6656, 4608), + 7, + true, + Rect::new(20, 12, 6240, 4160), + Rect::new(20, 12, 6240, 4160), + ), + ( + "arw-sony-a7rm4a-compressed.arw", + [257, 1, 17], + (a7rm4a_shading, a7rm4a_ca, a7rm4a_distortion), + SonyLensStatus::Applied, + (9600, 6376), + 32767, + false, + Rect::new(0, 20, 9504, 6336), + Rect::new(32, 20, 9504, 6336), + ), + ]; + let mut checked = 0; + for (name, settings, (shading, ca, distortion), status, (w, h), compression, linear, sony_crop, default_crop) in cases { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + let s = full.lens_metadata.sony.as_ref().unwrap_or_else(|| panic!("{name}: no Sony lens record")); + assert!(s.sr2_read, "{name}"); + let got = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.value)); + assert_eq!(got, settings.map(Some), "{name}: settings"); + let meanings = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.meaning)); + let forced = if settings[2] == 17 { M::ForcedAuto } else { M::Off }; + assert_eq!(meanings, [Some(M::Auto), Some(M::Auto), Some(forced)], "{name}: setting meanings"); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(shading), Some(ca), Some(distortion)), + "{name}" + ); + for (source, raw, sr2, agree) in [ + (s.shading.table.source, s.shading.table.raw_ifd, s.shading.table.sr2, s.shading.table.copies_agree), + ( + s.chromatic_aberration.table.source, + s.chromatic_aberration.table.raw_ifd, + s.chromatic_aberration.table.sr2, + s.chromatic_aberration.table.copies_agree, + ), + (s.distortion.table.source, s.distortion.table.raw_ifd, s.distortion.table.sr2, s.distortion.table.copies_agree), + (s.shading.setting.source, s.shading.setting.raw_ifd, s.shading.setting.sr2, s.shading.setting.copies_agree), + ( + s.chromatic_aberration.setting.source, + s.chromatic_aberration.setting.raw_ifd, + s.chromatic_aberration.setting.sr2, + s.chromatic_aberration.setting.copies_agree, + ), + (s.distortion.setting.source, s.distortion.setting.raw_ifd, s.distortion.setting.sr2, s.distortion.setting.copies_agree), + ] { + assert_eq!((source, raw, sr2, agree), (Some(SonySource::RawIfd), SonyCopy::Valid, SonyCopy::Valid, Some(true)), "{name}: provenance"); + } + assert_eq!( + (s.shading.status, s.chromatic_aberration.status, s.distortion.status), + (SonyLensStatus::NotValidatedComponent, SonyLensStatus::NotValidatedComponent, status), + "{name}" + ); + assert_eq!(full.opcodes.list3.len(), usize::from(status == SonyLensStatus::Applied), "{name}: only applied components are opcodes"); + let g = &s.geometry; + assert_eq!((g.storage_width, g.storage_height, g.compression, g.linear_ycbcr), (w, h, compression, linear), "{name}"); + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(sony_crop), Some(default_crop), sony_crop, default_crop), + "{name}" + ); + assert!(g.native_3_2, "{name}"); + eprintln!("{name}: shading, CA and distortion decoded; distortion {status:?}"); + checked += 1; + } + // every Sony corpus file: only the ILCE-7RM4A's distortion is ever applied, and an applied status is an opcode + for (name, _) in SONY_SAMPLES { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let info = probe_info(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let Some(s) = &info.lens_metadata.sony else { continue }; + let applied = s.distortion.status == SonyLensStatus::Applied; + assert_eq!(applied, info.metadata.model.as_deref() == Some("ILCE-7RM4A"), "{name}"); + assert_eq!(info.opcodes.list3.len(), usize::from(applied), "{name}"); + assert!(![s.shading.status, s.chromatic_aberration.status].contains(&SonyLensStatus::Applied), "{name}"); + checked += 1; + } + eprintln!("Sony lens records: {checked} checks on corpus files"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f95afef22..921e8900a 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -61,6 +61,7 @@ fn synthetic(w: usize, h: usize, cfa: Option<Cfa>, cpp: usize) -> RawImage { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), } } diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 6dc383de8..17e108f6f 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -26,6 +26,7 @@ fn sample(comp: DngCompression) -> Vec<u8> { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap() } diff --git a/crates/raw/tests/sony_lens_inventory.rs b/crates/raw/tests/sony_lens_inventory.rs new file mode 100644 index 000000000..3eda88418 --- /dev/null +++ b/crates/raw/tests/sony_lens_inventory.rs @@ -0,0 +1,351 @@ +//! Opt-in local check of Sony's lens-correction records ([`lightcraft_raw::lens_metadata`]); it does nothing unless +//! `LIGHTCRAFT_SONY_LENS_DIR` names a directory of ARW files, so CI never runs it. The files there (searched +//! recursively) are only read, from their headers, and one line per file is printed: settings, where each table came +//! from, whether its raw-IFD and `SR2SubIFD` copies agree, sample ranges and status. Header and full decode are +//! compared too when `LIGHTCRAFT_SONY_LENS_FULL=1` (slow on large files). +//! +//! With `LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory of those files (comma-separated, no quoting, a header +//! row, one row per file name), every file must have a row, every row a file, and these columns must match where +//! present: `split` (the file's folder name), `raw_size` (`WxH`), `compression`, `crop_origin` / `crop_size` (Sony's +//! crop tags, `x y` / `w h`), and for each of `shading`, `ca` and `distortion`: `_entries` (TIFF count), `_bytes`, +//! `_samples` (the leading sample count), `_min` / `_max` (over the samples), `_sr2_equal` (`True` / `False`), +//! `_setting_raw` and `_setting` (a label: `Off`, `Auto`, `Auto (ILCE-1)`, `Auto fixed by lens`, `No correction +//! params available`). A file that doesn't decode must still have a row, and fails the check when it has one. +//! +//! Any decode error fails the check except an unsupported variant (`RawError::Unsupported`, e.g. the packed ARW of +//! some older bodies): those are counted, and the count must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` (default 0). +//! +//! `LIGHTCRAFT_SONY_LENS_DIR=… LIGHTCRAFT_SONY_LENS_INVENTORY=….csv cargo test -p lightcraft-raw --release --test +//! sony_lens_inventory -- --nocapture` + +use lightcraft_raw::lens_metadata::{SonyCopy, SonyField, SonyLensCorrections, SonyLensStatus, SonySetting, SonySettingMeaning, SonySource}; +use lightcraft_raw::{RawError, RawInfo, decode, probe_info}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn arw_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && depth < 8 { + arw_files(&path, depth + 1, out); + } else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("arw")) { + out.push(path); + } + } +} + +/// One component's columns, from the decoded record. +struct Component { + setting: Option<(u16, SonySettingMeaning)>, + /// Samples when the table was decoded. + samples: Option<Vec<i16>>, + source: Option<SonySource>, + /// The raw-IFD and `SR2SubIFD` copies' states. + copies: (SonyCopy, SonyCopy), + agree: Option<bool>, + status: String, +} + +fn component<T>(setting: &SonyField<SonySetting>, table: &SonyField<T>, samples: Option<Vec<i16>>, status: SonyLensStatus) -> Component { + Component { + setting: setting.value.map(|s| (s.value, s.meaning)), + samples, + source: table.source, + copies: (table.raw_ifd, table.sr2), + agree: table.copies_agree, + status: format!("{status:?}"), + } +} + +/// Shading, CA and distortion of a record, by CSV column prefix. +fn components(s: &SonyLensCorrections) -> [(&'static str, Component); 3] { + let (sh, ca, d) = (&s.shading, &s.chromatic_aberration, &s.distortion); + [ + ("shading", component(&sh.setting, &sh.table, sh.table.value.map(|v| v.to_vec()), sh.status)), + ("ca", component(&ca.setting, &ca.table, ca.table.value.map(|v| v.concat()), ca.status)), + ("distortion", component(&d.setting, &d.table, d.table.value.map(|v| v.to_vec()), d.status)), + ] +} + +fn label(meaning: SonySettingMeaning) -> &'static [&'static str] { + match meaning { + SonySettingMeaning::Off => &["Off"], + SonySettingMeaning::Auto => &["Auto", "Auto (ILCE-1)"], + SonySettingMeaning::ForcedAuto => &["Auto fixed by lens"], + SonySettingMeaning::Unavailable => &["No correction params available"], + SonySettingMeaning::Unknown(_) => &[], + } +} + +/// The columns this check derives from a file, by CSV column name. +fn columns(path: &Path, info: &RawInfo) -> BTreeMap<String, String> { + let mut out = BTreeMap::new(); + let mut put = |k: &str, v: String| { + out.insert(k.to_string(), v); + }; + if let Some(split) = path.parent().and_then(|p| p.file_name()) { + put("split", split.to_string_lossy().into_owned()); + } + let Some(s) = &info.lens_metadata.sony else { return out }; + let g = &s.geometry; + put("raw_size", format!("{}x{}", g.storage_width, g.storage_height)); + put("compression", g.compression.to_string()); + if let Some(c) = g.sony_crop { + put("crop_origin", format!("{} {}", c.x, c.y)); + put("crop_size", format!("{} {}", c.width, c.height)); + } + for (name, c) in components(s) { + if let Some(v) = &c.samples { + put(&format!("{name}_entries"), (v.len() + 1).to_string()); + put(&format!("{name}_bytes"), (2 * (v.len() + 1)).to_string()); + put(&format!("{name}_samples"), v.len().to_string()); + put(&format!("{name}_min"), v.iter().min().map_or(String::new(), i16::to_string)); + put(&format!("{name}_max"), v.iter().max().map_or(String::new(), i16::to_string)); + } + put(&format!("{name}_sr2_equal"), c.agree.map_or("unknown", |a| if a { "True" } else { "False" }).to_string()); + if let Some((value, _)) = c.setting { + put(&format!("{name}_setting_raw"), value.to_string()); + } + } + out +} + +fn summary(info: &RawInfo) -> String { + let Some(s) = &info.lens_metadata.sony else { return "no lens record".into() }; + let mut line = format!("sr2 {} |", if s.sr2_read { "read" } else { "absent" }); + for (name, c) in components(s) { + let setting = c.setting.map_or("-".into(), |(v, m)| format!("{v} {m:?}")); + let range = c + .samples + .as_ref() + .map_or("no table".into(), |v| format!("{} samples {}..{}", v.len(), v.iter().min().unwrap_or(&0), v.iter().max().unwrap_or(&0))); + line += + &format!(" {name}: {setting}, {range}, {:?} (copies {:?} / {:?}), agree {:?}, {} |", c.source, c.copies.0, c.copies.1, c.agree, c.status); + } + line + &format!(" crop {:?} reference {:?} 3:2 {}", s.geometry.image_crop, s.geometry.reference, s.geometry.native_3_2) +} + +/// `(header, rows by file name)` of a simple CSV file. +fn read_csv(path: &Path) -> (Vec<String>, BTreeMap<String, Vec<String>>) { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let header: Vec<String> = lines.next().expect("CSV header").split(',').map(|s| s.trim().to_string()).collect(); + let file = header.iter().position(|h| h == "file").expect("a `file` column"); + let mut rows = BTreeMap::new(); + for line in lines { + assert!(!line.contains('"'), "quoted CSV fields are not supported: {line}"); + let row: Vec<String> = line.split(',').map(|s| s.trim().to_string()).collect(); + assert_eq!(row.len(), header.len(), "CSV row with {} fields, header has {}: {line}", row.len(), header.len()); + let name = row[file].clone(); + assert!(rows.insert(name.clone(), row).is_none(), "{name} listed twice"); + } + (header, rows) +} + +#[test] +fn sony_lens_records_reproduce_a_local_inventory() { + let Some(dir) = std::env::var_os("LIGHTCRAFT_SONY_LENS_DIR").map(PathBuf::from) else { + eprintln!("skip: LIGHTCRAFT_SONY_LENS_DIR unset"); + return; + }; + let full = std::env::var_os("LIGHTCRAFT_SONY_LENS_FULL").is_some_and(|v| v == "1"); + let inventory = std::env::var_os("LIGHTCRAFT_SONY_LENS_INVENTORY").map(PathBuf::from); + let unsupported = std::env::var("LIGHTCRAFT_SONY_LENS_UNSUPPORTED").map_or(0, |v| v.parse().expect("LIGHTCRAFT_SONY_LENS_UNSUPPORTED: a count")); + let report = check(&dir, inventory.as_deref(), full, unsupported); + assert!(report.problems.is_empty(), "{} problems", report.problems.len()); +} + +/// What [`check`] found. +#[derive(Debug, Default)] +struct Report { + files: usize, + records: usize, + without_record: usize, + unsupported: usize, + problems: Vec<String>, +} + +/// Check every ARW under `dir` (see the module docs). Any decode error is a problem except `Unsupported`, which +/// is counted and must come to exactly `expected_unsupported`; with an inventory, every file (decoded or not) must +/// have a row, and a file with a row must decode. +fn check(dir: &Path, inventory: Option<&Path>, full: bool, expected_unsupported: usize) -> Report { + let inventory = inventory.map(read_csv); + let mut files = Vec::new(); + arw_files(dir, 0, &mut files); + files.sort(); + let mut report = Report { files: files.len(), ..Default::default() }; + if files.is_empty() { + report.problems.push(format!("no ARW files under {}", dir.display())); + } + let (mut matched, mut mismatches, mut seen) = (0usize, Vec::new(), std::collections::BTreeSet::new()); + let compared: Vec<String> = ["split", "raw_size", "compression", "crop_origin", "crop_size"] + .into_iter() + .map(String::from) + .chain( + ["shading", "ca", "distortion"] + .into_iter() + .flat_map(|k| ["entries", "bytes", "samples", "min", "max", "sr2_equal", "setting_raw"].into_iter().map(move |c| format!("{k}_{c}"))), + ) + .collect(); + for path in &files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + seen.insert(name.clone()); + let info = match probe_info(&bytes) { + Ok(info) => info, + Err(e) => { + eprintln!("{name}: {e}"); + match (&inventory, &e) { + (Some((_, rows)), _) if rows.contains_key(&name) => mismatches.push(format!("{name}: does not decode: {e}")), + (Some(_), _) => mismatches.push(format!("{name}: does not decode ({e}) and is not in the inventory")), + (None, RawError::Unsupported(_)) => {} + (None, _) => mismatches.push(format!("{name}: does not decode: {e}")), + } + report.unsupported += usize::from(matches!(e, RawError::Unsupported(_))); + continue; + } + }; + if full { + match decode(&bytes) { + Ok(image) if image.info() == info => {} + Ok(_) => mismatches.push(format!("{name}: header and full decode differ")), + Err(e) => mismatches.push(format!("{name}: full decode fails: {e}")), + } + } + if info.lens_metadata.sony.is_some() { + report.records += 1; + } else { + report.without_record += 1; + } + eprintln!("{name}: {} {}", info.metadata.model.as_deref().unwrap_or("?"), summary(&info)); + let Some((header, rows)) = &inventory else { continue }; + let Some(row) = rows.get(&name) else { + mismatches.push(format!("{name}: not in the inventory")); + continue; + }; + let got = columns(path, &info); + let mut ok = true; + for (i, column) in header.iter().enumerate() { + let expected = &row[i]; + if let Some(setting) = column.strip_suffix("_setting") + && let Some(s) = &info.lens_metadata.sony + { + let meaning = components(s).into_iter().find(|(n, _)| *n == setting).and_then(|(_, c)| c.setting).map(|(_, m)| m); + if !meaning.is_some_and(|m| label(m).contains(&expected.as_str())) { + mismatches.push(format!("{name}: {column} {meaning:?}, inventory {expected}")); + ok = false; + } + continue; + } + if !compared.contains(column) { + continue; + } + let value = got.get(column).map(String::as_str).unwrap_or("(none)"); + if value != expected { + mismatches.push(format!("{name}: {column} {value}, inventory {expected}")); + ok = false; + } + } + matched += usize::from(ok); + } + if let Some((header, rows)) = &inventory { + for name in rows.keys().filter(|n| !seen.contains(*n)) { + mismatches.push(format!("{name}: in the inventory, no such file")); + } + let checked = header.iter().filter(|c| compared.contains(c) || c.ends_with("_setting")).count(); + assert!(checked > 0, "the inventory has none of the compared columns"); + eprintln!("{matched} of {} inventory rows reproduced from {} files ({checked} columns compared)", rows.len(), files.len()); + } + if report.unsupported != expected_unsupported { + mismatches.push(format!("{} files are unsupported variants, {expected_unsupported} expected", report.unsupported)); + } + eprintln!( + "{} files: {} with a lens record, {} decoded without one, {} unsupported ({expected_unsupported} expected)", + report.files, report.records, report.without_record, report.unsupported + ); + for m in &mismatches { + eprintln!("problem: {m}"); + } + report.problems.extend(mismatches); + report +} + +/// A directory under the system temporary directory, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lightcraft-sony-lens-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A small Sony CFA file with a distortion setting (a lens record), or, `unsupported`, the packed 12-bit ARW variant +/// the decoder refuses as unsupported. +fn synthetic_arw(unsupported: bool) -> Vec<u8> { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(0x7036, Value::Short(vec![0])); + if unsupported { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 + 8]] }); + } else { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 * 2]] }); + } + TiffWriter::default().write(&[raw]).unwrap() +} + +#[test] +fn files_that_fail_to_decode_fail_the_check() { + // a file that is no raw at all fails the check, whatever is expected + let dir = Scratch::new("decode-error"); + std::fs::write(dir.0.join("not-a-raw.arw"), b"not a raw file").unwrap(); + let report = check(&dir.0, None, false, 0); + assert!(!report.problems.is_empty(), "an ARW that does not decode passed the check"); + assert!(!check(&dir.0, None, false, 1).problems.is_empty(), "a decode error counted as an unsupported variant"); + // an unsupported variant is counted, and must be expected + let dir = Scratch::new("unsupported"); + std::fs::write(dir.0.join("good.arw"), synthetic_arw(false)).unwrap(); + std::fs::write(dir.0.join("packed.arw"), synthetic_arw(true)).unwrap(); + let report = check(&dir.0, None, false, 0); + assert_eq!((report.files, report.records, report.unsupported), (2, 1, 1)); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(check(&dir.0, None, true, 1).problems.is_empty()); + // with an inventory, a file that fails must still be listed: here it is not + let csv = dir.0.join("inventory.csv"); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + // (the inventory itself sits in the directory, but only .arw files are checked) + let mut report = check(&dir.0, Some(&csv), false, 1); + report.problems.sort(); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(report.problems[0].contains("packed.arw") && report.problems[0].contains("not in the inventory"), "{:?}", report.problems); + // listed but failing, and listed and decoding + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\npacked.arw,0\n").unwrap(); + let report = check(&dir.0, Some(&csv), false, 1); + assert!(report.problems.len() == 1 && report.problems[0].contains("packed.arw: does not decode"), "{:?}", report.problems); + std::fs::remove_file(dir.0.join("packed.arw")).unwrap(); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + assert!(check(&dir.0, Some(&csv), false, 0).problems.is_empty()); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,17\n").unwrap(); + assert!(!check(&dir.0, Some(&csv), false, 0).problems.is_empty(), "a wrong value passed"); +} diff --git a/crates/raw/tests/sr2_heap.rs b/crates/raw/tests/sr2_heap.rs new file mode 100644 index 000000000..2b936d18c --- /dev/null +++ b/crates/raw/tests/sr2_heap.rs @@ -0,0 +1,79 @@ +//! Peak heap of reading Sony's `SR2Private` directory (`crates/raw/src/vendor/sr2.rs`), measured with dhat's testing +//! mode. Every ARW with `DNGPrivateData` has its `SR2SubIFD` located and decrypted, so the directory that points to +//! it must not cost memory sized by its other entries: a hostile file can give it fields that each alias most of the +//! file. Only the block's offset, length and key may be read. One test per binary (dhat's profiler is global). + +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// A 32 × 24 Sony CFA file of `size` bytes whose `SR2Private` directory holds the block's offset, length and key plus +/// `aliases` UNDEFINED fields that each cover the first `size` bytes of the file. Plain black level and white balance +/// are present, so nothing but the lens record needs the `SR2SubIFD`. +fn hostile_arw(size: usize, aliases: u16) -> Vec<u8> { + let o = ByteOrder::Little; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x7310, Value::Short(vec![512; 4])); + raw.set(0x7313, Value::Short(vec![2048, 1024, 1024, 1536])); + // a distortion setting, so the file has a lens record that says whether the SR2SubIFD was read + raw.set(0x7036, Value::Short(vec![0])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let mut file = TiffWriter::new(o, false).write(&[raw]).unwrap(); + let pointer = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file.resize(size, 0); + let private = file.len(); + let entries = 3 + aliases; + let block_at = private + 2 + 12 * usize::from(entries) + 4; + let block_len = 64u32; + o.put_u16(&mut file, entries); + for (tag, kind, count, value) in [(0x7200u16, 4u16, 1u32, block_at as u32), (0x7201, 4, 1, block_len)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, kind); + o.put_u32(&mut file, count); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend([0x11, 0x22, 0x33, 0x44]); + for i in 0..aliases { + o.put_u16(&mut file, 0x7230 + i); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, size as u32); + o.put_u32(&mut file, 0); + } + o.put_u32(&mut file, 0); + file.resize(block_at + block_len as usize, 0); + let mut at = Vec::new(); + o.put_u32(&mut at, private as u32); + file[pointer..pointer + 4].copy_from_slice(&at); + file +} + +#[test] +fn sr2_private_fields_cost_no_memory_sized_by_the_file() { + // eight fields of 16 MiB each: 128 MiB if they were decoded, within the TIFF reader's default value budget + let file = hostile_arw(16 << 20, 8); + let _profiler = dhat::Profiler::builder().testing().build(); + let header = lightcraft_raw::probe_info(&file).unwrap(); + let full = lightcraft_raw::decode(&file).unwrap(); + let peak = dhat::HeapStats::get().max_bytes; + // the block was still found and decrypted (its content is noise: no SR2SubIFD copies) + for info in [&header, &full.info()] { + let sony = info.lens_metadata.sony.as_ref().expect("a lens record"); + assert!(sony.sr2_read, "the SR2SubIFD was not read"); + } + eprintln!("peak heap {peak} bytes decoding a {} byte file", file.len()); + assert!(peak < 2 << 20, "peak heap {peak} bytes decoding a {} byte file", file.len()); +} diff --git a/crates/tiff/README.md b/crates/tiff/README.md index c691dd641..cfaaffffb 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -18,6 +18,10 @@ later the TIFF/DNG exporters. - `image::ImageInfo` — width/height, bits, samples, compression, photometric, planar config, predictor, sample format and the strip or tile chunk list (`chunks()`, `chunk_bytes()`); missing byte counts are inferred. +- `directory_entries`, `entry_span`, `ParseOptions::budget_for` and `decode_value` are the reader's own rules + (directory layout and entry limit, which entries it reads, its value budget, value decoding), for callers that + must select entries exactly as `parse_ifd_at` would without decoding the whole directory (`lightcraft-raw`'s + Sony `SR2Private` lookup). - `TiffWriter` writes IFD chains with strips or tiles, `SubIFDs` and pointer IFDs, in either byte order, classic or BigTIFF; `writer::rational` / `srational` approximate floats as rationals. diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index bafa3eb5e..1d0b74d0d 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -25,7 +25,7 @@ mod reader; pub mod tags; pub mod writer; -pub use reader::{ParseOptions, parse_ifd_at}; +pub use reader::{DirectoryEntries, EntrySpan, ParseOptions, decode as decode_value, directory_entries, entry_span, parse_ifd_at}; pub use writer::{IfdBuilder, ImageData, TiffWriter}; use serde::{Deserialize, Serialize}; diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index a644159b7..4a80a906b 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -26,6 +26,101 @@ impl Default for ParseOptions { } } +impl ParseOptions { + /// The value budget for `data_len` bytes of input: [`Self::value_budget`], else `8 × data_len + 1 MiB`. + pub fn budget_for(&self, data_len: usize) -> u64 { + self.value_budget.unwrap_or((data_len as u64).saturating_mul(8).saturating_add(1 << 20)) + } +} + +/// Where a directory's entries are and how many of them the reader reads, as [`directory_entries`] found them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DirectoryEntries { + /// Position of the first entry. + table: usize, + /// Entries read: the declared count, cut to the entries the data holds and to the `max_entries` limit. + read: usize, + /// The declared entry count. + declared: u64, + /// Bytes per entry: 12, or 20 in BigTIFF. + entry_size: usize, +} + +impl DirectoryEntries { + /// Positions of the entries read, in directory order. Checked arithmetic: the iteration ends rather than + /// overflow, whatever the layout. + pub fn positions(&self) -> impl Iterator<Item = usize> + use<> { + let (table, size) = (self.table, self.entry_size); + (0..self.read).map_while(move |i| i.checked_mul(size)?.checked_add(table)) + } + + /// The number of entries read. + pub fn read(&self) -> usize { + self.read + } + + /// The entry count the directory declares. + pub fn declared(&self) -> u64 { + self.declared + } +} + +/// The directory at `offset` as the reader walks it: its entry table and how many entries it reads. The same errors +/// as [`parse_ifd_at`] for a directory it can't read: past the data, empty, or without room for one entry. +pub fn directory_entries(data: &[u8], offset: u64, order: ByteOrder, bigtiff: bool, max_entries: usize) -> Result<DirectoryEntries> { + let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; + let (n, head, entry_size) = if bigtiff { + (order.read_u64(data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize) + } else { + (order.read_u16(data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12) + }; + let table = pos + head; + let fit = (data.len().saturating_sub(table) / entry_size) as u64; + let read = n.min(fit).min(max_entries as u64) as usize; + if n == 0 { + return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); + } + if read == 0 { + return Err(TiffError::Truncated(offset)); + } + Ok(DirectoryEntries { table, read, declared: n, entry_size }) +} + +/// A directory entry the reader can read: its tag, type and count, and where its values are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntrySpan { + pub tag: u16, + pub field_type: FieldType, + pub count: u64, + /// Position of the values in the data: the entry's own value field when they fit it, else `base` plus the offset + /// stored there. + pub start: u64, + /// `count × size` bytes. + pub len: u64, +} + +/// The entry at `pos`, when the reader reads it: a known field type, `count × size` without overflow, and values +/// inside `data`. Nothing is decoded; [`parse_ifd_at`] also drops an entry whose values exceed what is left of its +/// value budget ([`ParseOptions::budget_for`]), which this leaves to the caller. +pub fn entry_span(data: &[u8], pos: usize, order: ByteOrder, bigtiff: bool, base: u64) -> Option<EntrySpan> { + let tag = order.read_u16(data, pos)?; + let field_type = FieldType::from_u16(order.read_u16(data, pos.checked_add(2)?)?)?; + let (count, field, inline) = if bigtiff { + (order.read_u64(data, pos.checked_add(4)?)?, pos.checked_add(12)?, 8u64) + } else { + (order.read_u32(data, pos.checked_add(4)?)? as u64, pos.checked_add(8)?, 4) + }; + let len = count.checked_mul(field_type.size() as u64)?; + let start = if len <= inline { + field as u64 + } else { + let rel = if bigtiff { order.read_u64(data, field)? } else { order.read_u32(data, field)? as u64 }; + base.checked_add(rel)? + }; + let end = start.checked_add(len)?; + (end <= data.len() as u64).then_some(EntrySpan { tag, field_type, count, start, len }) +} + /// Parse the header: (byte order, bigtiff, first IFD offset). pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { header_with(data, true) @@ -97,7 +192,7 @@ struct Ctx<'a> { impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { - let budget = opts.value_budget.unwrap_or((data.len() as u64).saturating_mul(8).saturating_add(1 << 20)); + let budget = opts.budget_for(data.len()); Self { data, order, big, opts, visited: HashSet::new(), budget } } @@ -116,34 +211,18 @@ impl<'a> Ctx<'a> { if !self.visited.insert(offset) { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } - let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; - let (n, head, entry_size, inline) = if self.big { - (self.order.read_u64(self.data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize, 8usize) - } else { - (self.order.read_u16(self.data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12, 4) - }; - let table = pos + head; - let fit = (self.data.len().saturating_sub(table) / entry_size) as u64; - let n_read = n.min(fit).min(self.opts.max_entries as u64) as usize; - if n == 0 { - return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); - } - if n_read == 0 { - return Err(TiffError::Truncated(offset)); - } - let mut entries = Vec::with_capacity(n_read); - for i in 0..n_read { - let p = table + i * entry_size; - if let Some(e) = self.entry(p, base, inline) { + let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + let mut entries = Vec::with_capacity(dir.read); + for p in dir.positions() { + if let Some(e) = self.entry(p, base) { entries.push(e); } } entries.sort_by_key(|e| e.tag); entries.dedup_by_key(|e| e.tag); - // the next-IFD offset follows the whole table, so only a table read in full has one: a count far past the - // file (a BigTIFF's is 64-bit) must not reach the arithmetic (issue #1516) - let next = if n == n_read as u64 { - match n_read.checked_mul(entry_size).and_then(|len| table.checked_add(len)).and_then(|p| self.read_off(p)) { + // Only a fully read table has a next-IFD offset. Keep hostile BigTIFF counts out of the arithmetic (#1516). + let next = if dir.declared == dir.read as u64 { + match dir.read.checked_mul(dir.entry_size).and_then(|len| dir.table.checked_add(len)).and_then(|p| self.read_off(p)) { Some(0) | None => 0, Some(v) => base.checked_add(v).unwrap_or(0), } @@ -181,33 +260,19 @@ impl<'a> Ctx<'a> { ifd.interop = interop; } - fn entry(&mut self, p: usize, base: u64, inline: usize) -> Option<Entry> { - let o = self.order; - let tag = o.read_u16(self.data, p)?; - let typ = FieldType::from_u16(o.read_u16(self.data, p + 2)?)?; - let (count, field) = if self.big { (o.read_u64(self.data, p + 4)?, p + 12) } else { (o.read_u32(self.data, p + 4)? as u64, p + 8) }; - let total = count.checked_mul(typ.size() as u64)?; - let start = if total <= inline as u64 { - field as u64 - } else { - let rel = self.read_off(field)?; - base.checked_add(rel)? - }; - let end = start.checked_add(total)?; - if end > self.data.len() as u64 { + fn entry(&mut self, p: usize, base: u64) -> Option<Entry> { + let span = entry_span(self.data, p, self.order, self.big, base)?; + if span.len > self.budget { return None; } - if total > self.budget { - return None; - } - self.budget -= total; - let bytes = &self.data[start as usize..end as usize]; - Some(Entry { tag, value: decode(o, typ, bytes), offset: start }) + self.budget -= span.len; + let bytes = self.data.get(usize::try_from(span.start).ok()?..usize::try_from(span.start + span.len).ok()?)?; + Some(Entry { tag: span.tag, value: decode(self.order, span.field_type, bytes), offset: span.start }) } } -/// Decode `bytes` (exactly `count × size` long) as values of type `typ`. -pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { +/// Decode `bytes` (exactly `count × size` long, or the first values of an entry) as values of type `typ`. +pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { fn arr<const N: usize>(c: &[u8]) -> [u8; N] { let mut a = [0u8; N]; a.copy_from_slice(c); @@ -237,3 +302,24 @@ pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { FieldType::SLong8 => Value::SLong8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c)) as i64).collect()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directory_positions_never_overflow() { + // a hand-built layout the reader never produces: the iterator stops instead of overflowing + let hostile = DirectoryEntries { table: usize::MAX, read: 2, declared: 2, entry_size: 12 }; + assert_eq!(hostile.positions().collect::<Vec<_>>(), vec![usize::MAX]); + assert_eq!(hostile.positions().nth(1), None); + let wide = DirectoryEntries { table: usize::MAX - 30, read: 4, declared: 4, entry_size: 12 }; + assert_eq!(wide.positions().collect::<Vec<_>>(), vec![usize::MAX - 30, usize::MAX - 18, usize::MAX - 6]); + // a directory read from data lists its entries + let mut data = Vec::new(); + ByteOrder::Little.put_u16(&mut data, 2); + data.extend([0; 24]); + let dir = directory_entries(&data, 0, ByteOrder::Little, false, 8192).unwrap(); + assert_eq!(dir.positions().collect::<Vec<_>>(), vec![2, 14]); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..e5e0feb9d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated on any body (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A distortion only); status unchanged (🟡) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 375f773ab..53c5e9755 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,25 +1,137 @@ -# Sony embedded distortion corrections +# Sony embedded lens corrections -LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of -Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, -even when they contain a table with the same layout. -It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, -CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. +Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic +aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and **applies one**: the distortion table +of the **ILCE-7RM4A (A7R IVA)** in Bayer ARWs with native 3:2 framing, independently validated with the +FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS. Every other model, and shading and CA on every model, are +decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +(`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in +`crates/raw/src/vendor/arw_lens.rs`) for diagnostics and for the validation work described below. -This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. -The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and -FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may -retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry -only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. -The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing -embedded-profile default (enabled); the profile correction control can disable or adjust it. +The applied distortion is converted into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction +control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. Linear YCbCr ARWs +and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial +normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain +uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off +setting does not erase its table. Newly imported photos use LightCraft's existing embedded-profile default +(enabled); the profile correction control can disable or adjust it. + +## What is decoded + +| Component | Raw-IFD setting / table | `SR2SubIFD` setting / table | Accepted layout | +|---|---|---|---| +| Shading | `0x7031` / `0x7032` | `0x797c` / `0x797d` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | +| Lateral CA | `0x7034` / `0x7035` | `0x797f` / `0x7980` | SHORT\[1\]; SSHORT\[33\]: 32, then two 16-sample curves | +| Distortion | `0x7036` / `0x7037` | `0x7981` / `0x7982` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | + +- **Bounded, exact reading.** A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses: + its first occurrence, within the reader's limits (8,192 entries per directory, a total value budget). The directory + is also scanned, in either byte order and in BigTIFF, for diagnostics only: a tag that occurs more than once is + flagged (`raw_ifd_repeated`, `sr2_repeated`), and an entry the reader left out is reported with its reason (values + outside the file, an unknown type, or past the reader's limits) rather than treated as absent. `SR2SubIFD` fields + are read straight from their first entry. A setting may also be a nonnegative SSHORT (the documented editor type + differs from the type the cameras store). Anything else is refused with its reason (`SonyFieldError`: field type, + count, leading sample count, values outside the file or block, past the reader's limits, a negative setting). + Nothing is allocated from a count in the file. +- **Raw IFD first, `SR2SubIFD` as a checked fallback.** The raw IFD's copy wins. The `SR2SubIFD`'s copy is used only + when the raw IFD lacks the tag; a malformed raw-IFD copy is reported, never silently replaced. When both copies are + well-formed their agreement is recorded (`copies_agree`), and on disagreement the raw IFD's copy is used. The block + is decrypted (by the existing `vendor/sr2.rs`) for every ARW that has one, so the check always runs; a block that + can't be found or decrypted gives no copies. Locating it selects the `SR2Private` directory's offset, length and + key exactly as the TIFF reader would (the reader's own directory layout, 8,192-entry limit, readable-entry rules + and value budget, then the first readable occurrence of each), but decodes only each field's first value, so the + directory's other entries cost no memory however large they claim to be; the block itself is limited to 1 MiB. The + plain tag numbers are not looked up inside the `SR2SubIFD`. +- **Settings keep their number and a documented meaning:** shading 256 Off, 257 and 272 Auto, 511 unavailable; CA + 0 Off, 1 Auto, 255 unavailable; distortion 0 Off, 1 Auto, 17 Auto fixed by the lens, 255 unavailable. Any other value + is `Unknown(value)`, never Off (the A7CR's Low shading setting has no documented number). Values are not read as + bit masks. Off does not mean absent: the table is still recorded. +- **Only the 16-sample (CA: 2 × 16) layout is interpreted.** Many bodies store 11 (CA: 22) samples padded into the + same 17 (33) values: older compacts and bodies such as the DSC-RX100 III and ILCE-3500, and the ILCE-7M4's APS-C + captures (its full-frame captures carry 16). Those tables are refused as `CountPrefix(11)` and stay + uninterpreted. An all-zero table is kept: it is a valid no-op, not a sign of an unsupported lens. +- **Framing evidence travels with the tables:** the stored frame (padding included), Sony's crop tags and the DNG + default crop as recorded, the image crop, the reference rectangle the distortion conversion is centred on and + normalized to (the default crop when present, else the image crop), compression, whether the data is linear + YCbCr (lossless M/S), and whether both crops are native 3:2. +- **Per-component status.** `Applied` (converted into an opcode), `NoTable`, `NotValidatedComponent` (shading and CA + on every body), `NotValidatedModel`, `NotValidatedMode` (linear YCbCr, non-3:2 framing, or a distortion table found + only in the `SR2SubIFD`), `Neutral` (all-zero table in the validated scope) and `Rejected` (the validated + conversion refused the table). A readable table never implies a usable correction; only `Applied` components + become opcodes. + +The applied path is unchanged: the validated conversion still receives the raw IFD's `0x7037` table exactly as the +TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps +the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but +never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh +decode. + +## What each component still needs + +The following summarizes an investigation on the ILCE-7CR (A7CR): an owner's collection of 131 raws with four +lenses (Voigtländer APO-LANTHAR 28mm F2 and 50mm F2, both manual focus; Sony FE 24–50mm F2.8 G; Tamron 35–150mm +F2–2.8), plus the three public raw.pixls.us A7CR samples. No private photographs, names or derived images are +part of this repository. + +- **Inventory.** All 131 raws carry all three tables in both the raw IFD and the `SR2SubIFD`, and the copies agree + in every file; all four lenses provide tables, the manual-focus ones included. Shading is 272 (Auto) and CA 1 + (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. + Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) + storage. The decoder reproduces this inventory exactly (see *Local inventory check*). +- **Distortion.** The table layout and crop tags match the A7R IVA's, but its interpretation applied unchanged does + not reproduce the A7CR camera JPEG at 24 mm: at 2376 px wide, median / 95th-percentile feature residuals were + 1.61 / 6.17 px (12.98 / 51.19 px uncorrected, 6,539 matches), with a signed radial pattern (about +1.7 px at + radii 0.25–0.5, −7.4 px at 0.8–1) that no translation removes. At 50 mm it agrees (0.23 / 1.05 px, from + 2.21 / 12.25 px). Scaling the raw table by about 10/9, or using the MakerNote's distortion array instead, brings + 24 mm to about 0.37 / 1.10 and 0.33 / 0.97 px. Those are exploratory comparisons on two files against the camera + JPEG, not a model: the MakerNote values are roughly 1.11 times the raw table without one exact ratio, and the + A7R IVA was validated against a different reference renderer (Imaging Edge exports). Needed: A7CR Imaging Edge + On/Off exports at 24 and 50 mm and with another lens, compared with the raw table at its fixed scale; a decision on + the reference (camera JPEG or Imaging Edge); then ILCE-7CR added to the validated models for the full-frame Bayer + 3:2 mode only. Neither a 10/9 multiplier nor the MakerNote table is used. +- **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius + convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six + natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading + Off / Auto / Low at several apertures and focal lengths, raw CFA ratios between them, and Imaging Edge On/Off + exports, to fix the gain function and processing stage and rule out double correction. +- **Lateral CA.** A public analysis reads the table as a red, then a blue curve relative to green, at a 2⁻²¹ + relative radial scale. On three distortion-Off files that candidate reduced red and blue feature residuals + modestly (for example 0.35 → 0.29 px median for red with the 28 mm), which supports investigating it but certifies + neither scale, interpolation nor processing stage. Two product changes come before it: embedded CA must be + independent of the distortion amount (today `pipeline::optics` scales the whole embedded per-plane warp with the + profile distortion control), and the red and blue planes must be resampled in camera RGB, before the camera colour + model mixes them (`engine::files::load_bytes_now` applies the colour model before the optics stage). Then + validation on full-resolution achromatic edges. +- **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay + excluded. + +## Local inventory check + +`crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a +directory of ARWs it decodes each from its headers and prints its record (settings, sources, copies' agreement, +sample ranges, statuses); `LIGHTCRAFT_SONY_LENS_FULL=1` also compares header and full decode. With +`LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory it checks every file's table counts, sizes, sample counts, +minima and maxima, raw-IFD versus `SR2SubIFD` agreement, settings (numbers and labels), stored size, compression and +crop tags. A file with a row must decode, and every file must have a row, whether it decodes or not. Any decode +error fails the check except an unsupported variant, which is counted and must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` +(default 0). On the 131 A7CR raws above, all 131 inventory rows reproduce and every file decodes. On 133 public Sony +ARWs (127 raw.pixls.us samples from 100 bodies, four corpus copies and two files attached to a public issue), 110 carry +a record, 14 decode without one, and 9 are unsupported variants (eight packed ARWs of older bodies and one ARW +without a CFA image); header and full decode agree on the 124 that decode, and no record has disagreeing copies. ## Clean-room evidence -The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies -`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples. -No external photo decoder implementation or Adobe lens profile was consulted. -Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies. +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) names the raw-IFD tags +`0x7031` to `0x7037` (VignettingCorrection / VignettingCorrParams, ChromaticAberrationCorrection / +ChromaticAberrationCorrParams, DistortionCorrection / DistortionCorrParams) with their setting values, and gives the +tables as 17 or 33 signed 16-bit integers whose first value is the count. The +[Sony SR2SubIFD documentation](https://exiftool.org/TagNames/Sony.html#SR2SubIFD) names the encrypted copies +`0x797d`, `0x7980` and `0x7982`; the setting tags next to them (`0x797c`, `0x797f`, `0x7981`) are unnamed there and +were identified by inspecting files, where they equal the raw-IFD settings. The corpus test's expected tables are the +values ExifTool, run as a black-box tool, prints for the public samples. No external photo decoder implementation, +lens database or Adobe lens profile was consulted. Sony Imaging Edge Edit 4.1 was used only as a black-box reference +on local scratch copies. The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: @@ -45,7 +157,8 @@ so the warp stays on the optical centre either way. On the A7R IVA that framing pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export). The main raw-IFD table is important: the similarly named MakerNote table has different values and -must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files. +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files, and the +`SR2SubIFD` copy is never converted. ## Validation @@ -90,7 +203,18 @@ The distortion geometry remains aligned while every body's image framing follows Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, -and preservation through DNG export. No private photographs or reference TIFFs are committed. +and preservation through DNG export. The decoder's synthetic tests (`crates/raw/src/vendor/arw_lens.rs`) add both +byte orders and BigTIFF, exact SHORT / SSHORT types, negative samples, missing, short, long, huge and zero counts, +wrong leading counts, values outside the file, a table past the TIFF reader's entry limit (not applied), repeated +tags (the reader's copy kept, as before), unknown and negative settings, raw-IFD versus +`SR2SubIFD` precedence and agreement, the correct `SR2SubIFD` tag numbers, a wrong decryption key, hostile entries +inside the decrypted block, linear YCbCr, framing evidence (Sony's crop at x = 0 against the default crop, agreeing +crops, padded storage, aspect crops, crop tags outside the frame), all eight EXIF orientations leaving the record +unchanged, and neutral, out-of-range and folding tables. On a small classic-TIFF fixture in both byte orders, every +truncation and four mutations of each byte outside the pixel data (set to 0x00 and 0xff, top and low bit flipped) +decode or fail without a panic. `tests/sr2_heap.rs` measures the peak heap of decoding a 16 MiB file whose +`SR2Private` directory holds eight fields aliasing the whole file: 37 kB, against 134 MB when those fields were +decoded. No private photographs or reference TIFFs are committed. ## Camera coverage and regression boundary @@ -99,27 +223,35 @@ model names, and every other model remain unsupported by this distortion decoder numbers and table lengths do not establish the scale, interpolation or crop normalization on another body. Support can expand after independent geometry validation; do not add model-name prefix matching. -The current `cargo xtask corpus --download` manifest contains 23 Sony CC0 samples across 17 bodies, -including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm). All 23 files have -pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_to_validated_models`: +The current `cargo xtask corpus --download` manifest contains 25 Sony CC0 samples across 18 bodies, +including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm), each with a pinned SHA-256 that the +download checks. `sony_embedded_distortion_is_limited_to_validated_models` checks 23 of them (the two ILCE-7CM2 +codings have their own test), and `sony_lens_records_of_the_public_samples` verifies all 25 against their pins +(`SONY_SAMPLES` in `crates/raw/tests/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) +before decoding. **No CI job fetches the corpus**: these fixture tests run only where it has been downloaded, and +otherwise skip each absent file. `LIGHTCRAFT_CORPUS_REQUIRED=1` makes `sony_lens_records_of_the_public_samples` +fail on any absent pinned file instead. -| Camera | Corpus samples | Raw-IFD table observed | Expected correction | +| Camera | Corpus samples | Decoded record: tables, settings (shading / CA / distortion) | Applied | |---|---|---|---| -| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | -| ILCE-9M2 | Compressed (3989) | 16 samples | None | -| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | -| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None | -| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | -| DSC-RX100 | One | No raw-IFD table | None | -| DSC-RX100M3 | One | 11 samples | None | -| ILCE-7CR | Compressed, lossless L/M | Not used; model excluded | None | -| ILCE-7RM4 | 14-bit compressed | Not used; model excluded | None | -| DSLR-A500, DSLR-A700, SLT-A33 | One each | Not used; model excluded | None | -| ILCE-3500, ILCE-7S | 5600 K, Shade respectively | Not used; model excluded | None | -| ILCE-7SM2, DSLR-A580 | 16:9 framing, one each | Not used; model excluded | None | -| DSLR-A900 | Packed 12-bit | Not used; model excluded | None | +| ILCE-7RM4A | Compressed (4822) | All three tables; 257 / 1 / 17 | One distortion warp | +| ILCE-9M2 | Compressed (3989) | All three; 257 / 1 / 0 | None | +| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | None | +| ILCE-7M4 | 14-bit, lossless L/M/S | L/M/S: all three, 272 / 1 / 17; the 14-bit file (APS-C framing, 4608 × 3072): settings only, its 11-sample tables are not interpreted | None | +| ILCE-7RM2 | 12-bit uncompressed | All three; 256 / 0 / 1 | None | +| DSC-RX100 | One | Shading, from the `SR2SubIFD` (the raw IFD has no tables); CA and distortion in the 11-sample layout | None | +| DSC-RX100M3 | One | Shading; CA and distortion in the 11-sample layout | None | +| ILCE-7CR | Compressed, lossless L/M | All three; 272 / 1 / 0 | None | +| ILCE-7RM4 | 14-bit compressed | All three; 257 / 1 / 17 | None | +| ILCE-3500 | 5600 K | Settings only (unavailable) | None | +| ILCE-7S | Shade | An all-zero CA table; settings unavailable | None | +| ILCE-7SM2 | 16:9 framing | All-zero shading and CA tables; settings unavailable | None | +| DSLR-A500, DSLR-A700, SLT-A33, DSLR-A580, DSLR-A900 | One each | No record | None | -These are **model-boundary regression checks, not new geometric validation of those models**. In particular, +These are **model-boundary regression checks, not new geometric validation of those models**. +`sony_lens_records_of_the_public_samples` also checks the exact tables, settings, provenance and framing evidence of +the three A7CR codings and the A7R IVA sample, and that across the Sony corpus only the A7R IVA's distortion is ever +applied. In particular, refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S files are linear YCbCr and remain excluded independently of the model check. Header and full decode must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, From c03eb8222f08faddba0ae18275be3e6e69791847 Mon Sep 17 00:00:00 2001 From: Alex Chambers <alexchambers91@gmail.com> Date: Mon, 12 Oct 2026 20:24:17 +0200 Subject: [PATCH 121/315] LR-IMP-CAMERA-COVERAGE: verify 30 public camera models Recheck all 54 files in the 41-model shortlist on main 25ef65c8. Pin 40 accepted files by SHA-256 and full decoded sensor FNV-1a, and record their camera-image correlation. Exclude eleven candidate models after checking identity, camera module, provenance and references. Update generated coverage and tracking documents to 265 verified models. Keep Nikon coding limits and the own-matrix DNG rendering issue explicit. Co-Authored-By: Codex <noreply@openai.com> --- ROADMAP.md | 15 +- crates/raw/tests/corpus.rs | 10 +- crates/raw/tests/coverage_sweep_corpus.rs | 66 +++++++ docs/gaps.md | 3 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 56 +++--- docs/raw/coverage-sweep.tsv | 45 +++++ docs/raw/coverage.tsv | 60 +++---- docs/raw/evidence.tsv | 30 ++++ docs/target-app-parity.md | 13 +- xtask/src/main.rs | 201 ++++++++++++++++++++++ 11 files changed, 434 insertions(+), 70 deletions(-) create mode 100644 crates/raw/tests/coverage_sweep_corpus.rs create mode 100644 docs/raw/coverage-sweep.tsv diff --git a/ROADMAP.md b/ROADMAP.md index 3f02a1199..9bc5d53ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Leica Q3 verified; EOS R5 Mark II CR3 verified; RAW decoding 77%; 30 models verified on 40 CC0 files; 267 verified models, 18%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are | | Value | Kind | |---|---:|---| -| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | +| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | | **Feature breadth** (weighted by use) | **~77%** | estimated | | **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | | **Mainstream practitioner** | **~49%** (44–54%) | estimated | @@ -44,8 +44,8 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| -| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| Features | ~61% | ~77% (checklist 81.6%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 82% of 1,446 Adobe models decode, 18% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see | Area | Ready | Breadth | Remaining | |---|---:|---:|---:| | Import and library | 80% | 90% | 25–45 h | -| RAW decoding and camera coverage | 77% | 81% | 120–220 h | +| RAW decoding and camera coverage | 77% | 82% | 120–220 h | | Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h | | Global develop adjustments | 70% | 93% | 30–60 h | | Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h | @@ -114,6 +114,10 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor + hashes pinned. Total: 267 verified models (18%); 43% of photographers on a verified body. + Eleven candidates excluded after rechecking identity, provenance and camera-image agreement. + Phone and drone DNG tone differences remain tracked in #1277. - 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a @@ -164,6 +168,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277). Refreshed the stale checklist summary: 81.6% across 532 rows | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index a96165b5e..20d0959d7 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,7 +1,8 @@ //! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with //! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent. //! -//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report +//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF, +//! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). @@ -41,7 +42,7 @@ fn corpus_raw_decodes() { let t0 = Instant::now(); let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; - // DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG, + // DNG and generic CFA TIFF previews can be absent or uncompressed RGB; other vendor raws embed a JPEG, // except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks. if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); @@ -51,7 +52,10 @@ fn corpus_raw_decodes() { let hevc_preview = fmt == RawFormat::Cr3 && lightcraft_meta::cr3::parse_cr3(&bytes) .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview"); + assert!( + matches!(fmt, RawFormat::Dng | RawFormat::CfaTiff) || name.starts_with("raw-panasonic-") || hevc_preview, + "{name}: no embedded preview" + ); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); diff --git a/crates/raw/tests/coverage_sweep_corpus.rs b/crates/raw/tests/coverage_sweep_corpus.rs new file mode 100644 index 000000000..78f384651 --- /dev/null +++ b/crates/raw/tests/coverage_sweep_corpus.rs @@ -0,0 +1,66 @@ +//! Sensor regressions for the public camera-coverage sweep. +//! +//! `docs/raw/coverage-sweep.tsv` records the file URLs, SHA-256 hashes, decoded dimensions, +//! FNV-1a sensor hashes and camera-image comparisons measured on main 25ef65c8. +//! Sensor hashes pin LightCraft's own output, not an independent decoder's output. +//! Hash input is every row-major sample in little-endian order, including masked borders. +//! Fetch the CC0 files with `cargo xtask corpus --download`. Missing files are reported. + +use lightcraft_raw::{RawData, decode, probe_info}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv"); + +#[test] +fn coverage_sweep_sensor_decodes_are_pinned() { + let corpus = + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + let mut checked = 0; + for line in RECORDS.lines().filter(|line| !line.starts_with('#')).skip(1) { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!(columns.len(), 14, "invalid coverage record: {line}"); + let name = columns[3]; + let path = corpus.join("raw").join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + continue; + } + Err(error) => panic!("{}: {error}", path.display()), + }; + assert_eq!(format!("{:x}", Sha256::digest(&bytes)), columns[5], "{name}: file SHA-256"); + let raw = decode(&bytes).unwrap_or_else(|error| panic!("{name}: {error}")); + raw.validate().unwrap(); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let dimensions: Vec<usize> = columns[6..9].iter().map(|value| value.parse().unwrap()).collect(); + assert_eq!([raw.width, raw.height, raw.cpp], dimensions.as_slice(), "{name}: sensor dimensions"); + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut feed = |byte: u8| hash = (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3); + match &raw.data { + RawData::U16(samples) => { + assert_eq!(columns[9], "u16", "{name}: sample type"); + for sample in samples { + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + RawData::F32(samples) => { + assert_eq!(columns[9], "f32", "{name}: sample type"); + for sample in samples { + assert!(sample.is_finite(), "{name}: non-finite sample"); + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + } + assert_eq!(format!("{hash:016x}"), columns[10], "{name}: sensor samples changed"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + checked += 1; + eprintln!("checked {name}: {}x{}, {hash:016x}", raw.width, raw.height); + } + eprintln!("camera-coverage sweep: {checked} files checked"); +} diff --git a/docs/gaps.md b/docs/gaps.md index ee6e58d1c..6871a3485 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A distortion and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 267 of 1,446 Adobe models verified (18%), ~43% of photographers on a verified body; Nikon 9 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). The public sweep adds 30 models on 40 pinned CC0 files; own-matrix DNG tone remains different (#1277). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..23e96b393 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -80,8 +80,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde267 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: 151 CC0 corpus files, including 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us sweep: 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned; 267 of 1,446 models verified (18%), 917 unverified, 226 preview-only and 36 unsupported. Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277). Refreshed the stale checklist summary: 81.6% across 532 rows | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 811353a70..52164d50b 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -14,15 +14,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **236** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **941** (65%) | measured from the format rules (631 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **232** (16%) | measured | +| Decoded from sensor data and **verified** on a real file | **267** (18%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **917** (63%) | measured from the format rules (611 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **226** (16%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 83 / 639 / 24 / 8 of 754 | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 106 / 618 / 22 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | +| …of which on a body we have verified | **~43%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | @@ -43,7 +41,7 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 151 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue @@ -53,6 +51,17 @@ committed table is stale. at 400 px; the files are not committed). Bulk-check body lists were matched to Adobe names through the raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) are not counted as verified. +- **2026-10-12 public sweep:** 30 further models on 40 CC0 files, independently downloaded and + decoded on main `25ef65c8`; [raw/coverage-sweep.tsv](raw/coverage-sweep.tsv) records the URLs, + SHA-256, dimensions, FNV-1a sensor hashes and render/reference luminance correlations (0.6801 + to 0.9998). The hashes pin LightCraft's own output. The regression test is + `crates/raw/tests/coverage_sweep_corpus.rs`; downloads are in `RAW_SAMPLES`. Ten references are + embedded TIFF images; the rest are JPEGs. FFF TIFF previews are not display-ready and check + image structure only. Eleven candidate models were excluded for identity, provenance or + missing/mismatched reference images. D6 / Z 6 / Z 7 verification covers compressed NEF only; + uncompressed remains preview-only (#671), as do Z 8 HE / HE* files. Own-matrix phone, drone + and other DNGs can decode correctly while rendering darker than their camera JPEG (#1277). + Verified means sensor data decoded, not colour parity. - **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market data (Nikkei production: Canon ~36%, Sony ~29%, Nikon ~14%, Fujifilm ~12% of mirrorless; BCN retail: Sony 29.9%, Canon 27.4%, Nikon 15.1%), adjusted for the DSLR installed base and for the makers named @@ -69,23 +78,23 @@ committed table is stale. |---|---:|---:|---:|---:|---:|---| | Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | | Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | -| Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | -| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | +| Samsung | 128 | 7 | 112 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | +| Apple | 113 | 4 | 109 | 0 | 0 | DNG / ProRAW | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | -| Nikon | 99 | 5 | 94 | 0 | 0 | — (HE / HE★ is a per-shot setting, preview-only) | +| Nikon | 99 | 9 | 90 | 0 | 0 | HE / HE* and some uncompressed NEFs remain preview-only | | Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | -| Google | 88 | 1 | 87 | 0 | 0 | — | +| Google | 88 | 5 | 83 | 0 | 0 | DNG | | Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | | Leica | 61 | 14 | 47 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | -| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | +| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | -| DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | +| DJI | 21 | 4 | 17 | 0 | 0 | DNG | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **236 (16%)** | **941 (65%)** | **233 (16%)** | **36 (2%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 3 | 11 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | +| **All** | **1,446** | **267 (18%)** | **917 (63%)** | **226 (16%)** | **36 (2%)** | | ## By use (estimated) @@ -93,16 +102,16 @@ committed table is stale. |---|---:|---:|---:|---| | Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | +| Nikon | 16% | 92% | 19% | HE and some uncompressed NEFs remain preview-only; nine bodies verified | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | -| Phones | 3% | 93% | 1% | DNG | +| Phones | 3% | 93% | 3% | DNG | | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 96% | 21% | the Q3 verified | -| DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **85%** | **42%** | | +| DJI | 1% | 96% | 19% | | +| Medium format and others | 0.5% | 27% | 6% | IIQ, 3FR, X3F | +| **Weighted** | 100% | **85%** | **43%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually checked, and Canon, the largest bucket, is the weakest of the big four. @@ -144,9 +153,9 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | | DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | -| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | +| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | | DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | @@ -272,6 +281,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277) | | 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | | 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | | 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | diff --git a/docs/raw/coverage-sweep.tsv b/docs/raw/coverage-sweep.tsv new file mode 100644 index 000000000..8e3b2449a --- /dev/null +++ b/docs/raw/coverage-sweep.tsv @@ -0,0 +1,45 @@ +# CC0 raw.pixls.us camera-coverage check, 2026-10-12; main 25ef65c8. File SHA-256 matches the public listing. +# FNV-1a-64 pins LightCraft's own complete decoded samples, row-major little-endian, including masked borders. +# Default renders at up to 1200 px; Pearson r on Lab L* at 200 px after orientation/aspect alignment, threshold 0.6. +# Hasselblad PreviewTIFF references check image structure only. Own-matrix DNG tone remains subject to issue #1277. +maker camera id file url sha256 width height cpp samples fnv1a64 luma_r reference notes +Apple iPhone 6s Plus 915 coverage-915.dng https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG 845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c 4032 3024 1 u16 17e4d3c620d593c9 0.9712 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -10.109 +Apple iPhone 7 Plus 939 coverage-939.dng https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e 4032 3024 1 u16 d0095a8b553f0f8a 0.9904 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.913 +Apple iPhone XS 3372 coverage-3372.dng https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng 50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6 4032 3024 1 u16 a8e819ac4e5beb2a 0.9805 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -14.751 +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) 3009 coverage-3009.dng https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG 7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec 5568 3648 1 u16 b207313fa7dee601 0.9781 embedded JPEG 16bit (3:2); comparison Normal/crop-render; mean dL -8.41 +DJI DJI Mavic 3 (Hasselblad L2D-20c) 5140 coverage-5140.dng https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4 5376 3956 1 u16 2c78ceb8cb893ff1 0.8618 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL 0.418 +DJI DJI Mavic 4 Pro (DJI FC9287) 8041 coverage-8041.dng https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG 901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0 8192 4608 1 u16 d1babb4646097d6f 0.6801 embedded JPEG 16bit (16:9); reference has a different distortion/crop; comparison Normal/crop-render; mean dL 2.583 +DJI DJI Mini 2 4785 coverage-4785.dng https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG 21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851 4000 3000 1 u16 d1e66e08f335b55b 0.9328 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -9.016 +DJI DJI Mini 2 4898 coverage-4898.dng https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d 4000 2250 1 u16 acaab4fe42d3dc58 0.9716 embedded JPEG 16bit (16:9); comparison Normal/crop-render; mean dL -8.153 +Google Pixel 4 XL 3502 coverage-3502.dng https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng 0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54 3700 2774 1 u16 d121f233fdbaff2c 0.9143 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.351 +Google Pixel 4a Rear Wide Camera 4176 coverage-4176.dng https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng 11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be 4032 3024 1 u16 ac056154d440cb2d 0.9515 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.361 +Google Pixel 6 Pro Rear Main Camera 5961 coverage-5961.dng https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng 3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d 4080 3072 1 u16 96631250f12729ad 0.944 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.402 +Google Pixel 8 Pro Wide Camera* 7751 coverage-7751.dng https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng 45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5 8160 6144 1 u16 5e30a4d3bfc914b3 0.9485 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -5.099 +Hasselblad CFV-50c 7217 coverage-7217.3fr https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0 8374 6304 1 u16 14e60b95775930f2 0.9984 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -14.199 +Hasselblad CFV-50c 7218 coverage-7218.fff https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932 8374 6304 1 u16 db63ffcd176381ee 0.9443 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.828 +Hasselblad H5D-40 1640 coverage-1640.fff https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff 209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832 7410 5586 1 u16 1d34f6f4e45b5eab 0.9574 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 29.541 +Hasselblad H5D-50c 1639 coverage-1639.fff https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff 1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0 8374 6304 1 u16 02a189d2a6b78d4e 0.9453 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.943 +Hasselblad X1D 2058 coverage-2058.3fr https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR 395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3 8384 6304 1 u16 0b480efe4d2ed7c2 0.9753 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -9.059 +Hasselblad X1D II 50C 3874 coverage-3874.fff https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff 8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794 8384 6304 1 u16 c7372897d70da8a8 0.9436 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 22.474 +Hasselblad X1D II 50C 3941 coverage-3941.3fr https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR 809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47 8384 6304 1 u16 3c3a77bf67197196 0.9982 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.176 +Hasselblad X2D 100C 6568 coverage-6568.fff https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff 44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc 11904 8842 1 u16 849906d491fba55a 0.9196 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 26.781 +Hasselblad X2D 100C 6565 coverage-6565.3fr https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280 11904 8842 1 u16 c07a267c0bc60a5f 0.9962 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.632 +Nikon D6 4726 coverage-4726.nef https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233 5584 3728 1 u16 272425c271bb0d76 0.9557 embedded JPEG 12bit 12bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.037 +Nikon D6 4720 coverage-4720.nef https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF 827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f 5584 3728 1 u16 1d6a9f10a502fb14 0.9581 embedded JPEG 14bit 14bit compressed (Lossless) (3:2); comparison Normal/crop-render; mean dL 0.048 +Nikon D6 4721 coverage-4721.nef https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838 5584 3728 1 u16 2d6c8af38e4a0ed4 0.9592 embedded JPEG 14bit 14bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.043 +Nikon Z 6 3584 coverage-3584.nef https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF 3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886 6064 4040 1 u16 a63b22e83bb7c82f 0.8749 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 0.327 +Nikon Z 6 3581 coverage-3581.nef https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF 8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02 6064 4040 1 u16 1e23d74a11c01a97 0.8752 embedded JPEG 14bit 14bit compressed (3:2); comparison Normal/crop-render; mean dL 0.353 +Nikon Z 6 3582 coverage-3582.nef https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31 6064 4040 1 u16 0a0bc73568e125e0 0.8755 embedded JPEG 14bit 14bit lossless compressed (3:2); comparison Normal/crop-render; mean dL 0.464 +Nikon Z 7 2792 coverage-2792.nef https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded 8288 5520 1 u16 c989690cb3e19ef9 0.7417 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.417 +Nikon Z 7 2789 coverage-2789.nef https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e 8288 5520 1 u16 627867ac6a8dd4ad 0.7412 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.299 +Nikon Z 7 2790 coverage-2790.nef https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114 8288 5520 1 u16 8a52e6f3d8ff64dd 0.7389 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.585 +Nikon Z 8 6617 coverage-6617.nef https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40 8280 5520 1 u16 716e1158bab28b76 0.9563 embedded JPEG 8bit 8bit compressed (3:2); comparison Normal/crop-render; mean dL 2.044 +Parrot Anafi 2912 coverage-2912.dng https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6 5344 4016 1 u16 07a188c932a25a31 0.9895 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -3.924 +Samsung Galaxy S21 Ultra Rear Main Camera 6728 coverage-6728.dng https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng 5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e 4000 3000 3 u16 16c5d6faed83390d 0.9912 embedded JPEG 4:3; comparison Rotate180/crop-render; mean dL -10.83 +Samsung Galaxy S22 Ultra Rear Wide Camera 6261 coverage-6261.dng https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng 53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581 4000 3000 3 u16 7cbe612adc5575b4 0.9739 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -20.487 +Samsung Galaxy S23 Rear Wide Camera 6697 coverage-6697.dng https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng 1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067 8160 6120 3 u16 b3da1535e2e31e9f 0.9418 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -6.585 +Samsung Galaxy S7 Edge 1297 coverage-1297.dng https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng 843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561 4032 2268 1 u16 7e99d7da67022e5a 0.8677 exiftool:PreviewTIFF 16bit (16:9); comparison Normal/crop-render; mean dL 5.772 +Samsung Galaxy S8 2645 coverage-2645.dng https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng 6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2 4032 3024 1 u16 b70ca9a743283754 0.9884 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -4.112 +Samsung Galaxy S8+ 2310 coverage-2310.dng https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng 3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c 4032 3024 1 u16 c3a20523ad3b18cf 0.986 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL 2.437 +Sigma fp 7273 coverage-7273.dng https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG 0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9 6064 4042 1 u16 f7b92f1904d6cc31 0.9754 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 7.1 +Sigma fp L 6949 coverage-6949.dng https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG 8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70 9536 6344 1 u16 6896f73ec6b49231 0.9998 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 1.597 diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index 7d224dc6e..7480123c1 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -97,16 +97,16 @@ Apple iPad Pro 12.9-inch (2018 model) DNG 2018 unverified DNG decoder (uncompres Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad mini (6th Generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 6s DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 6s Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 6s Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 7 Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 7 Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 8 Plus DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (2020 version) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone X DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone XR DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone XS DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XS DNG 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone XS Max DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad Air (5th generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (3rd generation) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -274,9 +274,9 @@ Casio EXILIM EX-ZR4100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, loss Casio EXILIM EX-ZR5000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-ZR5100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Contax N Digital RAW 2003 preview RAW is opened from its embedded JPEG only -DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI AIR 2S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mini 2 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 2 DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Air 3 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mini 3 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -287,13 +287,13 @@ DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncom DJI DJI Mavic 3 Pro Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Osmo Pocket 3 D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Osmo Action 5 Pro DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only @@ -407,7 +407,7 @@ Google Pixel 3 XL DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, De Google Pixel 3 XL Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 XL Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4 XL DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4 XL DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -415,7 +415,7 @@ Google Pixel 6 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, Google Pixel 6 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 6 Pro Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Main Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 6 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -426,7 +426,7 @@ Google Pixel 7 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompresse Google Pixel 7 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4a Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4a Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Ultra Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -442,7 +442,7 @@ Google Pixel 8 Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, Google Pixel 8 Pro Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Telephoto Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 8 Pro Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Wide Camera* DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 8a Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -502,7 +502,7 @@ Hasselblad CF-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-39 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFV 3FR 2008 preview 3FR is opened from its embedded JPEG only -Hasselblad CFV-50c 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFV-50c 3FR 2008 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H2D DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Hasselblad H2D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H2D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only @@ -515,18 +515,18 @@ Hasselblad H3DII-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H3DII-50 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-40 3FR 2010 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-60 3FR 2010 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-40 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-40 3FR 2012 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-50 3FR 2012 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-50c 3FR 2013 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D 3FR 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D II 50C 3FR 2018 preview 3FR is opened from its embedded JPEG only -Hasselblad X2D 100C 3FR 2021 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D II 50C 3FR 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C 3FR 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Huawei Honor 20 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -723,7 +723,7 @@ Nikon D40 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy Nikon D40x NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D50 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D60 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D6 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D6 NEF 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon D70 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D70S NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon D80 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only @@ -766,12 +766,12 @@ Nikon Z 50 II NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lo Nikon Z 5 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon Z 6 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon Z 7 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 8 NEF 2022 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 8 NEF 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only Nikon Z 9 (*See note) NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z fc NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -1007,7 +1007,7 @@ Panasonic LUMIX DC-S1M2ES (Final Support) RW2 2024 verified PR #215: 178 raw.pix Panasonic LUMIX DC-S1M2 RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decoder, every raw format Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format -Parrot Anafi DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Parrot Anafi DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax 645D DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax 645Z DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1127,16 +1127,16 @@ Samsung Galaxy S21 Rear Main Camera DNG 2020 unverified DNG decoder (uncompresse Samsung Galaxy S21 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S21 Ultra Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 Edge DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S7 Edge DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S7 Edge DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S9 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S9+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Z Fold3 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1154,11 +1154,11 @@ Samsung Galaxy S22 Ultra Front Camera DNG 2021 unverified DNG decoder (uncompres Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S23 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23+ Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1230,8 +1230,8 @@ Sigma BF DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JP Sigma DP1 X3F 2007 preview X3F is opened from its embedded JPEG only Sigma DP1s X3F 2008 preview X3F is opened from its embedded JPEG only Sigma DP2 X3F 2008 preview X3F is opened from its embedded JPEG only -Sigma fp DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sigma fp L DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma fp DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma SD9 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD10 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD14 X3F 2007 preview X3F is opened from its embedded JPEG only diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 4ce812d8c..ce108ca7e 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -2,6 +2,9 @@ # "verified" means a real file from this body decoded from sensor data in a corpus test or a recorded bulk check (source column). maker camera status source Apple iPhone 12 Pro Rear Wide Camera verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 6s Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 7 Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone XS verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Canon EOS 40D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 5D Mark II verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -14,6 +17,10 @@ Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor has Canon EOS R5 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 3 (Hasselblad L2D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 4 Pro (DJI FC9287) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mini 2 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -31,7 +38,17 @@ Fujifilm X-T4** verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T5 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T50 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Google Pixel 2 XL verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Google Pixel 4 XL verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4a Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 6 Pro Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 8 Pro Wide Camera* verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Hasselblad CFV-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-40 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad Lunar verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 +Hasselblad X1D verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D II 50C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Leica C (Typ 112) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux (Typ 109) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica D-Lux 3 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body @@ -48,8 +65,12 @@ Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leic Leica V-LUX 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Nikon COOLPIX B700 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7500 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon Z 6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -147,11 +168,20 @@ Panasonic LUMIX DMC-ZS40 (DMC-TZ60, DMC-TZ61) verified PR #215: 178 raw.pixls.us Panasonic LUMIX DMC-ZS50 (DMC-TZ70, DMC-TZ71) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DMC-ZS60 (DMC-TZ80, DMC-TZ81, DMC-TZ85) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX ZS200 (DC-TX2, DC-TZ200, DC-TZ202, DC-TZ220, DC-ZS220) verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Parrot Anafi verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Pentax K-3 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Samsung Galaxy S21 Ultra Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S22 Ultra Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S23 Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S7 Edge verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba62abb40..3ad4455cd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit; 30 models verified on 40 CC0 files; 267 verified models, 18%) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -12,7 +12,7 @@ have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md | Number | Value | Kind | |---|---:|---| -| **Feature breadth**, checklist | **81.1%** of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | +| **Feature breadth**, checklist | **81.6%** of 532 in-scope rows (P0 98.0%, P1 95.2%, P2 50.0%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | | **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | | **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | | **Mainstream practitioner** | **~49%** (range 44–54%) | estimated: weekly areas of a working photographer, depth 71.8% × 0.804 × colour 0.85 | @@ -46,8 +46,8 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| -| Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | -| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| Features (weighted areas below) | ~77% (checklist 81.6%, measured) | ~61% | 600–1,100 h | [Features](#features) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 82% of Adobe's 1,446 models decode (18% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | | UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | @@ -67,7 +67,7 @@ from the checklist sections and the deep-dive documents. | Area | Weight | Breadth | Ready | Remaining | Notes | |---|---:|---:|---:|---:|---| | Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | -| RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | +| RAW decoding and camera coverage | 15% | 82% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | | Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | | Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | | Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | @@ -257,7 +257,7 @@ verification. Calibrated on this repository's history (git log and GitHub PR ope (#279), NEF lossy after split 4.7 h (#633), ORF 12.8 bpp 1.7 h (#651), HEIC 7 h (#609). - **Large features:** AI RAW denoise 8.3 h (#384, 12.8k lines), SAM 3 masks 14.9 h (#162), HDR editing and export 2.3 h (#646, on existing groundwork), contact-sheet PDF (#563), German UI - 13.8 h (#265). + 13.8 h (#267). - PR open time includes queueing and review, so these are upper bounds per feature; the ranges add a long-tail factor of 1.3–1.5× for verification against real files. @@ -298,6 +298,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | +| 2026-10-12 | minor | Camera-coverage sweep: 30 models verified on 40 CC0 files; 267 verified, 917 unverified, 226 preview-only; 43% of photographers on a verified body. Sensor verification does not establish colour parity (#1277). Refreshed the stale checklist summary: 81.6% across 532 rows | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c392d0890..f5f293120 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -313,6 +313,207 @@ fn cmd_ci() -> Result<(), String> { /// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. /// One per format / compression variant we decode or deliberately report as unsupported (preview only). const RAW_SAMPLES: &[(&str, &str, &str)] = &[ + // Camera-coverage sweep: CC0 files, sensor hashes and comparisons in docs/raw/coverage-sweep.tsv. + ( + "coverage-915.dng", + "https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG", + "845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c", + ), + ( + "coverage-939.dng", + "https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG", + "e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e", + ), + ( + "coverage-3372.dng", + "https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng", + "50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6", + ), + ( + "coverage-3009.dng", + "https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG", + "7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec", + ), + ( + "coverage-5140.dng", + "https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG", + "dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4", + ), + ( + "coverage-8041.dng", + "https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG", + "901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0", + ), + ( + "coverage-4785.dng", + "https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG", + "21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851", + ), + ( + "coverage-4898.dng", + "https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG", + "fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d", + ), + ( + "coverage-3502.dng", + "https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng", + "0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54", + ), + ( + "coverage-4176.dng", + "https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng", + "11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be", + ), + ( + "coverage-5961.dng", + "https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng", + "3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d", + ), + ( + "coverage-7751.dng", + "https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng", + "45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5", + ), + ( + "coverage-7217.3fr", + "https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR", + "e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0", + ), + ( + "coverage-7218.fff", + "https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff", + "c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932", + ), + ( + "coverage-1640.fff", + "https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff", + "209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832", + ), + ( + "coverage-1639.fff", + "https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff", + "1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0", + ), + ( + "coverage-2058.3fr", + "https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR", + "395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3", + ), + ( + "coverage-3874.fff", + "https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff", + "8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794", + ), + ( + "coverage-3941.3fr", + "https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR", + "809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47", + ), + ( + "coverage-6568.fff", + "https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff", + "44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc", + ), + ( + "coverage-6565.3fr", + "https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR", + "dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280", + ), + ( + "coverage-4726.nef", + "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", + ), + ( + "coverage-4720.nef", + "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", + ), + ( + "coverage-4721.nef", + "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", + ), + ( + "coverage-3584.nef", + "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", + ), + ( + "coverage-3581.nef", + "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", + ), + ( + "coverage-3582.nef", + "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", + "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", + ), + ( + "coverage-2792.nef", + "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", + ), + ( + "coverage-2789.nef", + "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", + ), + ( + "coverage-2790.nef", + "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", + ), + ( + "coverage-6617.nef", + "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), + ( + "coverage-2912.dng", + "https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG", + "a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6", + ), + ( + "coverage-6728.dng", + "https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng", + "5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e", + ), + ( + "coverage-6261.dng", + "https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng", + "53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581", + ), + ( + "coverage-6697.dng", + "https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng", + "1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067", + ), + ( + "coverage-1297.dng", + "https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng", + "843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561", + ), + ( + "coverage-2645.dng", + "https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng", + "6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2", + ), + ( + "coverage-2310.dng", + "https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng", + "3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c", + ), + ( + "coverage-7273.dng", + "https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG", + "0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9", + ), + ( + "coverage-6949.dng", + "https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG", + "8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70", + ), ( "arw-sony-a7rm4a-compressed.arw", "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", From 8feda7a99721478cffe752fe60e6b0728d625a05 Mon Sep 17 00:00:00 2001 From: Alex Chambers <alexchambers91@gmail.com> Date: Mon, 12 Oct 2026 04:42:32 +0200 Subject: [PATCH 122/315] LR-IMP-FORMATS: the TIFF reader's next-IFD pointer no longer overflows on huge BigTIFF entry counts Pre-existing on main. The reader computed the position of a directory's next-IFD pointer before it checked that every declared entry had been read: a saturating multiplication of the declared count, then an unchecked addition. A 44-byte BigTIFF declaring u64::MAX entries therefore panicked with overflow checks on. The position is now computed only for a directory read whole, with checked arithmetic. 32-bit note: the same guard compared the counts after narrowing the declared one to usize, so on wasm32 a BigTIFF declaring 4,294,967,297 entries but holding one passed for a whole directory and its next pointer was followed. The counts now compare as 64-bit numbers (reads_every_entry). On wasm32 the old comparison followed all six malformed test chains and the new one follows none; 64-bit results are unchanged for every directory whose count is read whole. Regression tests (crates/tiff/src/reader.rs): a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow, which panicked before; a_bigtiff_count_past_u32_max_never_follows_the_next_pointer (the whole chain, on every target); and only_a_directory_read_whole_has_a_next_pointer (the comparison itself, on every target). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raw/src/dngwrite.rs | 1 + crates/raw/tests/robust.rs | 1 + crates/tiff/README.md | 4 +- crates/tiff/src/reader.rs | 89 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 379267c87..370b7c2b4 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -434,6 +434,7 @@ pub fn write_rendered_dng( linearized: false, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), + lens_metadata: Default::default(), }; let order = ByteOrder::Little; write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 17e108f6f..70103bfd7 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -162,6 +162,7 @@ fn non_finite_black_levels_and_samples_develop() { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), }; for img in [raw.clone(), lightcraft_raw::decode(&lightcraft_raw::write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap()] { let rgb = img.develop(Method::Ahd).unwrap(); diff --git a/crates/tiff/README.md b/crates/tiff/README.md index cfaaffffb..e6a771288 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -29,7 +29,9 @@ later the TIFF/DNG exporters. The reader never panics on malformed input (proptests feed random and mutated files): offsets are bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, -nesting depth and entries per IFD are limited; the total bytes of decoded values are capped at +nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was +read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't +pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at `8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. ## Sources diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index 4a80a906b..5f38dfad2 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -121,6 +121,13 @@ pub fn entry_span(data: &[u8], pos: usize, order: ByteOrder, bigtiff: bool, base (end <= data.len() as u64).then_some(EntrySpan { tag, field_type, count, start, len }) } +/// Whether a directory declaring `declared` entries was read whole (`read` entries), so that its next-IFD pointer +/// follows them. The counts compare as 64-bit numbers: narrowing the declared count to `usize` instead would, on a +/// 32-bit target, let a BigTIFF declaring 4,294,967,297 entries but holding one pass for a whole directory. +fn reads_every_entry(declared: u64, read: usize) -> bool { + u64::try_from(read).is_ok_and(|read| read == declared) +} + /// Parse the header: (byte order, bigtiff, first IFD offset). pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { header_with(data, true) @@ -220,9 +227,10 @@ impl<'a> Ctx<'a> { } entries.sort_by_key(|e| e.tag); entries.dedup_by_key(|e| e.tag); - // Only a fully read table has a next-IFD offset. Keep hostile BigTIFF counts out of the arithmetic (#1516). - let next = if dir.declared == dir.read as u64 { - match dir.read.checked_mul(dir.entry_size).and_then(|len| dir.table.checked_add(len)).and_then(|p| self.read_off(p)) { + // the next-IFD pointer follows the last entry, read only when every declared entry was (checked: a BigTIFF + // count can be anything up to u64::MAX) + let next = if reads_every_entry(dir.declared, dir.read) { + match dir.read.checked_mul(dir.entry_size).and_then(|n| n.checked_add(dir.table)).and_then(|p| self.read_off(p)) { Some(0) | None => 0, Some(v) => base.checked_add(v).unwrap_or(0), } @@ -307,6 +315,65 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { mod tests { use super::*; + /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer + /// `next`; a valid one-entry IFD sits at 16. + fn bigtiff_chain(order: ByteOrder, declared: u64, next: u64) -> Vec<u8> { + let ifd = |d: &mut Vec<u8>, count: u64, tag: u16, next: u64| { + order.put_u64(d, count); + order.put_u16(d, tag); + order.put_u16(d, 3); + order.put_u64(d, 1); + order.put_u16(d, 8); + d.extend([0; 6]); + order.put_u64(d, next); + }; + let mut d = if order == ByteOrder::Little { b"II".to_vec() } else { b"MM".to_vec() }; + order.put_u16(&mut d, 43); + order.put_u16(&mut d, 8); + order.put_u16(&mut d, 0); + order.put_u64(&mut d, 80); + ifd(&mut d, 1, 0x0101, 0); + d.resize(80, 0); + ifd(&mut d, declared, 0x0100, next); + d + } + + #[test] + fn only_a_directory_read_whole_has_a_next_pointer() { + // the comparison itself, on every target: a declared count past u32::MAX never matches a small read count + for (declared, read, whole) in [ + (0, 0, true), + (1, 1, true), + (8192, 8192, true), + (8193, 8192, false), + (1, 0, false), + ((1 << 32) + 1, 1, false), + ((2 << 32) + 1, 1, false), + ((1 << 63) + 1, 1, false), + (u64::MAX, 1, false), + ] { + assert_eq!(reads_every_entry(declared, read), whole, "{declared} declared, {read} read"); + } + } + + #[test] + fn a_bigtiff_count_past_u32_max_never_follows_the_next_pointer() { + // the whole chain: on 32-bit targets (wasm32) the old cast truncated these counts to 1 and followed the pointer + for order in [ByteOrder::Little, ByteOrder::Big] { + for declared in [(1u64 << 32) + 1, (2u64 << 32) + 1, (1u64 << 63) + 1] { + let d = bigtiff_chain(order, declared, 16); + let tiff = Tiff::parse(&d).unwrap(); + assert_eq!(tiff.ifds.len(), 1, "{order:?} {declared}"); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + assert_eq!(parse_ifd_at(&d, 80, order, 0, true, &ParseOptions::default()).unwrap().1, 0, "{order:?} {declared}"); + } + // control: the same file declaring its one entry follows the pointer to the IFD at 16 + let whole = Tiff::parse(&bigtiff_chain(order, 1, 16)).unwrap(); + assert_eq!(whole.ifds.len(), 2, "{order:?}"); + assert_eq!(whole.ifds[1].u64(0x0101), Some(8)); + } + } + #[test] fn directory_positions_never_overflow() { // a hand-built layout the reader never produces: the iterator stops instead of overflowing @@ -322,4 +389,20 @@ mod tests { let dir = directory_entries(&data, 0, ByteOrder::Little, false, 8192).unwrap(); assert_eq!(dir.positions().collect::<Vec<_>>(), vec![2, 14]); } + + #[test] + fn a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow() { + // 44 bytes: BigTIFF header (first IFD at 16), an entry count of u64::MAX, one 20-byte entry + let mut data = b"II\x2b\x00\x08\x00\x00\x00".to_vec(); + data.extend(16u64.to_le_bytes()); + data.extend(u64::MAX.to_le_bytes()); + data.extend(0x0100u16.to_le_bytes()); + data.extend(3u16.to_le_bytes()); + data.extend(1u64.to_le_bytes()); + data.extend([8, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(data.len(), 44); + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 1); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + } } From 5773533e230d2c9521435204247c606a9fbd07cb Mon Sep 17 00:00:00 2001 From: Val Enfys <val@valenfys.com> Date: Sun, 11 Oct 2026 17:01:02 +0100 Subject: [PATCH 123/315] LR-IMP-FORMATS: preview-only photos record the app version that decided it (#662) Catalog format v9 adds Photo.preview_checked and Op::SetContent.preview_checked: the app version (CARGO_PKG_VERSION) whose decoder set preview_only, empty when it isn't set. Import and content_op (Reload, Relink) stamp it; undo swaps it back. Catalogs from before v9 read an empty stamp, so their preview-only photos count as stale (Photo::preview_only_stale) and will be rechecked once. The version guard test now also accepts a format version that only adds fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/catalog/src/journal.rs | 3 ++- crates/catalog/src/lib.rs | 7 ++++- crates/catalog/src/model.rs | 27 +++++++++++++++++++ crates/catalog/src/tests.rs | 21 +++++++++++++-- crates/catalog/src/tests_format_version.rs | 30 ++++++++++++++++++++-- crates/engine/src/cmd/convert.rs | 9 +++++-- crates/engine/src/files.rs | 6 +++++ crates/engine/src/import.rs | 2 +- crates/engine/src/media.rs | 1 + crates/engine/src/tests_denoise.rs | 1 + crates/ui-egui/src/headless.rs | 1 + crates/ui-egui/src/render.rs | 1 + 12 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 98fd616a3..289ee4fb8 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -51,6 +51,7 @@ //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | +//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -75,7 +76,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 8; +pub const VERSION: u32 = 9; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ed4cb75b4..1e0248147 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -214,6 +214,10 @@ pub enum Op { /// `None` = its raw data decodes (or it isn't a raw). #[serde(default, skip_serializing_if = "Option::is_none")] preview_only: Option<String>, + /// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty + /// when it is `None`. + #[serde(default, skip_serializing_if = "String::is_empty")] + preview_checked: String, }, /// The lens data a photo's file carries (what Reload finds when it was read after import). SetEmbeddedLens { @@ -700,7 +704,7 @@ impl Catalog { let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } - Op::SetContent { id, width, height, file_size, content_hash, preview_only } => { + Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => { let p = self.photo_mut(id)?; Op::SetContent { id, @@ -709,6 +713,7 @@ impl Catalog { file_size: std::mem::replace(&mut p.file_size, file_size), content_hash: std::mem::replace(&mut p.content_hash, content_hash), preview_only: std::mem::replace(&mut p.preview_only, preview_only), + preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked), } } Op::SetEmbeddedLens { id, lens } => { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..0fcab3c4f 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -293,6 +293,12 @@ pub struct Photo { /// picture style baked in — so it is treated as a rendered (non-raw) source. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview_only: Option<String>, + /// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when + /// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp + /// differs from the running version is rechecked when its library opens (issue #662): newer + /// builds may decode the file, older ones may decode less. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub preview_checked: String, /// A Local record: the fingerprint of its state when the browse catalogued it, to tell /// whether the user changed anything since (see [`crate::local`]). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -332,6 +338,16 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool { relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } +/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every +/// workspace crate shares one version, so it is the app's. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version +/// when the photo is preview-only, empty otherwise. +pub fn preview_stamp(preview_only: &Option<String>) -> String { + if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() } +} + impl Photo { /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). pub fn history_index(&self) -> Option<usize> { @@ -371,9 +387,20 @@ impl Photo { import_look: None, analysis: None, preview_only: None, + preview_checked: String::new(), local_baseline: None, } } + /// Sets [`Photo::preview_only`] as this build decided it, with its stamp. + pub fn set_preview_only(&mut self, preview_only: Option<String>) { + self.preview_checked = preview_stamp(&preview_only); + self.preview_only = preview_only; + } + /// A preview-only photo whose flag was set by another app version (or before stamps existed), + /// so this build should probe its file again (issue #662). + pub fn preview_only_stale(&self) -> bool { + self.preview_only.is_some() && self.preview_checked != APP_VERSION + } /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from /// its embedded preview ([`Photo::preview_only`]). pub fn develops_raw(&self) -> bool { diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..b2d7571d8 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -265,13 +265,30 @@ fn preview_only_survives_serde_and_set_content_undo() { let old: Catalog = serde_json::from_value(v).unwrap(); assert_eq!(old.photo(a).unwrap().preview_only, None); assert!(old.photo(a).unwrap().develops_raw()); - // Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it - let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None }; + // a newer build still can't decode it: the reason and the stamp of the version that decided + // it are recorded together (#662) + let op = Op::SetContent { + id: a, + width: 6000, + height: 4000, + file_size: 1, + content_hash: None, + preview_only: Some("Nikon Huffman-compressed NEF".into()), + preview_checked: "0.6.0".into(), + }; + c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); + // Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON) + // restores both + let op = + Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() }; let inv = c.apply(op).unwrap(); assert!(c.photo(a).unwrap().develops_raw()); + assert_eq!(c.photo(a).unwrap().preview_checked, ""); let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); c.apply(inv).unwrap(); assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); assert_eq!(c.photo(a).unwrap().width, 6000); } diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index e2461781a..e31d7fe0c 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() { assert_eq!(snapshot_version(&m), u64::from(VERSION)); } +/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with +/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the +/// current format. +#[test] +fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() { + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01"); + p.kind = MediaKind::Raw; + p.preview_only = Some("Cr3 files are not decoded yet".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let snap = c.to_snapshot(); + assert!(!snap.contains("preview_checked"), "an empty stamp isn't written"); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes()); + let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap(); + let p = loaded.photo(id).unwrap(); + assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet")); + assert_eq!(p.preview_checked, ""); + assert_eq!(r.upgraded_from, Some(6)); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new(); @@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() { /// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks /// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the -/// variant here under the new version. +/// variant here under the new version. A version that only adds serialized fields raises +/// `NEWEST_FIELDS` instead. #[test] fn op_variants_are_versioned() { fn since(op: &Op) -> u32 { @@ -214,6 +238,8 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } } + /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`. + const NEWEST_FIELDS: u32 = 9; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); - assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); + assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..a5846d22b 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -201,12 +201,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: /// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or /// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different -/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`]. +/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a +/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed +/// again (see [`lightcraft_catalog::Photo::preview_checked`]). pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> { + let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only); let same = info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) - && info.preview_only == ph.preview_only; + && info.preview_only == ph.preview_only + && preview_checked == ph.preview_checked; (!same).then_some(Op::SetContent { id, width: info.width, @@ -214,6 +218,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo file_size: info.file_size, content_hash: info.content_hash, preview_only: info.preview_only, + preview_checked, }) } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb85..20d454001 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -2157,6 +2157,9 @@ mod tests { let p = s.catalog.photo(id).unwrap().clone(); let why = p.preview_only.clone().expect("marked preview only"); assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); assert!(!p.develops_raw()); // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults @@ -2174,14 +2177,17 @@ mod tests { let mut s = crate::Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); assert_eq!(r["reloaded"], json!([id.0]), "{r}"); assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // relinking to a file that decodes clears it too let dng = files.join("DSC_0001.dng"); std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e..039a32bee 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1386,7 +1386,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; - p.preview_only = info.preview_only.clone(); + p.set_preview_only(info.preview_only.clone()); apply_import_defaults(s, &mut p); if let Some(sc) = &sidecar { crate::sidecar::merge_into(&mut p, sc, &now); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0688410ed..59d4c1d23 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1720,6 +1720,7 @@ mod thumbnail_hash_tests { file_size: 99, content_hash: Some("replacement".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); s.media.forget(PhotoId(1)); diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index 1cba9f648..cc9ad2eed 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -705,6 +705,7 @@ fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() { file_size: p.file_size + 1, content_hash: Some("ffffffffffffffffffffffffffffffff".into()), preview_only: None, + preview_checked: String::new(), }, ) .unwrap(); diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..783224377 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2365,6 +2365,7 @@ mod tests { file_size: ph.file_size, content_hash: ph.content_hash.clone(), preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), + preview_checked: lightcraft_catalog::APP_VERSION.into(), }) .unwrap(); h.settle(SETTLE); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index fd0f0e9bf..d1de43c68 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -962,6 +962,7 @@ mod thumbnail_tests { file_size: 99, content_hash: Some("changed".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); From f29a340fa6e991e8882952c0f57b93506f771023 Mon Sep 17 00:00:00 2001 From: Val Enfys <val@valenfys.com> Date: Sun, 11 Oct 2026 13:13:25 +0100 Subject: [PATCH 124/315] LR-IMP-FORMATS: recheck stale preview-only raws when a library opens (#662) A raw imported while its format couldn't be decoded kept preview_only until a manual Reload, so new decoders in a release never reached existing photos. When a library opens in another app version, its preview-only photos whose stamp differs are probed again in the background and get exactly what Reload from Disk gives them (shared reload_ops): a file that decodes now loses the flag, one that still doesn't gets the current reason and a fresh stamp. The result is one journaled system batch, not an undo step; develop settings and history are kept. Unreadable files are left for next time, photos changed while probing are skipped, and a result for a library closed meanwhile is dropped. A toast says how many raws now develop from their raw data. - engine: cmd/recheck.rs (stale / probe / apply) and library.recheckPreviewOnly {all?} -> {checked, updated, cleared}; refused without file access, where every probe would clear every flag - desktop: recheck::tick once per library generation, through tasks::spawn ("Checking raw files" in the activity stack) - the preview-only notice (Edit, Info) gains a Reload from Disk button - strings translated in all nine catalogs; docs/mcp.md and the parity row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/convert.rs | 52 ++++--- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/recheck.rs | 129 ++++++++++++++++ crates/engine/src/files.rs | 4 +- crates/engine/src/import.rs | 6 + crates/engine/src/lib.rs | 6 +- crates/engine/src/tests_recheck.rs | 155 ++++++++++++++++++++ crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 6 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 6 +- crates/ui-egui/locales/fr-formats.json | 3 +- crates/ui-egui/locales/fr.json | 6 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 6 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 6 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 6 +- crates/ui-egui/locales/uk-formats.json | 3 +- crates/ui-egui/locales/uk.json | 6 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 6 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 6 +- crates/ui-egui/src/headless.rs | 8 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels/edit.rs | 22 ++- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/recheck.rs | 46 ++++++ crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/tests_recheck.rs | 90 ++++++++++++ crates/ui-egui/src/widgets.rs | 10 +- docs/mcp.md | 10 ++ docs/parity-checklist.md | 4 +- 35 files changed, 589 insertions(+), 49 deletions(-) create mode 100644 crates/engine/src/cmd/recheck.rs create mode 100644 crates/engine/src/tests_recheck.rs create mode 100644 crates/ui-egui/src/recheck.rs create mode 100644 crates/ui-egui/src/tests_recheck.rs diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index a5846d22b..bcce67b4c 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -276,6 +276,37 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat (ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) }) } +/// The ops that bring photo `id` and its virtual copies up to date with what their file is now +/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`. +/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it. +pub(crate) fn reload_ops( + cat: &lightcraft_catalog::Catalog, + id: lightcraft_catalog::PhotoId, + info: &crate::media::ProbeInfo, + ops: &mut Vec<Op>, + changed: &mut Vec<lightcraft_catalog::PhotoId>, +) { + let Some(ph) = cat.photo(id) else { return }; + // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; + // nothing already set is overwritten + let meta_ops = fill_missing_meta(id, ph, info); + let mut mine: Vec<Op> = + content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect(); + if !mine.is_empty() { + ops.append(&mut mine); + changed.push(id); + } + // virtual copies share the file + for c in cat.photos().filter(|c| c.copy_of == Some(id)) { + let theirs: Vec<Op> = + content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect(); + if !theirs.is_empty() { + ops.extend(theirs); + changed.push(c.id); + } + } +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result<Value> { @@ -291,25 +322,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> { let mut ops = Vec::new(); let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { - let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; - // nothing already set is overwritten - let meta_ops = fill_missing_meta(id, ph, &info); - let mut mine: Vec<Op> = - content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect(); - if !mine.is_empty() { - ops.append(&mut mine); - reloaded.push(id); - } - // virtual copies share the file - for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { - let theirs: Vec<Op> = - content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect(); - if !theirs.is_empty() { - ops.extend(theirs); - reloaded.push(c.id); - } - } + let Ok(info) = info else { continue }; + reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); } for id in &reloaded { s.media.forget(*id); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..2a11670fe 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -33,6 +33,7 @@ mod prefs; mod preset_files; pub mod previews; mod query; +pub mod recheck; mod sync; mod xmp; @@ -157,6 +158,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); + v.extend(recheck::specs()); v.extend(sync::specs()); v.extend(metadata::specs()); v.extend(geocode::specs()); diff --git a/crates/engine/src/cmd/recheck.rs b/crates/engine/src/cmd/recheck.rs new file mode 100644 index 000000000..494426b90 --- /dev/null +++ b/crates/engine/src/cmd/recheck.rs @@ -0,0 +1,129 @@ +//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or +//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file. +//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a +//! library opens in another version, its stale preview-only photos are probed again and get +//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that +//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again. +//! +//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no +//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the +//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use +//! `library.recheckPreviewOnly`, which does all three at once. + +use lightcraft_catalog::{Catalog, Op, Photo, PhotoId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A preview-only photo to probe, and what the library knew about it when it was listed (so a +/// result for a photo that changed meanwhile is dropped). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stale { + pub id: PhotoId, + pub path: String, + preview_only: Option<String>, + preview_checked: String, +} + +impl Stale { + fn of(p: &Photo, path: &str) -> Stale { + Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() } + } + + /// The photo is still as it was listed: same file, flag and stamp. + fn current(&self, p: &Photo) -> bool { + crate::cmd::missing::checked_path(p) == Some(self.path.as_str()) + && p.preview_only == self.preview_only + && p.preview_checked == self.preview_checked + } +} + +/// The library's preview-only photos whose flag another app version set (or every preview-only +/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they +/// were copied from). No file-system calls. +pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> { + cat.photos() + .filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale())) + .filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path))) + .collect() +} + +/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no +/// file access (a demo or browser session), where nothing can be rechecked. +pub fn file_probe(s: &Session) -> Option<FileProbe> { + s.media.file_probe.clone() +} + +/// Probe each listed photo's file as Reload would. Doesn't touch the session. +pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> { + let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect(); + stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect() +} + +/// What a recheck changed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rechecked { + /// Photos brought up to date (virtual copies included). + pub updated: Vec<PhotoId>, + /// Of those, the ones that develop from their raw data now. + pub cleared: Vec<PhotoId>, +} + +impl Rechecked { + pub fn to_json(&self) -> Value { + json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()}) + } +} + +/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is +/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing, +/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded, +/// removed) is skipped. +pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> { + let mut ops = Vec::new(); + let mut out = Rechecked::default(); + for (item, info) in results { + let Ok(info) = info else { continue }; + if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) { + continue; + } + let first = out.updated.len(); + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated); + if info.preview_only.is_none() { + out.cleared.extend_from_slice(&out.updated[first..]); + } + } + if !ops.is_empty() { + s.apply_system(Op::Batch { ops })?; + } + for id in &out.updated { + s.media.forget(*id); + } + Ok(out) +} + +fn recheck(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "library.recheckPreviewOnly"; + let all = p.get("all").and_then(Value::as_bool).unwrap_or(false); + // without file access every probe "succeeds" with nothing known, which would clear every flag + let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?; + let listed = stale(&s.catalog, all); + let n = listed.len(); + let mut r = apply(s, probe(&fp, listed))?.to_json(); + r["checked"] = json!(n); + Ok(r) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![cmd!( + "library.recheckPreviewOnly", + "Recheck Preview-Only Raws", + [], + None, + "{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}", + always, + recheck + )] +} diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 20d454001..825c43ee9 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1048,7 +1048,7 @@ impl crate::Session { } #[cfg(test)] -mod tests { +pub(crate) mod tests { /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides /// them; an exported `travel|Italy|Rome` comes back as itself. @@ -1633,7 +1633,7 @@ mod tests { } /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. - fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> { + pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> { let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 039a32bee..0b9bf2089 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -470,6 +470,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) } +/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread. +pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec<Result<ProbeInfo, String>> { + let progress = ScanProgress::default(); + probe_all(Some(probe), paths, &progress.done, &progress.cancel) +} + /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. fn probe_all( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..054dcabee 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -541,8 +541,8 @@ impl Session { } /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background - /// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the - /// redo stack alone. + /// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not + /// an undo step, and it leaves the redo stack alone. pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { let fwd = op.clone(); self.catalog.apply(op)?; @@ -1046,6 +1046,8 @@ mod tests_presets_scale; #[cfg(test)] mod tests_process; #[cfg(test)] +mod tests_recheck; +#[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; diff --git a/crates/engine/src/tests_recheck.rs b/crates/engine/src/tests_recheck.rs new file mode 100644 index 000000000..9f7dab984 --- /dev/null +++ b/crates/engine/src/tests_recheck.rs @@ -0,0 +1,155 @@ +//! Issue #662: preview-only raws are rechecked when a library opens in another app version. + +use std::path::PathBuf; + +use lightcraft_catalog::{APP_VERSION, Op, PhotoId}; +use serde_json::json; + +use crate::Session; +use crate::cmd::recheck; +use crate::files::tests::cr3_with_preview; + +/// A version other than this build's: the flag was set by an older (or newer) LightCraft. +const OTHER: &str = "0.5.0"; + +struct Lib { + s: Session, + dir: PathBuf, + lib: PathBuf, + file: PathBuf, + id: PhotoId, +} + +impl Drop for Lib { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// A library with one preview-only CR3, its flag stamped by [`OTHER`] as a library saved by +/// another version would be. +fn library(name: &str) -> Lib { + let dir = std::env::temp_dir().join(format!("lc-recheck-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let file = files.join("IMG_0001.cr3"); + std::fs::write(&file, cr3_with_preview(96, 64)).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = PhotoId(r["imported"][0].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + assert!(p.preview_only.is_some() && !p.preview_only_stale(), "just imported: decided by this version"); + s.apply_system(Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: p.preview_only.clone(), + preview_checked: OTHER.into(), + }) + .unwrap(); + assert!(s.catalog.photo(id).unwrap().preview_only_stale()); + Lib { s, dir, lib, file, id } +} + +fn decodable_now(x: &Lib) { + std::fs::write(&x.file, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); +} + +/// The acceptance test: a stale flag on a file that decodes now is cleared without a manual +/// Reload, adds no undo step, leaves develop settings and history alone, and survives a reopen. +#[test] +fn stale_flag_on_a_file_that_decodes_now_is_cleared() { + let mut x = library("decodes"); + x.s.execute("develop.set", &json!({"ids": [x.id.0], "control": "light.exposure", "value": 0.5})).unwrap(); + let before = x.s.catalog.photo(x.id).unwrap().clone(); + let undo = x.s.undo.len(); + decodable_now(&x); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": [x.id.0]})); + let p = x.s.catalog.photo(x.id).unwrap().clone(); + assert_eq!(p.preview_only, None); + assert_eq!(p.preview_checked, "", "the stamp goes with the flag"); + assert!(p.develops_raw()); + assert_eq!(x.s.undo.len(), undo, "not an undo step"); + assert_eq!(*p.develop, *before.develop); + assert_eq!(p.history.len(), before.history.len()); + // journaled: a reopen keeps it + x.s.persist().unwrap(); + x.s = Session::new().with_fs(); + x.s.open_library(&x.lib, false).unwrap(); + assert_eq!(x.s.catalog.photo(x.id).unwrap().preview_only, None); +} + +/// A file that still doesn't decode is stamped by this version (with the decoder's current +/// reason), so the next open doesn't probe it again. +#[test] +fn still_undecodable_file_gets_a_fresh_stamp_and_isnt_probed_again() { + let mut x = library("still"); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": []})); + let p = x.s.catalog.photo(x.id).unwrap(); + assert!(p.preview_only.is_some()); + assert_eq!(p.preview_checked, APP_VERSION); + assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["checked"], 0); + // `all` ignores stamps (testing a decoder build of the same version) + assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({"all": true})).unwrap()["checked"], 1); +} + +/// A file that can't be read (unplugged drive) changes nothing and is tried again next time. +#[test] +fn missing_file_keeps_its_flag_and_stamp_and_is_retried() { + let mut x = library("missing"); + std::fs::remove_file(&x.file).unwrap(); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r, json!({"checked": 1, "updated": [], "cleared": []})); + let p = x.s.catalog.photo(x.id).unwrap(); + assert!(p.preview_only.is_some()); + assert_eq!(p.preview_checked, OTHER); + // back again, and decodable now + decodable_now(&x); + assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["cleared"], json!([x.id.0])); +} + +/// A photo that changed between the probe and applying it (here: relinked) is left alone. +#[test] +fn photo_changed_while_probing_is_skipped() { + let mut x = library("changed"); + let listed = recheck::stale(&x.s.catalog, false); + assert_eq!(listed.iter().map(|i| i.id).collect::<Vec<_>>(), vec![x.id]); + let probed = recheck::probe(&recheck::file_probe(&x.s).unwrap(), listed); + decodable_now(&x); + let other = x.dir.join("files").join("IMG_0001-moved.cr3"); + std::fs::write(&other, cr3_with_preview(96, 64)).unwrap(); + x.s.execute("photo.relink", &json!({"id": x.id.0, "path": other.to_string_lossy()})).unwrap(); + let relinked = x.s.catalog.photo(x.id).unwrap().clone(); + assert_eq!(recheck::apply(&mut x.s, probed).unwrap(), recheck::Rechecked::default()); + assert_eq!(*x.s.catalog.photo(x.id).unwrap().clone(), *relinked); +} + +/// Virtual copies share the file: they follow the photo they were copied from. +#[test] +fn virtual_copies_follow_their_photo() { + let mut x = library("copies"); + let r = x.s.execute("photo.virtualCopy", &json!({"ids": [x.id.0]})).unwrap(); + let copy = PhotoId(r["ids"][0].as_u64().unwrap()); + assert!(x.s.catalog.photo(copy).unwrap().preview_only.is_some()); + assert_eq!(recheck::stale(&x.s.catalog, true).len(), 1, "copies aren't probed on their own"); + decodable_now(&x); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r["cleared"], json!([x.id.0, copy.0])); + assert!(x.s.catalog.photo(copy).unwrap().develops_raw()); +} + +/// Without file access every probe would "succeed" knowing nothing and clear every flag: refused. +#[test] +fn session_without_file_access_refuses() { + let mut x = library("nofs"); + x.s.media.file_probe = None; + let e = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no file access"), "{e}"); + assert!(x.s.catalog.photo(x.id).unwrap().preview_only.is_some()); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..6636b006f 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..47909f4cf 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,9 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Checking raw files": "RAW-Dateien werden geprüft", + "1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden", + "This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt", + "This raw variant still can't be decoded": "Diese RAW-Variante kann noch nicht dekodiert werden" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..22339e40f 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "{n} raws can now be developed from their raw data": "{n} RAW ya se pueden revelar a partir de sus datos RAW" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..01db71d56 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,9 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Checking raw files": "Comprobando archivos RAW", + "1 raw can now be developed from its raw data": "1 RAW ya se puede revelar a partir de sus datos RAW", + "This raw is now developed from its raw data": "Este RAW ya se revela a partir de sus datos RAW", + "This raw variant still can't be decoded": "Esta variante RAW aún no se puede decodificar" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..5dde524f4 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "{n} raws can now be developed from their raw data": "{n} fichiers RAW peuvent désormais être développés à partir de leurs données RAW" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..2dc7f0855 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,9 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Checking raw files": "Vérification des fichiers RAW", + "1 raw can now be developed from its raw data": "1 fichier RAW peut désormais être développé à partir de ses données RAW", + "This raw is now developed from its raw data": "Ce fichier RAW est désormais développé à partir de ses données RAW", + "This raw variant still can't be decoded": "Cette variante RAW ne peut toujours pas être décodée" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..3fa9518ad 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "{n} raws can now be developed from their raw data": "{n}枚のRAWをRAWデータから現像できるようになりました" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..c81d83ad5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,9 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Checking raw files": "RAWファイルを確認中", + "1 raw can now be developed from its raw data": "1枚のRAWをRAWデータから現像できるようになりました", + "This raw is now developed from its raw data": "このRAWはRAWデータから現像されるようになりました", + "This raw variant still can't be decoded": "このRAWの形式はまだデコードできません" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..1260aff8c 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "{n} raws can now be developed from their raw data": "{n} RAW agora podem ser revelados a partir dos dados RAW" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..1517b158d 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,9 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Checking raw files": "Verificando arquivos RAW", + "1 raw can now be developed from its raw data": "1 RAW agora pode ser revelado a partir dos dados RAW", + "This raw is now developed from its raw data": "Este RAW agora é revelado a partir dos dados RAW", + "This raw variant still can't be decoded": "Esta variante RAW ainda não pode ser decodificada" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..4b7958b14 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "{n} raws can now be developed from their raw data": "RAW-файлов, которые теперь можно проявлять из RAW-данных: {n}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..b6ba094c9 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,9 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Checking raw files": "Проверка RAW-файлов", + "1 raw can now be developed from its raw data": "1 RAW-файл теперь можно проявлять из RAW-данных", + "This raw is now developed from its raw data": "Этот RAW-файл теперь проявляется из RAW-данных", + "This raw variant still can't be decoded": "Этот вариант RAW пока не удаётся декодировать" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..7adbea2b2 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "{n} raws can now be developed from their raw data": "RAW-файлів, які тепер можна проявляти з RAW-даних: {n}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..b3e737f3a 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,9 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Checking raw files": "Перевірка RAW-файлів", + "1 raw can now be developed from its raw data": "1 RAW-файл тепер можна проявляти з RAW-даних", + "This raw is now developed from its raw data": "Цей RAW-файл тепер проявляється з RAW-даних", + "This raw variant still can't be decoded": "Цей варіант RAW поки що не вдається декодувати" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..ab5f19753 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "{n} raws can now be developed from their raw data": "{n} 个 RAW 现在可以从 RAW 数据冲印" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..4f26bc7d6 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,9 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Checking raw files": "正在检查 RAW 文件", + "1 raw can now be developed from its raw data": "1 个 RAW 现在可以从 RAW 数据冲印", + "This raw is now developed from its raw data": "此 RAW 现在从 RAW 数据冲印", + "This raw variant still can't be decoded": "这种 RAW 变体仍无法解码" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..366271f7c 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,6 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..c895e5ede 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,9 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Checking raw files": "正在檢查 RAW 檔案", + "1 raw can now be developed from its raw data": "1 個 RAW 現在可以從 RAW 資料沖印", + "This raw is now developed from its raw data": "此 RAW 現在從 RAW 資料沖印", + "This raw variant still can't be decoded": "這種 RAW 變體仍無法解碼" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 783224377..4bc9ba534 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2382,10 +2382,18 @@ mod tests { h.settle(SETTLE); let n = ids(&mut h, "notice:previewOnly:"); assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); + // with a Reload from Disk button (issue #662) + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]); // Info h.app.ui.right = crate::state::RightPanel::Info; h.settle(SETTLE); assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]); + // …which re-reads the file and says what came of it (a demo photo has none to re-read) + let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded")); // a regular photo: no notice h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); h.app.ui.right = crate::state::RightPanel::Edit; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..a28528294 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; +pub mod recheck; pub mod region; pub mod render; pub mod shortcuts; @@ -111,6 +112,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_recheck; +#[cfg(test)] mod tests_sam_setup; #[cfg(test)] mod tests_scroll; @@ -1039,6 +1042,7 @@ impl LightcraftApp { sync::poll_run(self, ctx); import::tick(self, ctx); lightroom_import::tick(self, ctx); + recheck::tick(self); tasks::poll(self, ctx); self.preview_build_status(ctx); self.save_status(ctx); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..c5578d386 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -133,9 +133,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); if let Some(why) = &preview_only { - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 }).show(ui, |ui| { - crate::widgets::preview_only_notice(ui, "edit", why); - }); + let reload = egui::Frame::NONE + .inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 }) + .show(ui, |ui| crate::widgets::preview_only_notice(ui, "edit", why)) + .inner; + if reload { + reload_preview_only(app, ui.ctx(), id); + } } let n = app.session.selection.ids.len(); if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { @@ -1681,6 +1685,18 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { }); } +/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can +/// decode, whose drive was offline when the library opened), and say what came of it. +pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + let r = app.run("photo.reload", json!({"ids": [id.0]})); + let decoded = app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()); + match r { + Err(e) => app.toast_error(ctx, e), + Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")), + Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), + } +} + #[cfg(test)] mod curve_histogram_tests { use super::*; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..8ef588d57 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -534,7 +534,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { camera_card(ui, &p); ui.add_space(6.0); if let Some(why) = &p.preview_only { - crate::widgets::preview_only_notice(ui, "info", why); + if crate::widgets::preview_only_notice(ui, "info", why) { + crate::panels::edit::reload_preview_only(app, ui.ctx(), id); + } ui.add_space(6.0); } if let Some(name) = &p.copy_name { diff --git a/crates/ui-egui/src/recheck.rs b/crates/ui-egui/src/recheck.rs new file mode 100644 index 000000000..9a53c5a06 --- /dev/null +++ b/crates/ui-egui/src/recheck.rs @@ -0,0 +1,46 @@ +//! Preview-only raws rechecked when a library opens (issue #662): a raw imported while its format +//! couldn't be decoded is probed again when the library opens in another LightCraft version (see +//! `lightcraft_engine::cmd::recheck`). The probe runs on a worker thread, so opening never waits +//! for it; the result is applied back here, unless another library was opened meanwhile. + +use lightcraft_engine::cmd::recheck; + +use crate::LightcraftApp; + +/// The activity stack's label while the files are read. +pub const LABEL: &str = "Checking raw files"; + +/// Once per opened library (called every frame): start the recheck when it has stale photos. +pub fn tick(app: &mut LightcraftApp) { + let library = app.session.library_generation(); + if app.ui.rechecked_library == Some(library) { + return; + } + app.ui.rechecked_library = Some(library); + // a demo or browser session has no files to read + let Some(probe) = recheck::file_probe(&app.session) else { return }; + let stale = recheck::stale(&app.session.catalog, false); + if stale.is_empty() { + return; + } + let work = move || recheck::probe(&probe, stale); + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, results| { + if app.session.library_generation() != library { + return; + } + match recheck::apply(&mut app.session, results) { + Ok(r) => { + let n = r.cleared.len(); + if n == 1 { + app.toast(ctx, crate::i18n::tr("1 raw can now be developed from its raw data")); + } else if n > 1 { + app.toast(ctx, crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n)); + } + } + Err(e) => log::warn!("recheck of preview-only raws: {e}"), + } + }; + if let Err(e) = crate::tasks::spawn(app, LABEL, Some("recheck"), work, done) { + log::warn!("{e}"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..84a45264b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -751,6 +751,10 @@ pub struct UiState { #[cfg(test)] #[serde(skip)] pub find_missing_test_delay: Option<std::time::Duration>, + /// The library (its `Session::library_generation`) whose preview-only raws were rechecked + /// (`crate::recheck`). + #[serde(skip)] + pub rechecked_library: Option<u64>, #[serde(skip)] pub status: String, #[serde(skip)] @@ -1158,6 +1162,7 @@ impl Default for UiState { last_find_missing_result: None, #[cfg(test)] find_missing_test_delay: None, + rechecked_library: None, status: String::new(), dialog: None, } diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs new file mode 100644 index 000000000..88149e464 --- /dev/null +++ b/crates/ui-egui/src/tests_recheck.rs @@ -0,0 +1,90 @@ +//! Issue #662: opening a library rechecks its preview-only raws in the background. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use lightcraft_catalog::{Op, PhotoId}; +use serde_json::json; + +use crate::LightcraftApp; + +struct Dir(PathBuf); + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// An app on a library at `dir/lib` with one photo whose preview-only flag an older version set. +/// Its file (a PNG) reads fine, as a raw a newer decoder handles would. +fn app_with_stale_photo(dir: &Path) -> (LightcraftApp, PhotoId) { + let _ = std::fs::remove_dir_all(dir); + std::fs::create_dir_all(dir.join("files")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let file = dir.join("files/one.png"); + std::fs::write(&file, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("lib"), false).unwrap(); + let r = session.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = PhotoId(r["imported"][0].as_u64().unwrap()); + let p = session.catalog.photo(id).unwrap().clone(); + session + .catalog + .apply(Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: Some("Cr3 files are not decoded yet".into()), + preview_checked: "0.5.0".into(), + }) + .unwrap(); + (LightcraftApp::new(session, Default::default()), id) +} + +#[test] +fn opening_a_library_rechecks_stale_preview_only_raws_in_the_background() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + crate::recheck::tick(&mut app); + // it shows in the activity stack while it runs + let rows = app.session.activity.list(); + assert!(rows.iter().any(|r| r.kind == "recheck" && r.label == crate::recheck::LABEL), "{rows:?}"); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.session.activity.list().iter().all(|r| r.kind != "recheck"), "the row goes with the task"); + let p = app.session.catalog.photo(id).unwrap(); + assert_eq!(p.preview_only, None); + assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "1 raw can now be developed from its raw data"), "{:?}", app.ui.toast); + // once per opened library + app.ui.toast = None; + crate::recheck::tick(&mut app); + assert!(app.tasks.is_empty()); +} + +/// A result for a library that was closed meanwhile is dropped, not applied to the new one. +#[test] +fn recheck_result_is_dropped_after_switching_libraries() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-switch-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + crate::recheck::tick(&mut app); + app.run("app.openLibrary", json!({"path": dir.0.join("other").to_string_lossy()})).unwrap(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.ui.toast.is_none(), "{:?}", app.ui.toast); + assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched"); +} + +/// The notice's Reload from Disk re-reads the file and says what came of it. +#[test] +fn notice_reload_button_rereads_the_file() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + crate::panels::edit::reload_preview_only(&mut app, &ctx, id); + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..30c4042d8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -58,8 +58,11 @@ pub fn preview_only_explanation(reason: &str) -> String { } /// A panel notice for a raw shown from its embedded preview (Edit, Info): an amber info icon, -/// "Preview only" and the explanation. Registered as `notice:previewOnly:{key}`. -pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) { +/// "Preview only", the explanation and a Reload from Disk button (`button:reloadPreviewOnly:{key}`: +/// for a file a newer version can decode that was offline when its library opened). Registered as +/// `notice:previewOnly:{key}`. `true` when the button was clicked. +pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) -> bool { + let mut reload = false; let t = Tokens::get(ui.ctx()); let r = egui::Frame::NONE .fill(t.canvas) @@ -75,11 +78,14 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) { }); ui.add_space(2.0); ui.label(egui::RichText::new(preview_only_explanation(reason)).size(11.5).color(t.text_label)); + ui.add_space(6.0); + reload = text_button(ui, &format!("reloadPreviewOnly:{key}"), "Reload from Disk", false).clicked(); }) .response; let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason)); r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG"))); register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect); + reload } // ------------------------------------------------------------------ colour helpers diff --git a/docs/mcp.md b/docs/mcp.md index f0cc9a1d1..0cfc3904b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -203,6 +203,16 @@ JSON params (documented in each tool's description and by `list_commands`). Pass leave these per-command tools out when a client struggles with a large tool list; `run_command` still reaches every command. +### Preview-only raws after an update + +A raw LightCraft couldn't decode when it was imported is shown from its embedded preview +(`previewOnly` in `catalog.query`). The desktop app rechecks such photos in the background when a +library opens in a newer (or older) LightCraft version, so a raw the new decoder handles develops +from its raw data without a manual Reload. Headless sessions don't do this on their own: call +`cmd_library_recheckPreviewOnly` (`{all: true}` ignores the version stamps, e.g. to test a decoder +build) → `{checked, updated, cleared}`. Like the app's recheck, it isn't an undo step and keeps +develop settings and history. + ## Resources `resources/list` / `resources/read` serve JSON snapshots: diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..ea675ad9d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-IMP-FORMATS: preview-only raws rechecked after an update, #662) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | From 0bc2f79bb5d41e6103a68ee3f986e7791994a036 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Wed, 14 Oct 2026 18:56:21 +1100 Subject: [PATCH 125/315] Import: find sidecars whose photo extension or .xmp differs in case On a case-sensitive file system, `DSC_1.nef.xmp` or `DSC_1.Xmp` beside `DSC_1.NEF` was never found, so the photo came in with only the file's own metadata (its IPTC and keywords) and lost the sidecar's rating and crop. The lookup now tries the photo extension in lower and upper case and `.xmp`/`.XMP`/`.Xmp`; Move takes such a sidecar along. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/engine/src/import_move.rs | 5 +++- crates/engine/src/sidecar.rs | 16 ++++++++--- crates/engine/src/tests_import.rs | 48 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index c92ee2796..06f22be40 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -128,7 +128,10 @@ pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> { if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() { return None; } - if base == name { + // `IMG_1.cr3.xmp` names `IMG_1.CR3` too: some apps lower-case the photo's extension + let full = base == name + || base.rsplit_once('.').is_some_and(|(s, x)| s == stem && name.rsplit_once('.').is_some_and(|(_, y)| x.eq_ignore_ascii_case(y))); + if full { Some((e.path(), false)) } else if base == stem { Some((e.path(), true)) diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..fc49fc7cb 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -63,16 +63,24 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf { } /// An existing sidecar for `original`: the preferred naming first, then the other one (also -/// accepting an upper-case `.XMP`). +/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as +/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss). pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> { let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; + let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let base = Path::new(original).with_extension(""); + let recased = |e: String| (e != ext).then(|| format!("{}.{e}", base.to_string_lossy())); + let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect(); [naming, other] .into_iter() .flat_map(|n| { - let p = sidecar_path(original, n); - let upper = p.with_extension("XMP"); - [p, upper] + let mut v = vec![sidecar_path(original, n)]; + if n == SidecarNaming::Full { + v.extend(full_variants.iter().map(|o| sidecar_path(o, n))); + } + v }) + .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) .find(|p| p.is_file()) } diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..d49426039 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1612,3 +1612,51 @@ fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup } let _ = std::fs::remove_dir_all(&src); } +/// Sidecars are found and read whatever their name's case, header or byte-order mark, so a +/// raw's rating and crop never silently stay behind while the file's own IPTC comes in (fork #43). +#[test] +fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec<u8> = (0..60 * 40).flat_map(|i| [(i % 251) as u8, 90, 200]).collect(); + let j = encode_jpeg(&EncodeImage::new(60, 40, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut nef = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, 38u32), (514, j.len() as u32)] { + nef.extend_from_slice(&tag.to_le_bytes()); + nef.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + nef.extend_from_slice(&v.to_le_bytes()); + } + nef.extend_from_slice(&[0, 0, 0, 0]); + nef.extend_from_slice(&j); + let body = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmp:Rating="3" crs:HasCrop="True" crs:CropLeft="0.2" crs:CropTop="0.1" crs:CropRight="0.8" crs:CropBottom="0.9" crs:CropAngle="1.5"/></rdf:RDF></x:xmpmeta>"#; + let cases = [ + ("darktable-full", "DSC_0001.NEF.xmp", body.to_string()), + ("lower-full", "DSC_0001.nef.xmp", body.to_string()), + ("upper-full", "DSC_0001.NEF.XMP", body.to_string()), + ("mixed-stem", "DSC_0001.Xmp", body.to_string()), + ("bom-and-decl", "DSC_0001.xmp", format!("\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- written by another app -->\n{body}")), + ]; + for (name, file, text) in cases { + let src = temp_dir(&format!("sc-variant-{name}")); + std::fs::write(src.join("DSC_0001.NEF"), &nef).unwrap(); + std::fs::write(src.join(file), text).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), r["sidecars"].as_u64()), (1, Some(1)), "{name}: {r}"); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.rating, 3, "{name}"); + let c = p.develop.to_json()["crop"]["geometry"].clone(); + assert!((c["rect"]["x0"].as_f64().unwrap_or(0.0) - 0.2).abs() < 1e-6, "{name}: {c}"); + if name == "lower-full" { + // a move takes that sidecar along + let dest = temp_dir("sc-variant-dest"); + let mut s = Session::new().with_fs(); + let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(), "organize": "flat"}); + let r = s.execute("library.import", &args).unwrap(); + assert_eq!(s.catalog.photos().next().unwrap().rating, 3, "{r}"); + assert!(std::fs::read_dir(&dest).unwrap().flatten().any(|e| e.file_name().to_string_lossy().to_lowercase().ends_with(".xmp")), "{r}"); + assert!(!src.join(file).exists(), "no sidecar left behind: {r}"); + let _ = std::fs::remove_dir_all(&dest); + } + let _ = std::fs::remove_dir_all(&src); + } +} From 707aa5aa9282ed9d53323bda7b07401f0b7ac685 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:02:08 +0900 Subject: [PATCH 126/315] fix(mcp): stop stdio cleanly after output failure Wake the serving loop after protocol output fails and preserve the actual writer error. Use one process-owned stdin worker so the CLI can finish normal backend cleanup without waiting for client EOF, and terminate the output worker even when a progress hook is retained. Closes #1527 Co-Authored-By: OpenAI Codex <noreply@openai.com> --- apps/lightcraft-cli/src/main.rs | 5 +- apps/lightcraft-cli/tests/mcp_shutdown.rs | 38 +++++ crates/mcp/src/lib.rs | 3 +- crates/mcp/src/progress.rs | 167 ++++++++++++++------ crates/mcp/src/progress_tests.rs | 177 ++++++++++++++++++++++ crates/mcp/src/server.rs | 4 +- docs/mcp.md | 10 ++ 7 files changed, 356 insertions(+), 48 deletions(-) create mode 100644 apps/lightcraft-cli/tests/mcp_shutdown.rs create mode 100644 crates/mcp/src/progress_tests.rs diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..3f34485ba 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -17,7 +17,7 @@ mod alloc_release; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use std::process::ExitCode; @@ -364,8 +364,7 @@ fn mcp(args: &[String]) -> Result<(), String> { }; eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe()); let mut server = Server::new(backend).with_command_tools(!compact); - let stdin = std::io::stdin(); - server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) + server.serve_stdio(std::io::stdout()).map_err(|e| e.to_string()) } fn merge(args: &[String]) -> Result<(), String> { diff --git a/apps/lightcraft-cli/tests/mcp_shutdown.rs b/apps/lightcraft-cli/tests/mcp_shutdown.rs new file mode 100644 index 000000000..29d1ace93 --- /dev/null +++ b/apps/lightcraft-cli/tests/mcp_shutdown.rs @@ -0,0 +1,38 @@ +//! A broken protocol output must not require the client to close its input pipe. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn broken_stdout_exits_with_stdin_still_open() { + let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + drop(child.stdout.take()); + input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap(); + input.flush().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("MCP did not exit after stdout failed while stdin stayed open"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let mut stderr = String::new(); + child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap(); + assert!(stderr.contains("serving MCP on stdio"), "CLI must reach the MCP transport: {stderr}"); + assert!(!status.success(), "broken protocol output must be reported: {stderr}"); + assert!(stderr.contains("lightcraft-cli:"), "CLI must report the transport error: {stderr}"); + drop(input); +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f95dd7a53..f632f145b 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -11,7 +11,8 @@ //! control-channel method names itself (rendering with the engine's pipeline and encoding with //! `lightcraft-codecs`), so agents can develop photos and look at the result without a window. //! -//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +//! Entry points: [`Server::serve_stdio`] (process stdin), [`Server::serve`] (caller-managed +//! streams), and [`Server::handle_line`] (one message). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/mcp/src/progress.rs b/crates/mcp/src/progress.rs index 2043dfd91..3f6a8deec 100644 --- a/crates/mcp/src/progress.rs +++ b/crates/mcp/src/progress.rs @@ -2,8 +2,8 @@ //! serving thread; only stdin and stdout move to transport threads. Other calls wait in order. use std::collections::VecDeque; -use std::io::{BufRead, Write}; -use std::sync::atomic::Ordering; +use std::io::{BufRead, BufReader, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -13,12 +13,17 @@ use serde_json::{Value, json}; use super::{Server, response}; use crate::backend::ProgressHook; -type Input = Option<std::io::Result<String>>; +enum Input { + Line(std::io::Result<String>), + Closed, + OutputFailed, +} #[derive(Clone)] pub(super) struct Wire { inbox: Arc<Mutex<Receiver<Input>>>, - out: Sender<String>, + out: Sender<Option<String>>, + failed: Arc<AtomicBool>, deferred: Arc<Mutex<VecDeque<Input>>>, } @@ -26,56 +31,114 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +fn read_input(input: impl BufRead, tx: Sender<Input>) { + let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for line in input.lines() { + let failed = line.is_err(); + if tx.send(Input::Line(line)).is_err() || failed { + return; + } + } + let _ = tx.send(Input::Closed); + })); + if read.is_err() { + let _ = tx.send(Input::Line(Err(std::io::Error::other("MCP input thread panicked")))); + } +} + impl Server { + /// Serve process stdin once, writing replies to `output` (normally stdout). + /// + /// The caller must finish the process after this returns. On output failure, the single + /// stdin worker can remain blocked until process exit; it owns no backend or borrowed data. + /// Further calls are refused, including after EOF, so blocked readers cannot accumulate. + pub fn serve_stdio(&mut self, output: impl Write + Send) -> std::io::Result<()> { + static STARTED: AtomicBool = AtomicBool::new(false); + if STARTED.swap(true, Ordering::AcqRel) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "MCP stdin already claimed")); + } + let (in_tx, in_rx) = std::sync::mpsc::channel(); + let reader_tx = in_tx.clone(); + let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn(move || read_input(BufReader::new(std::io::stdin()), reader_tx)) + { + Ok(reader) => reader, + Err(error) => { + STARTED.store(false, Ordering::Release); + return Err(error); + } + }; + let served = self.serve_messages(in_rx, in_tx, output); + let read = if reader.is_finished() { reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")) } else { Ok(()) }; + served.and(read) + } + pub(super) fn serve_wire(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> { std::thread::scope(|scope| { - let (in_tx, in_rx) = std::sync::mpsc::channel::<Input>(); - let (out_tx, out_rx) = std::sync::mpsc::channel::<String>(); - let wire = Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, deferred: Arc::new(Mutex::new(VecDeque::new())) }; + let (in_tx, in_rx) = std::sync::mpsc::channel(); + let reader_tx = in_tx.clone(); + let reader = std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || read_input(input, reader_tx))?; + let served = self.serve_messages(in_rx, in_tx, output); + let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")); + served.and(read) + }) + } + + fn serve_messages(&mut self, in_rx: Receiver<Input>, in_tx: Sender<Input>, output: impl Write + Send) -> std::io::Result<()> { + std::thread::scope(|scope| { + let (out_tx, out_rx) = std::sync::mpsc::channel::<Option<String>>(); + let failed = Arc::new(AtomicBool::new(false)); + let wire = + Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, failed: failed.clone(), deferred: Arc::new(Mutex::new(VecDeque::new())) }; let writer = std::thread::Builder::new().name("mcp-output".into()).spawn_scoped(scope, move || -> std::io::Result<()> { let mut output = output; - for line in out_rx { - output.write_all(line.as_bytes())?; - output.write_all(b"\n")?; - output.flush()?; - } - Ok(()) - })?; - let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || { - for line in input.lines() { - let failed = line.is_err(); - if in_tx.send(Some(line)).is_err() || failed { - return; + let written = (|| -> std::io::Result<()> { + while let Ok(Some(line)) = out_rx.recv() { + output.write_all(line.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; } + Ok(()) + })(); + if written.is_err() { + failed.store(true, Ordering::Release); + let _ = in_tx.send(Input::OutputFailed); } - let _ = in_tx.send(None); - }) { - Ok(reader) => reader, - Err(error) => { - drop(wire); - let _ = writer.join(); - return Err(error); - } - }; + written + })?; self.wire = Some(wire.clone()); let served = (|| -> std::io::Result<()> { loop { + if wire.failed.load(Ordering::Acquire) { + return Ok(()); + } let queued = lock(&wire.deferred).pop_front(); let line = match queued { Some(line) => line, - None => lock(&wire.inbox).recv().ok().flatten(), + None => { + if wire.failed.load(Ordering::Acquire) { + return Ok(()); + } + lock(&wire.inbox).recv().map_err(|_| std::io::Error::other("MCP input disconnected without EOF"))? + } }; - let Some(line) = line else { return Ok(()) }; - if let Some(reply) = self.handle_line(&line?) { - wire.out.send(reply).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?; + let line = match line { + Input::Line(line) => line?, + Input::Closed | Input::OutputFailed => return Ok(()), + }; + if wire.failed.load(Ordering::Acquire) { + return Ok(()); + } + if let Some(reply) = self.handle_line(&line) { + wire.out.send(Some(reply)).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?; } } })(); self.wire = None; + // A backend that panicked while clearing its progress hook may still hold a sender. + let _ = wire.out.send(None); drop(wire); let written = writer.join().map_err(|_| std::io::Error::other("MCP output thread panicked")).and_then(|r| r); - let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")); - served.and(written).and(read) + written.and(served) }) } @@ -91,16 +154,29 @@ impl Server { let mut reported: Option<Instant> = None; Some(Box::new(move |done, total, name| { while !closed { + if wire.failed.load(Ordering::Acquire) { + suppress.store(true, Ordering::Relaxed); + return false; + } let next = lock(&wire.inbox).try_recv(); let line = match next { - Ok(Some(Ok(line))) => line, - Ok(Some(Err(error))) => { - lock(&wire.deferred).push_back(Some(Err(error))); + Ok(Input::Line(Ok(line))) => line, + Ok(Input::Line(Err(error))) => { + lock(&wire.deferred).push_back(Input::Line(Err(error))); closed = true; break; } - Ok(None) | Err(TryRecvError::Disconnected) => { - lock(&wire.deferred).push_back(None); + Ok(Input::OutputFailed) => { + suppress.store(true, Ordering::Relaxed); + return false; + } + Ok(Input::Closed) => { + lock(&wire.deferred).push_back(Input::Closed); + closed = true; + break; + } + Err(TryRecvError::Disconnected) => { + lock(&wire.deferred).push_back(Input::Line(Err(std::io::Error::other("MCP input disconnected without EOF")))); closed = true; break; } @@ -115,12 +191,13 @@ impl Server { if msg.get("id").is_some_and(|i| i.is_string() || i.is_number()) && msg.get("params").is_none_or(|p| p.is_null() || p.is_object()) => { - let _ = wire.out.send(response(msg["id"].clone(), json!({})).to_string()); + let _ = wire.out.send(Some(response(msg["id"].clone(), json!({})).to_string())); } - _ => lock(&wire.deferred).push_back(Some(Ok(line))), + _ => lock(&wire.deferred).push_back(Input::Line(Ok(line))), } } - if suppress.load(Ordering::Relaxed) { + if suppress.load(Ordering::Relaxed) || wire.failed.load(Ordering::Acquire) { + suppress.store(true, Ordering::Relaxed); return false; } if let Some(token) = &token @@ -137,13 +214,17 @@ impl Server { }; let notification = json!({"jsonrpc":"2.0","method":"notifications/progress", "params":{"progressToken":token,"progress":done,"total":total,"message":message}}); - let _ = wire.out.send(notification.to_string()); + let _ = wire.out.send(Some(notification.to_string())); } true })) } } +#[cfg(test)] +#[path = "progress_tests.rs"] +mod shutdown_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mcp/src/progress_tests.rs b/crates/mcp/src/progress_tests.rs new file mode 100644 index 000000000..fb7859dc5 --- /dev/null +++ b/crates/mcp/src/progress_tests.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::Backend; +use std::io::{BufReader, Cursor, Read}; +use std::process::{Command, Stdio}; + +struct ExportUntilCancelled { + hook: Option<ProgressHook>, + retain_hook: bool, +} + +impl Backend for ExportUntilCancelled { + fn call(&mut self, _: &str, _: Value) -> Result<Value, String> { + if self.retain_hook { + return Ok(json!({})); + } + let hook = self.hook.as_mut().expect("export installs a progress hook"); + while hook(0, 2, "photo.png") { + std::thread::sleep(Duration::from_millis(1)); + } + Ok(json!({"cancelled": true})) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "test".into() + } + + fn set_progress(&mut self, hook: Option<ProgressHook>) { + assert!(!self.retain_hook || hook.is_some(), "synthetic clear-hook panic"); + self.hook = hook; + } +} + +struct FailedOutput { + flush: bool, + initialized: bool, +} + +impl Write for FailedOutput { + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { + if !self.initialized || self.flush { Ok(bytes.len()) } else { Err(std::io::Error::other("synthetic output failure")) } + } + + fn flush(&mut self) -> std::io::Result<()> { + if self.initialized { + Err(std::io::Error::other("synthetic output failure")) + } else { + self.initialized = true; + Ok(()) + } + } +} + +// The child owns the blocked read; a regression cannot strand the test runner in a thread join. +#[test] +#[ignore = "subprocess helper for the bounded shutdown regression"] +fn output_failure_child() { + let mode = std::env::var("LIGHTCRAFT_MCP_SHUTDOWN_CASE").unwrap(); + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: mode == "retained-hook" })); + if mode == "retained-hook" { + let request = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"dir":"unused"}}}); + let mut output = Vec::new(); + server.serve(Cursor::new(format!("{request}\n")), &mut output).unwrap(); + let reply: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(reply["result"]["isError"], true); + assert!(reply.to_string().contains("synthetic clear-hook panic")); + } else { + let error = server.serve_stdio(FailedOutput { flush: mode.ends_with("flush"), initialized: false }).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert_eq!(error.to_string(), "synthetic output failure"); + assert!(server.wire.is_none()); + let mut another = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let error = another.serve_stdio(std::io::sink()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + } + eprintln!("MCP shutdown verified"); +} + +#[test] +fn idle_write_failure_does_not_wait_for_input_eof() { + run_shutdown_case("idle-write"); +} + +#[test] +fn idle_flush_failure_does_not_wait_for_input_eof() { + run_shutdown_case("idle-flush"); +} + +#[test] +fn progress_write_failure_does_not_wait_for_input_eof() { + run_shutdown_case("progress-write"); +} + +#[test] +fn progress_flush_failure_does_not_wait_for_input_eof() { + run_shutdown_case("progress-flush"); +} + +fn run_shutdown_case(mode: &str) { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "server::progress::shutdown_tests::output_failure_child", "--ignored", "--nocapture"]) + .env("LIGHTCRAFT_MCP_SHUTDOWN_CASE", mode) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + if mode != "retained-hook" { + let request = if mode.starts_with("progress") { + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"dir":"unused"},"_meta":{"progressToken":"photos"}}}) + } else { + json!({"jsonrpc":"2.0","id":2,"method":"ping"}) + }; + let initialize = json!({"jsonrpc":"2.0","id":0,"method":"initialize"}); + let requests = format!("{initialize}\n{request}\n{{\"jsonrpc\":"); + input.write_all(requests.as_bytes()).unwrap(); + input.flush().unwrap(); + } + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("{mode}: MCP shutdown waited for input EOF"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let mut stderr = String::new(); + child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap(); + assert!(status.success(), "{mode}: {status}: {stderr}"); + assert!(stderr.contains("MCP shutdown verified"), "child did not exercise shutdown: {stderr}"); + drop(input); +} + +#[test] +fn retained_progress_hook_does_not_keep_output_alive() { + run_shutdown_case("retained-hook"); +} + +#[test] +fn input_errors_and_eof_keep_their_meaning() { + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let error = server.serve(Cursor::new(vec![0xff]), std::io::sink()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(server.wire.is_none()); + server.serve(Cursor::new(b""), std::io::sink()).unwrap(); +} + +#[test] +fn reader_panic_is_an_input_error() { + struct PanickingRead; + impl Read for PanickingRead { + fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> { + panic!("synthetic input panic"); + } + } + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let error = server.serve(BufReader::new(PanickingRead), std::io::sink()).unwrap_err(); + assert_eq!(error.to_string(), "MCP input thread panicked"); +} + +#[test] +fn output_error_takes_precedence_over_input_error() { + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let mut input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n".to_vec(); + input.push(0xff); + let error = server.serve(Cursor::new(input), FailedOutput { flush: false, initialized: true }).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert_eq!(error.to_string(), "synthetic output failure"); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 4565db76e..de086ca70 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -84,7 +84,9 @@ impl Server { } /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is - /// the protocol stream). + /// the protocol stream). The scoped input worker must reach EOF or an input error before + /// this returns, including on output failure: callers must unblock their own input. + /// For a process serving stdin, use [`Self::serve_stdio`] instead. pub fn serve(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> { self.serve_wire(input, output) } diff --git a/docs/mcp.md b/docs/mcp.md index f0cc9a1d1..ec29a75b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -314,5 +314,15 @@ being processed finishes first: completed photos remain, each written with the e file writer, and there are no partial files to delete. Unrelated outputs and earlier exports are untouched. A failed export is an `isError` tool result. Transport mutexes recover poisoning. +If a protocol write or flush fails, the CLI reports the output error and shuts down without +waiting for stdin to close. Output failures are observable only when writing. An active direct +headless export stops at the next photo boundary; a synchronous command and normal backend +cleanup must finish first. Completed exports and library cleanup retain their usual guarantees. + +Library callers serving process stdin use `Server::serve_stdio(stdout())` once per process and +exit after it returns. Its single stdin worker may remain blocked until process exit. The generic +`Server::serve(input, output)` keeps borrowed input scoped: its caller must arrange input EOF or +an input error, even when output fails. + Connect mode and exports inside `command_batch` remain synchronous and do not report MCP progress or cancellation. Use a direct headless export call for this behavior. From 2b86ca0537203a8d96f656069f41ee49c303bd90 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:23:40 +0200 Subject: [PATCH 127/315] LR-EXP-TYPE: omit redundant alpha from opaque 8-bit exports (#1670) Preserve real transparency, pixels and embedded metadata. Cover RGB PNG, TIFF compression modes and WebP, plus real-alpha round trips. Co-authored-by: Codex <codex@openai.com> --- crates/engine/src/export.rs | 47 ++++++++++++++++++++++++++++++++++++- docs/parity-checklist.md | 3 ++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..69f4f7b03 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1351,7 +1351,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let e = EncodeImage::rgba8(&img); + // Opaque photo exports need no alpha channel (#1670). Keep real transparency for + // callers of this encoder, and leave unrelated formats on their existing path. + let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) + && img.data.iter().all(|p| p.last() == Some(&255))) + .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); + let e = match &rgb { + Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), + None => EncodeImage::rgba8(&img), + }; let r = match o.format { ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). @@ -2268,6 +2276,43 @@ mod tests { img } + /// Issue #1670: removing a redundant alpha channel preserves samples and metadata. + #[test] + fn opaque_eight_bit_exports_are_rgb() { + let img = test_image(); + let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; + let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); + assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); + assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); + assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); + if format == ExportFormat::Png { + assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); + } + if format != ExportFormat::Tiff { + break; + } + } + } + } + + #[test] + fn eight_bit_exports_preserve_real_alpha() { + let mut img = test_image(); + img.data[0][3] = 128; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(decoded.has_alpha, "{format:?} must retain real alpha"); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); + } + } + #[test] fn formats_have_magic() { let img = test_image(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..4f922e47d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -385,7 +385,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | From 8628c160dfbb6ddb50f7af0c144fb96fc030278f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 10:33:43 +0200 Subject: [PATCH 128/315] Loupe: the info overlay's shadow is dark, not a second white copy of the text (fixes #1690) --- crates/ui-egui/src/panels/detail.rs | 42 ++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..73790037e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -850,7 +850,9 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li let mut y = canvas.top() + 12.0; for (i, l) in lines.iter().enumerate() { let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) }; - let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE); + // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white + // layout made the shadow a second white copy (#1690) + let g = p.layout_no_wrap(l.clone(), font, Color32::PLACEHOLDER); let at = pos2(canvas.left() + 14.0, y); // a soft shadow keeps the text readable on bright photos p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); @@ -2689,3 +2691,41 @@ mod filter_pill_tests { assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}"); } } + +#[cfg(test)] +mod info_overlay_tests { + use super::*; + + /// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white + /// copy one point off, each line read doubled. + #[test] + fn the_info_overlay_shadow_is_dark() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo)); + out.textures_delta.clear(); + // the colour a text shape paints its glyphs in, as epaint resolves it + let ink = |s: &egui::epaint::TextShape| { + let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color); + s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c }) + }; + fn texts(shape: &egui::epaint::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + let copies: Vec<Color32> = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect(); + assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}"); + assert_eq!(copies[1], Color32::WHITE, "the text on top"); + assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); + } +} From 3ae306ecb31e856f4fd5a15d6bcbca23fe92029c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 14 Oct 2026 08:41:52 +0000 Subject: [PATCH 129/315] LR-IMP-FORMATS: decode EOS R6 Mark II C-RAW with adaptive QP up to 175 EOS R6 Mark II C-RAW QP maps reach 172 and 175; crx.rs accepted 131-168, so those files opened as their preview ("Canon CRX adaptive QP value 172"). Adaptive QP now goes to 175 (quantization value 51, step 228), the highest seen in any file; uniform subband values still stop at 44. Checked black-box against the camera JPEGs on private files: 144 R6 II / R7 C-RAW files decode to identical hashes where they decoded before, the 3 failing ones now decode (green follows the JPEG at r 0.98), fine detail per QP shows no jump at 172/175, 100 % crops are clean, and 1,492 files sampled from a 35,796-file library decode with max QP 175. No third-party decoder source consulted. Bumps RENDER_CACHE_VERSION to 63; cr3.md and What's New. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YSGFEf87ba1zKJZb9uzddK --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/crx.rs | 52 +++++++++++++++++++++++++++--------- docs/cr3.md | 18 ++++++++++++- docs/whats-new.md | 5 ++++ 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0688410ed..93b8cdad6 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 62; +pub const RENDER_CACHE_VERSION: u64 = 63; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 0321ac042..853efdffe 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -30,6 +30,12 @@ const MAX_CRX_SAMPLES: usize = 200_000_000; const MAX_CRX_EDGE: u32 = 65_536; /// Unary QP-map prefix length that announces an 8-bit escaped symbol. const QP_ESCAPE_ZEROS: u32 = 23; +/// Highest uniform (per-subband) quantization value with a measured step. +const MAX_UNIFORM_QUANT: u8 = 44; +/// Highest adaptive quantization value (QP − 124) seen in real files: QP 175 on the EOS R6 Mark II. +/// See [`quant_step`]. +const MAX_ADAPTIVE_QUANT: u8 = 51; +const MAX_QP: i32 = 124 + MAX_ADAPTIVE_QUANT as i32; const RUN_EXPONENT: [u8; 32] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15]; fn corrupt(reason: &str) -> RawError { @@ -207,7 +213,7 @@ fn validate_planes(tiles: &[Tile], config: &Cr3Compression) -> Result<()> { if config.levels != 0 { match band.quant { Quantization::Uniform(value) => { - quant_step(value)?; + quant_step(value, MAX_UNIFORM_QUANT)?; } Quantization::Adaptive { gain, .. } => { if index < 4 && gain != 0 { @@ -342,12 +348,14 @@ fn tile_qp(sample: &[u8], tile: &Tile) -> Result<Option<QpMap>> { .transpose() } -fn quant_step(value: u8) -> Result<i32> { +fn quant_step(value: u8, max: u8) -> Result<i32> { // Measured independently with an external decoder after modifying only the // quantValue field: all forty values 4..43 follow this integer sequence. Value 44 (QP 168) // continues it (step 102); it was checked against a measured step of about 100 on an EOS R50 - // C-RAW file. Nothing above 44 has been seen, so it stays rejected. - if !(4..=44).contains(&value) { + // C-RAW file. Uniform subband values stop there (`MAX_UNIFORM_QUANT`). Adaptive QP maps reach + // QP 175 (value 51) on the EOS R6 Mark II; those steps (114..228) continue the same sequence and + // were checked against the camera's own JPEGs (docs/cr3.md). Anything higher is still rejected. + if !(4..=max).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX quantization value {value}"))); } let scale = [40i32, 45, 51, 57, 64, 72].get(usize::from(value % 6)).copied().ok_or_else(|| corrupt("quantization table index"))?; @@ -403,7 +411,7 @@ fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edge }; for (position, value) in data.iter_mut().enumerate() { let step = match band.quant { - Quantization::Uniform(value) => quant_step(value)?, + Quantization::Uniform(value) => quant_step(value, MAX_UNIFORM_QUANT)?, Quantization::Adaptive { base, gain } => { adaptive_step(qp.ok_or_else(|| corrupt("missing QP map"))?, i, position % w, position / w, base, gain)? } @@ -443,7 +451,7 @@ fn adaptive_step(map: &QpMap, band: usize, x: usize, y: usize, base: u32, gain: // QP rows are averaged before looking up the integer step. R8 field // mutations distinguish this order from averaging dequantization steps. let quant = u8::try_from(value - 124).map_err(|_| corrupt("QP value outside quantization table"))?; - u64::from(base) + u64::from(gain) * quant_step(quant)? as u64 / 8 + u64::from(base) + u64::from(gain) * quant_step(quant, MAX_ADAPTIVE_QUANT)? as u64 / 8 }; i32::try_from(step.max(1)).map_err(|_| corrupt("adaptive quantization step outside precision")) } @@ -775,7 +783,7 @@ fn qp_map(src: &[u8], width: usize, height: usize) -> Result<QpMap> { // The map opens with its first symbol as a 32-bit literal; every later one is Rice coded. let symbol = if x == 0 && y == 0 { (bits.take(16)? << 16) | bits.take(16)? } else { bits.qp_rice(k)? }; let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?; - if !(131..=168).contains(&value) { + if !(131..=MAX_QP).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}"))); } *current.get_mut(x).ok_or_else(|| corrupt("QP outside row"))? = value; @@ -990,10 +998,19 @@ mod tests { #[test] fn measured_quantization_and_allocation_limits() { for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90), (44, 102)] { - assert_eq!(quant_step(quant).unwrap(), expected); + assert_eq!(quant_step(quant, MAX_UNIFORM_QUANT).unwrap(), expected); } - assert!(matches!(quant_step(3), Err(RawError::Unsupported(_)))); - assert!(matches!(quant_step(45), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(3, MAX_UNIFORM_QUANT), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(45, MAX_UNIFORM_QUANT), Err(RawError::Unsupported(_)))); + // Adaptive QP 169..=175 (EOS R6 Mark II C-RAW) continue the sequence; QP 176 does not decode. + for (quant, expected) in [(45, 114), (46, 128), (47, 144), (48, 160), (49, 180), (50, 204), (51, 228)] { + assert_eq!(quant_step(quant, MAX_ADAPTIVE_QUANT).unwrap(), expected); + } + assert!(matches!(quant_step(52, MAX_ADAPTIVE_QUANT), Err(RawError::Unsupported(_)))); + let high = QpMap { width: 1, height: 2, data: vec![172, 175] }; + // floor((172+175)/2) = 173 → value 49, step 180; gain 8/8 plus base 1. + assert_eq!(adaptive_step(&high, 4, 0, 0, 1, 8).unwrap(), 181); + assert_eq!(adaptive_step(&high, 7, 0, 1, 0, 8).unwrap(), 228); for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] { let mut config = coding(); config.width = width; @@ -1032,9 +1049,18 @@ mod tests { } #[test] - fn qp_168_is_accepted_and_169_is_not() { - assert_eq!(qp_map(&[0, 0, 1, 0x50, 0x94], 1, 4).unwrap().data, [168; 4]); - assert!(matches!(qp_map(&[0, 0, 1, 0x52, 0x94], 1, 4), Err(RawError::Unsupported(_)))); + fn qp_limit_is_accepted_and_one_above_is_not() { + // The first symbol is a 32-bit literal: signed(2·QP) = QP from a prediction of 0. QP 172 and 175 + // occur in EOS R6 Mark II maps (3 of 40 sample files failed with them before). + let map = |qp: u32| { + let symbol = 2 * qp; + let mut src = vec![0, 0, (symbol >> 8) as u8, symbol as u8]; + src.push(0x94); + qp_map(&src, 1, 4) + }; + assert_eq!(map(168).unwrap().data, [168; 4]); + assert_eq!(map(MAX_QP as u32).unwrap().data, [MAX_QP; 4]); + assert!(matches!(map(MAX_QP as u32 + 1), Err(RawError::Unsupported(_)))); } #[test] diff --git a/docs/cr3.md b/docs/cr3.md index 831d6017d..5060732aa 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -93,7 +93,23 @@ The version `0x200` QP predictor and entropy framing were checked independently. Controlled input mutations establish floor averaging of paired QP rows before the nonlinear integer table, then multiplication, division by eight, addition of the subband base and a minimum step of one. QP values 131–167 are verified; -unmeasured values are rejected. +168–175 continue the same integer sequence (below); higher values are rejected. + +EOS R6 Mark II C-RAW maps reach QP 172 and 175, which the decoder used to reject ("Canon CRX adaptive QP +value 172"; the file opened as its preview). Quantization values 45–51 (QP 169–175) give steps 114, 128, +144, 160, 180, 204 and 228, continuing the 2^(1/6) ladder of the measured values. No external decoder was +run; the check is black-box against the camera, on a user's private files: + +- 144 C-RAW files (EOS R6 Mark II and R7): every file that decoded before decodes to an identical sensor + hash; the 3 that failed (QP maps 168–175) now decode, and their green mosaic follows the camera's + embedded JPEG at Pearson r 0.980–0.984 (24 × 16 cells, as in the corpus test), within the set's range; +- per QP value, the ratio of fine detail in the decode to fine detail in the camera JPEG over the same + 16 × 4-pixel QP cells (pooled over the files) is 6.2 at QP 172 and 12.2 at QP 175, inside the + 2.8–15.5 spread of the already-accepted QPs 150–165; a wrong step at those QPs would show as a jump; +- 100 % crops round the highest-QP cells of six files show no blocking, banding, ringing or seams, and + no change in noise between high-QP areas and their surroundings; +- 1,492 files sampled from a 35,796-file library (1,094 R7, 298 R6 Mark II, 100 R100) decode with no + errors; the highest QP is 175 (3 of the 298 R6 Mark II files go above 167; the R7 stays at or below 164). The QP map is coded as follows (established on the EOS R5 Mark II). Its first symbol is a 32-bit big-endian literal, mapped like every other symbol and added to a prediction of zero. Every later diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..65d8a136f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### More Canon EOS R6 Mark II C-RAW files decode +- Some EOS R6 Mark II C-RAW files (about 1 in 50 in one photographer's library) opened as their preview with + "Canon CRX adaptive QP value 172" or "175". They now decode from the sensor data. Photos imported before this + switch over with Photo ▸ Reload from Disk. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From f625c74c7a87513bcc0cf9c981eef940b79220d6 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Wed, 14 Oct 2026 19:43:44 +1100 Subject: [PATCH 130/315] Faces: whole networks on the GPU, batched faces, CPU-only switch, paced GPU use Activations stay on the device between layers (net/program.rs lowers the graph; every operator is a WGSL kernel, one submission and read-back per run); faces of a photo go in batches of up to 16; Settings > Faces shows each model's device and a CPU-only switch (faces.gpu); the scan's pace limits GPU use; the GPU check is an activity row. YuNet 58 -> 21 ms, SFace 40 -> 9.6 ms, AuraFace 295 -> 71 ms (heavily loaded machine). Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/engine/src/cmd/face_detect.rs | 140 ++++- crates/engine/src/cmd/face_models.rs | 12 + crates/engine/src/cmd/face_recognize.rs | 5 +- crates/engine/src/faces_worker.rs | 28 +- crates/engine/src/tests_face_models.rs | 14 + crates/faces/src/net.rs | 241 ++++++++- crates/faces/src/net/kernels.rs | 8 +- crates/faces/src/net/program.rs | 315 +++++++++++ crates/faces/src/net/shape.rs | 10 +- crates/faces/src/runtime.rs | 76 ++- crates/faces/src/synthetic.rs | 60 +++ crates/gpu/src/faces.rs | 671 +++++++++++++++++++----- crates/gpu/src/wgsl/faces_ops.wgsl | 188 +++++++ crates/ui-egui/locales/de.json | 10 +- crates/ui-egui/locales/es.json | 10 +- crates/ui-egui/locales/fr.json | 10 +- crates/ui-egui/locales/ja.json | 10 +- crates/ui-egui/locales/pt-br.json | 10 +- crates/ui-egui/locales/ru.json | 10 +- crates/ui-egui/locales/uk.json | 10 +- crates/ui-egui/locales/zh-hans.json | 10 +- crates/ui-egui/locales/zh-hant.json | 10 +- crates/ui-egui/src/panels/faces.rs | 37 ++ docs/faces.md | 49 +- 24 files changed, 1753 insertions(+), 191 deletions(-) create mode 100644 crates/faces/src/net/program.rs create mode 100644 crates/gpu/src/wgsl/faces_ops.wgsl diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 8e706c367..718420ffc 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options}; use lightcraft_meta::{Region, RegionKind}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_selection}; +use super::{CommandSpec, always, bad, cmd, has_selection}; use crate::{Result, Session}; const C: &str = "faces.detect"; @@ -34,10 +34,12 @@ const EXISTING_IOU: f64 = 0.3; /// What to tell someone who has not downloaded the detector yet. const NOT_INSTALLED: &str = "finding faces needs the YuNet face detector (232 KB, MIT): download it in Settings > Faces"; +/// The YuNet loaded last, and from where. +static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None); + /// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes). /// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none) pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String> { - static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None); let home = s.face_models_dir.as_deref().ok_or("this build has nowhere to keep face models (the desktop app does)")?.join(YUNET_ID); let path = home.join("model.onnx"); // installing writes the manifest last, so a folder without one is not (yet) a model @@ -59,35 +61,130 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, String #[cfg(not(target_arch = "wasm32"))] { let net = d.clone(); - offer_gpu("detection", move |gpu| net.accelerate(gpu, true)); + offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true)); } *loaded = Some((path, d.clone())); Ok(d) } +/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`. +#[derive(Clone, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GpuState { + /// The background check is still running. + pub checking: bool, + /// The GPU it runs on; `None` when it runs on the CPU. + pub device: Option<String>, + /// Why it runs on the CPU. + pub why: Option<String>, +} + +/// Each model's [`GpuState`], by what it does ("detection", "recognition"). +static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new()); + +pub(crate) fn gpu_state(what: &str) -> Option<GpuState> { + GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone()) +} + +fn set_gpu_state(what: &'static str, st: GpuState) { + let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner); + match all.iter_mut().find(|(w, _)| *w == what) { + Some((_, old)) => *old = st, + None => all.push((what, st)), + } +} + +/// The user chose the CPU for face models (Settings ▸ Faces). +pub(crate) fn cpu_only(s: &Session) -> bool { + s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only) +} + /// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model -/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower. +/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU. +/// The check shows in the activity stack while it runs. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn offer_gpu( + s: &Session, what: &'static str, attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static, ) { + if cpu_only(s) { + set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() }); + return; + } + let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No); + task.detail(what); + set_gpu_state(what, GpuState { checking: true, ..GpuState::default() }); let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || { + let _task = task; let gpu = match lightcraft_gpu::faces::accelerator() { Ok(gpu) => gpu, - Err(why) => return log::info!("faces: {what} runs on the CPU ({why})"), + Err(why) => { + log::info!("faces: {what} runs on the CPU ({why})"); + return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() }); + } }; let device = lightcraft_faces::net::Accelerator::device(&*gpu); match attach(gpu) { - Ok(()) => log::info!("faces: {what} runs on {device}"), - Err(why) => log::info!("faces: {what} stays on the CPU: {why}"), + Ok(()) => { + log::info!("faces: {what} runs on {device}"); + set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() }); + } + Err(why) => { + log::info!("faces: {what} stays on the CPU: {why}"); + set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() }); + } } }); if let Err(e) = spawned { log::warn!("faces: could not check the GPU for {what}: {e}"); + set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() }); } } +/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU. +fn gpu(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "faces.gpu"; + if let Some(cpu) = p.get("cpuOnly") { + let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?; + let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?; + super::face_models::set_cpu_only(&dir, cpu)?; + let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone()); + #[cfg(not(target_arch = "wasm32"))] + let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone()); + #[cfg(target_arch = "wasm32")] + let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None; + if cpu { + for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] { + if let Some(net) = net { + net.detach(); + } + set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() }); + } + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(d) = detector { + offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true)); + } + if let Some(e) = embedder { + offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string())); + } + } + } + } + Ok(gpu_json(s)) +} + +/// What `faces.gpu` and `faces.models.list` say about the GPU. +pub(crate) fn gpu_json(s: &Session) -> Value { + json!({ + "cpuOnly": cpu_only(s), + "detection": gpu_state("detection"), + "recognition": gpu_state("recognition"), + }) +} + fn face_json(f: &Face) -> Value { json!({ "rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1}, @@ -175,15 +272,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> { } pub fn specs() -> Vec<CommandSpec> { - vec![cmd!( - "faces.detect", - "Detect Faces", - ["Photo"], - None, - "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched", - has_selection, - detect - )] + vec![ + cmd!( + "faces.detect", + "Detect Faces", + ["Photo"], + None, + "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched", + has_selection, + detect + ), + cmd!( + query "faces.gpu", + "Face Models on the GPU", + [], + None, + "{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again", + always, + gpu + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 6033c4dcc..1ea39d04c 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -27,6 +27,9 @@ pub(super) struct FaceSettings { pub(super) enabled: bool, /// The installed recogniser in use. pub(super) embedder: Option<String>, + /// Keep every face model on the CPU, even when the GPU would be faster. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub(super) cpu_only: bool, } fn fail(what: &str, e: impl std::fmt::Display) -> EngineError { @@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { }) } +/// Keep the face models on the CPU (or let them use the GPU again). +pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> { + let mut st = read_settings(dir); + st.cpu_only = on; + write_settings(dir, &st) +} + fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> { std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?; write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?) @@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> { "dir": dir.as_ref().map(|d| d.display().to_string()), "enabled": settings.enabled, "embedder": settings.embedder, + // where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it + "gpu": super::face_detect::gpu_json(s), // Native builds always have the pure-Rust runner; model files/background scanning remain native-only. "runtime": cfg!(not(target_arch = "wasm32")), "models": models, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index 094735c6d..509a5bc70 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -235,7 +235,7 @@ mod imp { } let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?); let net = embedder.clone(); - super::super::face_detect::offer_gpu("recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string())); + super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string())); let dim = match installed.manifest.output { lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize, _ => return Err(bad(cmd, "the chosen model is not a recognition model")), @@ -584,6 +584,9 @@ mod imp { if let Some(w) = &s.faces.worker { w.allow(target, pace); } + // the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders + let (runs, share) = crate::faces_worker::gpu_pace(pace); + lightcraft_gpu::faces::set_pace(runs, share); while target > 0 && s.faces.in_flight.len() < target + PREFETCH { let Some(id) = s.faces.queue.pop() else { break }; let Some(job) = prepare(s, id, true) else { continue }; diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 81ff6adae..e44ad77c9 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize { cap.map_or(n, |c| n.min(c.max(1))) } +/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be +/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders +/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes. +pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) { + match pace { + Pace::Pause | Pace::Light => (1, 0.25), + Pace::Normal => (2, 0.75), + Pace::Full => (4, 1.0), + } +} + /// One photo's faces to embed (and, if `detect`, to find first). pub(crate) struct Prepared { /// `<model id>@<sha256>` the embeddings are for. @@ -197,7 +208,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { let img = Rgb { data: &rgb, width: w, height: h }; let (iw, ih) = embedder.input_size(); let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32)); - let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo + // every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo) + let mut faces: Vec<(Rect, Option<Vec<u8>>, Rect)> = todo .into_iter() .map(|rect| { let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0)); @@ -214,20 +226,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { ih, ), }; - let v = crop.and_then(|c| embedder.embed(&c).ok()); // shown by the detector's box where it found this face: every face then has the same tightness let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect); - (rect, v, view) + (rect, crop, view) }) .collect(); // a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass let new_faces = if detect { - detected.map(|faces| { + detected.map(|found| { let mut rects = Vec::new(); - for f in faces.iter().filter(|f| f.score >= REGION_SCORE) { + for f in found.iter().filter(|f| f.score >= REGION_SCORE) { let Some(rect) = face_rect(f) else { continue }; - let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok()); - results.push((rect, v, rect)); + faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect)); rects.push(rect); } rects @@ -235,6 +245,10 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { } else { None }; + let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect(); + let mut embedded = embedder.embed_batch(&crops).into_iter(); + let results: Vec<(Rect, Option<Vec<f32>>, Rect)> = + faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect(); (results, new_faces) })); let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None)); diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index f1c27c849..00d6dc0cc 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -613,3 +613,17 @@ fn recognition_cannot_be_turned_on_without_a_chosen_model() { assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err()); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn the_cpu_only_switch_is_kept_and_bad_values_are_refused() { + let d = temp("cpu-only"); + let mut s = session(&d); + assert_eq!(s.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], false); + assert_eq!(s.execute("faces.gpu", &json!({"cpuOnly": true})).unwrap()["cpuOnly"], true); + // kept with the models, so a new session starts on the CPU too + let mut again = session(&d); + assert_eq!(again.execute("faces.models.list", &json!({})).unwrap()["gpu"]["cpuOnly"], true); + assert!(again.execute("faces.gpu", &json!({"cpuOnly": "yes"})).is_err()); + assert_eq!(again.execute("faces.gpu", &json!({"cpuOnly": false})).unwrap()["cpuOnly"], false); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 3983404c5..430335840 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -12,6 +12,7 @@ use std::rc::Rc; use std::sync::{Arc, PoisonError, RwLock}; mod kernels; +pub mod program; mod shape; use crate::graph::{Data, Graph, Node}; @@ -111,6 +112,12 @@ pub trait Accelerator: Send + Sync { fn device(&self) -> String; /// `[cout][oh][ow]`, or `Ok(None)` to leave this one to the CPU (too small to be worth a dispatch, say). fn conv(&self, job: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String>; + /// The whole network on the device, keeping every value there between steps, for each of `inputs` (all of + /// `program.lens[INPUT]` values) in one go: per input, each of `program.outputs` in order. `Ok(None)` leaves the + /// run to the CPU path (which may still use [`Accelerator::conv`]). + fn run_program(&self, _program: &program::Program, _inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> { + Ok(None) + } } /// A loaded network, ready to run. @@ -190,7 +197,7 @@ impl Net { /// A convolution on the accelerator, if one is attached and takes it. A failing accelerator is dropped. fn accelerated_conv(&self, layer: usize, node: &Node, x: &Tensor) -> Result<Option<Tensor>> { let Some(accelerator) = self.accelerator() else { return Ok(None) }; - let job = kernels::conv_job(layer, node, x, &self.graph)?; + let job = kernels::conv_job(layer, node, &x.shape, &x.data, &self.graph)?; match accelerator.conv(&job) { Ok(Some(out)) if out.len() == job.cout * job.oh * job.ow => Ok(Some(Tensor::new(vec![1, job.cout, job.oh, job.ow], out)?)), Ok(None) => Ok(None), @@ -214,7 +221,67 @@ impl Net { /// Run the network on `input` (its single input); returns each output by name. pub fn run(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> { - shape::tensor(&input.shape, input.data.len(), &input.data)?; + self.run_batch(vec![input])?.pop().ok_or_else(|| NetError::Shape("the run gave nothing".into())) + } + + /// Run the network on each of `inputs`; an accelerator that keeps the values on its device runs inputs of one shape + /// together (one upload and one read-back for all of them). + pub fn run_batch(&self, inputs: Vec<Tensor>) -> Result<Vec<Vec<(String, Tensor)>>> { + for input in &inputs { + shape::tensor(&input.shape, input.data.len(), &input.data)?; + } + if let Some(out) = self.run_on_device(&inputs)? { + return Ok(out); + } + inputs.into_iter().map(|input| self.run_one(input)).collect() + } + + /// The whole batch on the attached accelerator, or `None` to run it on the CPU path. A failing or misbehaving + /// accelerator is dropped. + fn run_on_device(&self, inputs: &[Tensor]) -> Result<Option<Vec<Vec<(String, Tensor)>>>> { + let (Some(accelerator), Some(first)) = (self.accelerator(), inputs.first()) else { return Ok(None) }; + if inputs.iter().any(|t| t.shape != first.shape) { + return Ok(None); + } + let Some(program) = self.program(&first.shape)? else { return Ok(None) }; + let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect(); + let started = web_time::Instant::now(); + let outs = match accelerator.run_program(&program, &slices) { + Ok(Some(outs)) => outs, + Ok(None) => return Ok(None), + Err(_) => { + self.detach(); + return Ok(None); + } + }; + let gathered: Option<Vec<Vec<(String, Tensor)>>> = (outs.len() == inputs.len()) + .then(|| { + outs.into_iter() + .map(|values| { + (values.len() == program.outputs.len()) + .then(|| { + values + .into_iter() + .zip(&program.outputs) + .map(|(data, (name, _, shape))| Tensor::new(shape.clone(), data).ok().map(|t| (name.clone(), t))) + .collect::<Option<Vec<_>>>() + }) + .flatten() + }) + .collect() + }) + .flatten(); + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("[faces-gpu] {} steps x {}: {:.3} ms", program.steps.len(), inputs.len(), started.elapsed().as_secs_f64() * 1000.0); + } + if gathered.is_none() { + // wrong sizes or values that are not finite: the device is not trusted any more + self.detach(); + } + Ok(gathered) + } + + fn run_one(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> { self.validate_input(&input.shape)?; let mut scratch = kernels::Scratch::default(); let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some(); @@ -745,6 +812,176 @@ mod tests { } } + /// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls; + /// `poison` makes it give a value that is not finite. + struct ProgramFake { + calls: std::sync::atomic::AtomicUsize, + poison: bool, + } + + fn run_steps(p: &program::Program, input: &[f32]) -> Vec<Vec<f32>> { + use program::{Arg, Binary, Op, Unary}; + let mut values: Vec<Vec<f32>> = vec![Vec::new(); p.lens.len()]; + values[program::INPUT] = input.to_vec(); + for step in &p.steps { + let arg = |i: usize| -> Vec<f32> { + match &step.args[i] { + Arg::Value(v) => values[*v].clone(), + Arg::Const { data, .. } => data.to_vec(), + } + }; + let len = p.lens[step.out]; + let x = arg(0); + let out: Vec<f32> = match &step.op { + Op::Conv(j) => { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; len]; + for co in 0..j.cout { + for oy in 0..j.oh { + for ox in 0..j.ow { + let mut v = j.bias.map_or(0.0, |b| b[co]); + for ci in 0..cg { + for ky in 0..j.kh { + for kx in 0..j.kw { + let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { + v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] + * x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(), + Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(), + Op::ChannelAffine { channels, plane } => { + let (s, o) = (arg(1), arg(2)); + x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect() + } + Op::ChannelPrelu { channels, plane } => { + let a = arg(1); + x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect() + } + Op::Binary { op, dims, a, b } => { + let y = arg(1); + (0..len) + .map(|i| { + let (mut rest, mut ia, mut ib) = (i, 0, 0); + for d in (0..4).rev() { + let at = rest % dims[d]; + rest /= dims[d]; + ia += at * a[d]; + ib += at * b[d]; + } + match op { + Binary::Add => x[ia] + y[ib], + Binary::Sub => x[ia] - y[ib], + Binary::Mul => x[ia] * y[ib], + } + }) + .collect() + } + Op::Transpose { dims, strides } => (0..len) + .map(|i| { + let (mut rest, mut from) = (i, 0); + for d in (0..4).rev() { + from += (rest % dims[d]) * strides[d]; + rest /= dims[d]; + } + x[from] + }) + .collect(), + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len) + .map(|i| { + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + let mut m = f32::NEG_INFINITY; + for ky in 0..*kh { + for kx in 0..*kw { + m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]); + } + } + m + }) + .collect(), + Op::Resize { h, w, sh, sw, .. } => (0..len) + .map(|i| { + let (ow, oh) = (w * sw, h * sh); + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + x[(c * h + oy / sh) * w + ox / sw] + }) + .collect(), + Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::<f32>() / *plane as f32).collect(), + Op::Gemv { k, n, trans_b, alpha } => { + let (wt, bias) = (arg(1), arg(2)); + (0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::<f32>() + bias[o]).collect() + } + }; + assert_eq!(out.len(), len, "{:?}", step.op); + values[step.out] = out; + for f in &step.free { + values[*f] = Vec::new(); + } + } + p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect() + } + + impl Accelerator for ProgramFake { + fn device(&self) -> String { + "program test".into() + } + fn conv(&self, _: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> { + Ok(None) + } + fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut out: Vec<Vec<Vec<f32>>> = inputs.iter().map(|x| run_steps(p, x)).collect(); + if self.poison { + out[0][0][0] = f32::NAN; + } + Ok(Some(out)) + } + } + + fn every_operator_input(seed: usize) -> Tensor { + input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05) + } + + #[test] + fn a_program_of_every_operator_gives_what_the_interpreter_gives() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(0); + let cpu = net.run(x.clone()).unwrap(); + let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false }); + net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap(); + let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed); + assert!(calls > 0, "the whole network ran as one program"); + // several inputs go to the device together, and each gets its own outputs + let inputs: Vec<Tensor> = (0..3).map(every_operator_input).collect(); + let together = net.run_batch(inputs.clone()).unwrap(); + assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch"); + net.detach(); + for (x, got) in inputs.into_iter().zip(&together) { + let want = net.run(x).unwrap(); + agree(&want, got, 1e-5).unwrap(); + } + assert_eq!(cpu.len(), 2); + } + + #[test] + fn a_device_that_gives_values_that_are_not_finite_is_dropped() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(1); + let cpu = net.run(x.clone()).unwrap(); + assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err()); + assert!(net.accelerator().is_none()); + assert_eq!(net.run(x).unwrap(), cpu); + } + #[test] fn unsupported_and_inconsistent_models_are_errors() { let x = input(1, 3, 3, |i| i as f32); diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 6d7c1dbb9..71ae95874 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -60,8 +60,8 @@ fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, p Ok(()) } /// The checked geometry of a convolution node, for an accelerator. -pub(super) fn conv_job<'a>(layer: usize, n: &Node, x: &'a Tensor, g: &'a Graph) -> Result<ConvJob<'a>> { - let (cin, h, w) = chw(x)?; +pub(super) fn conv_job<'a>(layer: usize, n: &Node, shape: &[usize], input: &'a [f32], g: &'a Graph) -> Result<ConvJob<'a>> { + let (cin, h, w) = program::chw_of(shape)?; let wt = shape::weight(n, g, 1)?; let weights = shape::floats(n, g, 1)?; let [cout, cg, kh, kw] = wt.dims.as_slice() else { @@ -84,7 +84,7 @@ pub(super) fn conv_job<'a>(layer: usize, n: &Node, x: &'a Tensor, g: &'a Graph) return shape_err("the convolution bias does not match"); } let (oh, ow) = ((ph - kh) / sh + 1, (pw - kw) / sw + 1); - Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input: &x.data }) + Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input }) } pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result<Tensor> { let (cin, h, w) = chw(x)?; @@ -208,7 +208,7 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re Ok(out) } /// Flat offset under ONNX's trailing-dimension broadcasting; shapes are checked before execution. -fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize { +pub(super) fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize { let mut offset = 0; let mut stride = 1; for (axis, &size) in out.iter().enumerate().rev() { diff --git a/crates/faces/src/net/program.rs b/crates/faces/src/net/program.rs new file mode 100644 index 000000000..e00e51da1 --- /dev/null +++ b/crates/faces/src/net/program.rs @@ -0,0 +1,315 @@ +//! A network lowered to a flat list of steps over numbered values, for an [`Accelerator`] that keeps every activation +//! on its device between layers (one upload, one read-back per run instead of one per convolution). +//! +//! Shapes are resolved for one input shape; every step names the values it reads and the one it writes, and which +//! values nothing reads after it. Shape-only operators (`Reshape`, `Flatten`, `Identity`, `Dropout`) make no step: their +//! output is the same value under another name. Anything this cannot express (a broadcast past four dimensions, a +//! transposed or batched `Gemm`, a slope that is not per channel) makes the whole lowering `None`, and the net runs +//! the CPU path instead. + +use std::borrow::Cow; + +use super::*; + +/// Value 0 is the network's input. +pub const INPUT: usize = 0; + +/// An operand: a value computed on the device, or a constant (uploaded once and kept, by `id`). +#[derive(Clone, Debug)] +pub enum Arg<'a> { + Value(usize), + /// `id` is the same for every run of the same net, so the device can keep the data. + Const { + id: usize, + data: Cow<'a, [f32]>, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unary { + Relu, + Sigmoid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Binary { + Add, + Sub, + Mul, +} + +/// What a step computes. Element counts come from the program's `lens` of the step's output. +#[derive(Clone, Debug)] +pub enum Op<'a> { + /// A convolution; its `input` is empty (it reads the step's first argument). + Conv(ConvJob<'a>), + /// `out[i] = f(x[i])`. Args: `[x]`. + Unary(Unary), + /// `out[i] = x[i] * scale[c] + offset[c]`, `c = (i / plane) % channels`. Args: `[x, scale, offset]`. + ChannelAffine { channels: usize, plane: usize }, + /// `out[i] = x[i] < 0 ? x[i] * slope[c] : x[i]`. Args: `[x, slope]` (`channels` values). + ChannelPrelu { channels: usize, plane: usize }, + /// Broadcasting over four dimensions: `dims` of the output, element strides of each operand (0 where it repeats). + /// Args: `[a, b]`. + Binary { op: Binary, dims: [usize; 4], a: [usize; 4], b: [usize; 4] }, + /// `out[n] = alpha · Σk x[k] · B(k, n) + bias[n]`, `B(k, n) = weights[n·k_len + k]` when `trans_b`, else + /// `weights[k·n_len + n]`. Args: `[x, weights, bias]` (bias already times beta; zeros when the node has none). + Gemv { k: usize, n: usize, trans_b: bool, alpha: f32 }, + /// The mean of each `plane`-sized run. Args: `[x]`. + GlobalAverage { channels: usize, plane: usize }, + /// Unpadded max pooling. Args: `[x]`. + MaxPool { c: usize, h: usize, w: usize, kh: usize, kw: usize, sh: usize, sw: usize, oh: usize, ow: usize }, + /// Nearest-neighbour upscale by whole numbers. Args: `[x]`. + Resize { c: usize, h: usize, w: usize, sh: usize, sw: usize }, + /// `out` in its own order (`dims`), read from the input at `strides` (the input's strides, permuted). Args: `[x]`. + Transpose { dims: [usize; 4], strides: [usize; 4] }, +} + +#[derive(Clone, Debug)] +pub struct Step<'a> { + pub op: Op<'a>, + pub args: Vec<Arg<'a>>, + pub out: usize, + /// Values nothing reads after this step (never an output): their memory can be reused. + pub free: Vec<usize>, +} + +/// A whole network for one input shape. +#[derive(Clone, Debug)] +pub struct Program<'a> { + /// Elements of each value; `lens[INPUT]` is the input's. + pub lens: Vec<usize>, + pub steps: Vec<Step<'a>>, + /// Each graph output: its name, the value that holds it and its shape. + pub outputs: Vec<(String, usize, Vec<usize>)>, +} + +/// Leading ones up to rank four, or `None` past four. +fn rank4(s: &[usize]) -> Option<[usize; 4]> { + let pad = 4usize.checked_sub(s.len())?; + let mut out = [1; 4]; + for (i, d) in s.iter().enumerate() { + *out.get_mut(pad + i)? = *d; + } + Some(out) +} + +/// Element strides of `input` read as `out` under trailing-dimension broadcasting (0 along repeated axes). +fn broadcast_strides(out: [usize; 4], input: &[usize]) -> Option<[usize; 4]> { + let dims = rank4(input)?; + let mut strides = [0; 4]; + let mut step = 1; + for axis in (0..4).rev() { + let (d, o) = (*dims.get(axis)?, *out.get(axis)?); + if d == o && d != 1 { + *strides.get_mut(axis)? = step; + } else if d != 1 { + return None; + } + step *= d; + } + Some(strides) +} + +impl Net { + /// This network lowered for `input` (its shape), or `None` when a step has no device form. + pub fn program(&self, input: &[usize]) -> Result<Option<Program<'_>>> { + let g = &self.graph; + let (shapes, _) = shape::infer_all(g, input)?; + let Some((input_name, _)) = g.inputs.first() else { return shape_err("the model has no input") }; + let mut lens = vec![shape::elements(input)?]; + let mut names: HashMap<&str, (usize, Vec<usize>)> = HashMap::new(); + names.insert(input_name.as_str(), (INPUT, input.to_vec())); + let mut steps: Vec<Step> = Vec::new(); + for (i, node) in g.nodes.iter().enumerate() { + let Some(out_shape) = shapes.get(i) else { return shape_err("a node has no shape") }; + let Some(out_name) = node.outputs.first() else { return shape_err("a node has no output") }; + let value = |idx: usize| -> Option<&(usize, Vec<usize>)> { node.inputs.get(idx).and_then(|n| names.get(n.as_str())) }; + let constant = |idx: usize| -> Option<(Cow<'_, [f32]>, &[usize])> { + let w = node.inputs.get(idx).and_then(|n| g.weights.get(n))?; + match &w.data { + Data::F32(v) => Some((Cow::Borrowed(v.as_slice()), w.dims.as_slice())), + _ => None, + } + }; + let id = |k: usize| i * 4 + k; + let Some((x, x_shape)) = value(0).cloned() else { return Ok(None) }; + let (op, args) = match node.op.as_str() { + "Reshape" | "Flatten" | "Identity" | "Dropout" => { + names.insert(out_name.as_str(), (x, out_shape.clone())); + continue; + } + "Conv" => (Op::Conv(kernels::conv_job(i, node, &x_shape, &[], g)?), vec![Arg::Value(x)]), + "Relu" => (Op::Unary(Unary::Relu), vec![Arg::Value(x)]), + "Sigmoid" => (Op::Unary(Unary::Sigmoid), vec![Arg::Value(x)]), + "BatchNormalization" => { + let channels = *x_shape.get(1).ok_or_else(|| NetError::Shape("BatchNormalization rank".into()))?; + let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?; + let (s, b, m, v) = + (shape::floats(node, g, 1)?, shape::floats(node, g, 2)?, shape::floats(node, g, 3)?, shape::floats(node, g, 4)?); + let eps = shape::float(node, "epsilon", 1e-5); + let mut scale = Vec::with_capacity(channels); + let mut offset = Vec::with_capacity(channels); + for c in 0..channels { + let (Some(s), Some(b), Some(m), Some(v)) = (s.get(c), b.get(c), m.get(c), v.get(c)) else { + return shape_err("short BatchNormalization parameters"); + }; + let k = s / (v + eps).sqrt(); + scale.push(k); + offset.push(b - m * k); + } + ( + Op::ChannelAffine { channels, plane }, + vec![Arg::Value(x), Arg::Const { id: id(1), data: scale.into() }, Arg::Const { id: id(2), data: offset.into() }], + ) + } + "PRelu" => { + let Some((slope, dims)) = constant(1) else { return Ok(None) }; + let channels = x_shape.get(1).copied().unwrap_or(1); + let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?; + let mut expanded = vec![1; x_shape.len().saturating_sub(dims.len())]; + expanded.extend_from_slice(dims); + let per_channel = expanded.iter().enumerate().all(|(i, &d)| d == 1 || i == 1) && slope.len() == channels; + let slope: Cow<[f32]> = if per_channel { + slope + } else if let [one] = slope.as_ref() { + vec![*one; channels].into() + } else { + return Ok(None); + }; + (Op::ChannelPrelu { channels, plane }, vec![Arg::Value(x), Arg::Const { id: id(1), data: slope }]) + } + "Add" | "Sub" | "Mul" => { + let op = match node.op.as_str() { + "Add" => Binary::Add, + "Sub" => Binary::Sub, + _ => Binary::Mul, + }; + let (b, b_shape) = match (value(1), constant(1)) { + (Some((v, s)), _) => (Arg::Value(*v), s.clone()), + (None, Some((data, dims))) => (Arg::Const { id: id(1), data }, dims.to_vec()), + _ => return Ok(None), + }; + let Some(dims) = rank4(out_shape) else { return Ok(None) }; + let (Some(sa), Some(sb)) = (broadcast_strides(dims, &x_shape), broadcast_strides(dims, &b_shape)) else { return Ok(None) }; + (Op::Binary { op, dims, a: sa, b: sb }, vec![Arg::Value(x), b]) + } + "Gemm" => { + let Some((weights, wdims)) = constant(1) else { return Ok(None) }; + let ([1, k], [n_out]) = (x_shape.as_slice(), out_shape.get(1..).unwrap_or(&[])) else { return Ok(None) }; + if node.int("transA").unwrap_or(0) != 0 || out_shape.first() != Some(&1) { + return Ok(None); + } + let trans_b = node.int("transB").unwrap_or(0) != 0; + if wdims.len() != 2 || weights.len() != k * n_out { + return shape_err("Gemm weights do not match"); + } + let mut bias = vec![0.0f32; *n_out]; + if node.inputs.get(2).is_some_and(|s| !s.is_empty()) { + let wt = shape::weight(node, g, 2)?; + let b = shape::floats(node, g, 2)?; + let beta = shape::float(node, "beta", 1.0); + for (j, to) in bias.iter_mut().enumerate() { + *to = beta + * b.get(kernels::broadcast_offset(j, out_shape, &wt.dims)) + .copied() + .ok_or_else(|| NetError::Shape("short matrix bias".into()))?; + } + } + ( + Op::Gemv { k: *k, n: *n_out, trans_b, alpha: shape::float(node, "alpha", 1.0) }, + vec![Arg::Value(x), Arg::Const { id: id(1), data: weights }, Arg::Const { id: id(2), data: bias.into() }], + ) + } + "GlobalAveragePool" => { + let (c, h, w) = chw_of(&x_shape)?; + (Op::GlobalAverage { channels: c, plane: h * w }, vec![Arg::Value(x)]) + } + "MaxPool" => { + let (c, h, w) = chw_of(&x_shape)?; + let (kh, kw) = pair(node, "kernel_shape", 1)?; + let (sh, sw) = pair(node, "strides", 1)?; + if pads(node)? != (0, 0, 0, 0) { + return Ok(None); + } + let (_, oh, ow) = chw_of(out_shape)?; + (Op::MaxPool { c, h, w, kh, kw, sh, sw, oh, ow }, vec![Arg::Value(x)]) + } + "Resize" => { + let (c, h, w) = chw_of(&x_shape)?; + let (_, oh, ow) = chw_of(out_shape)?; + if oh % h != 0 || ow % w != 0 { + return shape_err("a Resize that is not by whole numbers"); + } + (Op::Resize { c, h, w, sh: oh / h, sw: ow / w }, vec![Arg::Value(x)]) + } + "Transpose" => { + let rank = x_shape.len(); + let default_perm: Vec<i64> = (0..rank as i64).rev().collect(); + let perm = node.ints("perm").unwrap_or(&default_perm); + if rank > 4 || perm.len() != rank { + return Ok(None); + } + let mut in_strides = vec![1usize; rank]; + for a in (0..rank.saturating_sub(1)).rev() { + let next = in_strides.get(a + 1).copied().unwrap_or(1) * x_shape.get(a + 1).copied().unwrap_or(1); + if let Some(s) = in_strides.get_mut(a) { + *s = next; + } + } + let Some(dims) = rank4(out_shape) else { return Ok(None) }; + let mut strides = [0usize; 4]; + for (j, p) in perm.iter().enumerate() { + let p = usize::try_from(*p).map_err(|_| NetError::Shape("a negative permutation".into()))?; + let s = in_strides.get(p).copied().ok_or_else(|| NetError::Shape("a bad permutation".into()))?; + *strides.get_mut(4 - rank + j).ok_or_else(|| NetError::Shape("a bad permutation".into()))? = s; + } + (Op::Transpose { dims, strides }, vec![Arg::Value(x)]) + } + _ => return Ok(None), + }; + let out = lens.len(); + lens.push(shape::elements(out_shape)?); + names.insert(out_name.as_str(), (out, out_shape.clone())); + steps.push(Step { op, args, out, free: Vec::new() }); + } + let outputs = g + .outputs + .iter() + .map(|name| { + let (v, s) = names.get(name.as_str()).ok_or_else(|| NetError::Shape(format!("output `{name}` was never computed")))?; + Ok((name.clone(), *v, s.clone())) + }) + .collect::<Result<Vec<_>>>()?; + // the last step that reads each value; a value nothing reads is free right after it is made + let mut last: Vec<Option<usize>> = vec![None; lens.len()]; + for (at, step) in steps.iter().enumerate() { + if let Some(l) = last.get_mut(step.out) { + *l = Some(at); + } + for a in &step.args { + if let Arg::Value(v) = a + && let Some(l) = last.get_mut(*v) + { + *l = Some(at); + } + } + } + for (v, at) in last.iter().enumerate() { + if outputs.iter().any(|(_, o, _)| *o == v) { + continue; + } + if let Some(step) = at.and_then(|at| steps.get_mut(at)) { + step.free.push(v); + } + } + Ok(Some(Program { lens, steps, outputs })) + } +} + +pub(super) fn chw_of(s: &[usize]) -> Result<(usize, usize, usize)> { + match s { + [1, c, h, w] => Ok((*c, *h, *w)), + s => unsupported(format!("a tensor of shape {s:?} (only [1, channels, height, width])")), + } +} diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index d76271bc7..5b9fc68b1 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -261,7 +261,12 @@ fn image(s: &[usize]) -> Result<(usize, usize, usize)> { } } pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> { + Ok(infer_all(g, input)?.1) +} +/// Each node's output shape, and the graph's outputs' shapes. +pub(super) fn infer_all(g: &Graph, input: &[usize]) -> Result<(Vec<Vec<usize>>, Vec<Vec<usize>>)> { elements(input)?; + let mut per_node = Vec::with_capacity(g.nodes.len()); let mut shapes: HashMap<&str, Vec<usize>> = g.weights.iter().map(|(n, w)| (n.as_str(), w.dims.clone())).collect(); shapes.insert(g.inputs.first().ok_or_else(|| NetError::Shape("no input".into()))?.0.as_str(), input.to_vec()); let mut live = elements(input)?; @@ -484,6 +489,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> { .checked_add(out_count) .filter(|&n| n <= 128 * 1024 * 1024) .ok_or_else(|| NetError::Shape("live tensors exceed the 512 MiB working limit".into()))?; + per_node.push(out.clone()); shapes.insert(n.outputs.first().ok_or_else(|| NetError::Shape("no output".into()))?, out); let mut removed = std::collections::HashSet::new(); for name in &n.inputs { @@ -492,5 +498,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> { } } } - g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect() + let outputs = + g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect::<Result<_>>()?; + Ok((per_node, outputs)) } diff --git a/crates/faces/src/runtime.rs b/crates/faces/src/runtime.rs index 7a8ab948a..92f9e8134 100644 --- a/crates/faces/src/runtime.rs +++ b/crates/faces/src/runtime.rs @@ -40,6 +40,9 @@ pub struct SelfTest { type Plan = Net; +/// Most faces [`Embedder::embed_batch`] sends to a GPU in one go. +pub const BATCH: usize = 16; + /// A loaded recognition model. #[derive(Clone)] pub struct Embedder { @@ -135,6 +138,66 @@ impl Embedder { /// The face as a unit-length vector. `rgb` is an aligned face at exactly [`Self::input_size`], 8-bit RGB. pub fn embed(&self, rgb: &[u8]) -> Result<Vec<f32>, RuntimeError> { + let input = self.input(rgb)?; + let out = guarded("running it", || { + let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?; + if result.len() != 1 { + return Err(RuntimeError::Run("the model must give one embedding".into())); + } + Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data) + })?; + self.unit(out) + } + + /// Several faces at once, each as [`Self::embed`] gives it. On a GPU they run together, up to [`BATCH`] per + /// dispatch (one upload and one read-back for all of them); on the CPU one after another. + pub fn embed_batch(&self, faces: &[&[u8]]) -> Vec<Result<Vec<f32>, RuntimeError>> { + if self.plan.accelerator().is_none() || faces.len() < 2 { + return faces.iter().map(|f| self.embed(f)).collect(); + } + let mut results: Vec<Option<Result<Vec<f32>, RuntimeError>>> = faces.iter().map(|_| None).collect(); + let mut ready: Vec<(usize, Tensor)> = Vec::new(); + for (i, face) in faces.iter().enumerate() { + match self.input(face) { + Ok(t) => ready.push((i, t)), + Err(e) => { + if let Some(r) = results.get_mut(i) { + *r = Some(Err(e)); + } + } + } + } + while !ready.is_empty() { + let chunk: Vec<(usize, Tensor)> = ready.drain(..ready.len().min(BATCH)).collect(); + let (at, inputs): (Vec<usize>, Vec<Tensor>) = chunk.into_iter().unzip(); + let ran = guarded("running it", || self.plan.run_batch(inputs).map_err(|e| RuntimeError::Run(e.to_string()))); + match ran { + Ok(outs) if outs.len() == at.len() => { + for (i, mut out) in at.into_iter().zip(outs) { + let r = match (out.len(), out.pop()) { + (1, Some((_, t))) => self.unit(t.data), + _ => Err(RuntimeError::Run("the model must give one embedding".into())), + }; + if let Some(slot) = results.get_mut(i) { + *slot = Some(r); + } + } + } + // one bad face spoils a batch: run them one by one so the others still get theirs + _ => { + for i in at { + if let (Some(slot), Some(face)) = (results.get_mut(i), faces.get(i)) { + *slot = Some(self.embed(face)); + } + } + } + } + } + results.into_iter().map(|r| r.unwrap_or_else(|| Err(RuntimeError::Run("the face was not embedded".into())))).collect() + } + + /// The model's input for an aligned face. + fn input(&self, rgb: &[u8]) -> Result<Tensor, RuntimeError> { if rgb.len() != self.width * self.height * 3 { return Err(RuntimeError::Input("the face picture is not the size the model wants".into())); } @@ -149,14 +212,11 @@ impl Embedder { } } } - let out = guarded("running it", || { - let input = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?; - let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?; - if result.len() != 1 { - return Err(RuntimeError::Run("the model must give one embedding".into())); - } - Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data) - })?; + Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string())) + } + + /// The model's output, checked and scaled to unit length. + fn unit(&self, out: Vec<f32>) -> Result<Vec<f32>, RuntimeError> { if out.len() != self.dim { return Err(RuntimeError::Run(format!("the model gave {} numbers per face, not the {} its description says", out.len(), self.dim))); } diff --git a/crates/faces/src/synthetic.rs b/crates/faces/src/synthetic.rs index 61010a243..6bb792a1f 100644 --- a/crates/faces/src/synthetic.rs +++ b/crates/faces/src/synthetic.rs @@ -248,3 +248,63 @@ pub fn tiny_detector_manifest() -> crate::manifest::ModelManifest { m.size_bytes = Some(bytes.len() as u64); m } + +/// A small network that uses every operator the interpreter runs (both kinds of convolution, batch norm, PRelu, +/// sigmoid, broadcasting add/sub/mul with values and constants, max pooling, nearest upscaling, transposes, reshape, +/// global pooling, flatten and a fully connected layer), with two outputs, for checking an accelerator's program +/// against the CPU. Its input is `[1, 4, 8, 8]`. +pub fn every_operator_graph() -> crate::graph::Graph { + use crate::graph::{Attr, Data, Graph, Node, Weight}; + let node = |op: &str, inputs: &[&str], output: &str, attrs: &[(&str, Attr)]| Node { + op: op.into(), + inputs: inputs.iter().map(|s| s.to_string()).collect(), + outputs: vec![output.into()], + attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), + }; + let wave = |n: usize, k: usize| -> Vec<f32> { (0..n).map(|i| (((i * k) % 23) as f32 - 11.0) * 0.04).collect() }; + let w = |dims: &[usize], v: Vec<f32>| Weight { dims: dims.to_vec(), data: Data::F32(v) }; + let weights = [ + ("w1", w(&[8, 4, 3, 3], wave(288, 7))), + ("b1", w(&[8], wave(8, 5))), + ("bn_s", w(&[8], (0..8).map(|i| 0.5 + i as f32 * 0.1).collect())), + ("bn_b", w(&[8], wave(8, 3))), + ("bn_m", w(&[8], wave(8, 11))), + ("bn_v", w(&[8], (0..8).map(|i| 0.2 + i as f32 * 0.05).collect())), + ("slope", w(&[8, 1, 1], (0..8).map(|i| 0.05 * i as f32).collect())), + ("w2", w(&[8, 1, 3, 3], wave(72, 13))), + ("scales", w(&[4], vec![1.0, 1.0, 2.0, 2.0])), + ("half", w(&[1], vec![0.5])), + ("per_channel", w(&[1, 8, 1, 1], wave(8, 17))), + ("wg", w(&[5, 8], wave(40, 19))), + ("bg", w(&[5], wave(5, 2))), + ("flat", Weight { dims: vec![2], data: Data::I64(vec![1, -1]) }), + ]; + let pads = |p: i64| ("pads", Attr::Ints(vec![p; 4])); + let nodes = vec![ + node("Conv", &["x", "w1", "b1"], "c1", &[pads(1)]), + node("BatchNormalization", &["c1", "bn_s", "bn_b", "bn_m", "bn_v"], "bn", &[]), + node("PRelu", &["bn", "slope"], "pr", &[]), + node("Conv", &["pr", "w2"], "c2", &[("group", Attr::Int(8)), ("strides", Attr::Ints(vec![2, 2])), pads(1)]), + node("Sigmoid", &["c2"], "sg", &[]), + node("Mul", &["c2", "sg"], "swish", &[]), + node("Resize", &["swish", "", "scales"], "up", &[]), + node("Add", &["up", "pr"], "sum", &[]), + node("Sub", &["sum", "half"], "less", &[]), + node("MaxPool", &["less"], "pool", &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))]), + node("Relu", &["pool"], "relu", &[]), + node("Transpose", &["relu"], "nhwc", &[("perm", Attr::Ints(vec![0, 2, 3, 1]))]), + node("Transpose", &["nhwc"], "nchw", &[("perm", Attr::Ints(vec![0, 3, 1, 2]))]), + node("Mul", &["nchw", "per_channel"], "scaled", &[]), + node("Identity", &["scaled"], "same", &[]), + node("GlobalAveragePool", &["same"], "avg", &[]), + node("Flatten", &["avg"], "row", &[]), + node("Reshape", &["row", "flat"], "row2", &[]), + node("Gemm", &["row2", "wg", "bg"], "y", &[("transB", Attr::Int(1)), ("alpha", Attr::Float(0.5))]), + ]; + Graph { + nodes, + weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + inputs: vec![("x".into(), vec![1, 4, 8, 8])], + outputs: vec!["y".into(), "nhwc".into()], + } +} diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs index ae982d30b..bee9929ef 100644 --- a/crates/gpu/src/faces.rs +++ b/crates/gpu/src/faces.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use std::time::Duration; use bytemuck::{Pod, Zeroable}; +use lightcraft_faces::net::program::{Arg, Binary, INPUT, Op, Program, Unary}; use lightcraft_faces::net::{Accelerator, ConvJob}; use wgpu::util::DeviceExt; @@ -35,6 +36,22 @@ struct Dev { tiled: wgpu::ComputePipeline, direct: wgpu::ComputePipeline, reduce: wgpu::ComputePipeline, + ops: Ops, + /// Bound where a kernel has fewer operands than bindings. + dummy: wgpu::Buffer, +} + +/// The kernels of `wgsl/faces_ops.wgsl`. +struct Ops { + unary: wgpu::ComputePipeline, + affine: wgpu::ComputePipeline, + prelu: wgpu::ComputePipeline, + binary: wgpu::ComputePipeline, + transpose: wgpu::ComputePipeline, + maxpool: wgpu::ComputePipeline, + resize: wgpu::ComputePipeline, + gavg: wgpu::ComputePipeline, + gemv: wgpu::ComputePipeline, } static DEV: OnceLock<Result<Dev, String>> = OnceLock::new(); @@ -146,20 +163,43 @@ fn make_device(backends: wgpu::Backends) -> Result<Dev, String> { source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_conv.wgsl").into()), }); let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); - let pipe = |entry: &str| { + let ops_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("faces ops"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_ops.wgsl").into()), + }); + let pipe_in = |module: &wgpu::ShaderModule, entry: &str| { device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(entry), layout: Some(&pl), - module: &module, + module, entry_point: Some(entry), // both kernels write all of their workgroup memory before reading it compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() }, cache: None, }) }; + let pipe = |entry: &str| pipe_in(&module, entry); + let op = |entry: &str| pipe_in(&ops_module, entry); let (tiled, direct, reduce) = (pipe("tiled"), pipe("direct"), pipe("reduce")); + let ops = Ops { + unary: op("unary"), + affine: op("affine"), + prelu: op("prelu"), + binary: op("binary"), + transpose: op("transpose"), + maxpool: op("maxpool"), + resize: op("resize"), + gavg: op("gavg"), + gemv: op("gemv"), + }; + let dummy = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces unused"), + size: 16, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); log::info!("gpu (faces): {} ({:?})", info.name, info.backend); - Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce }) + Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce, ops, dummy }) } /// The adapter face scans would use ("AMD Radeon RX 7900 XTX (Vulkan)"), or why they cannot use one. Creates the @@ -203,53 +243,151 @@ struct Resident { pub struct FaceGpu { dev: &'static Dev, layers: Mutex<HashMap<usize, Arc<Resident>>>, + consts: Mutex<HashMap<usize, wgpu::Buffer>>, resident: AtomicU64, } /// A GPU accelerator for one face model, or why there is none (GPU off, missing or broken). pub fn accelerator() -> Result<Arc<FaceGpu>, String> { - Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) + Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), consts: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) } fn u32_of(v: usize) -> Result<u32, String> { u32::try_from(v).ok().filter(|v| *v < (1 << 31)).ok_or_else(|| "a layer too large for the GPU kernel".to_string()) } +/// How much of the GPU face work may take: at most `concurrent` runs at once, and busy for at most `share` of the time +/// (after a run of `t`, the next one waits `t · (1 − share) / share`). Set by the background scan's pace. +struct Pace { + concurrent: usize, + share: f32, + running: usize, +} + +static PACE: Mutex<Pace> = Mutex::new(Pace { concurrent: usize::MAX, share: 1.0, running: 0 }); +static PACE_FREED: std::sync::Condvar = std::sync::Condvar::new(); + +/// Limit the GPU work face models do: `concurrent` runs at once (at least one) and `share` of the time (0.05 to 1). +/// The background face scan sets it from its pace; anything else that runs a face model shares the same limit. +pub fn set_pace(concurrent: usize, share: f32) { + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + p.concurrent = concurrent.max(1); + p.share = if share.is_finite() { share.clamp(0.05, 1.0) } else { 1.0 }; + PACE_FREED.notify_all(); +} + +/// A turn on the GPU under the pace; waits for one. Dropping it gives it back. +struct Turn { + share: f32, + started: std::time::Instant, +} + +impl Turn { + fn take() -> Turn { + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + while p.running >= p.concurrent { + p = PACE_FREED.wait_timeout(p, Duration::from_millis(200)).map(|(g, _)| g).unwrap_or_else(|e| e.into_inner().0); + } + p.running += 1; + Turn { share: p.share, started: std::time::Instant::now() } + } +} + +impl Drop for Turn { + fn drop(&mut self) { + // rest while still holding the turn, so the GPU stays idle for the rest of the period + if self.share < 1.0 { + let rest = self.started.elapsed().mul_f32((1.0 - self.share) / self.share).min(Duration::from_secs(2)); + std::thread::sleep(rest); + } + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + p.running = p.running.saturating_sub(1); + PACE_FREED.notify_one(); + } +} + +/// One dispatch to record: a pipeline, its parameters, its four buffers (three read, the last written) and its +/// workgroup counts. +struct Dispatch { + pipeline: &'static wgpu::ComputePipeline, + params: Vec<u8>, + buffers: [wgpu::Buffer; 4], + groups: [u32; 3], +} + +/// Workgroups for `count` items of 256 (or `count` workgroups when `per_group`): x, y and the row length the kernel +/// reads back as `q(1)`. +fn flat(count: usize, limit: usize) -> Option<[u32; 3]> { + let blocks = count.max(1); + let row = blocks.clamp(1, limit.min(65_535)); + let rows = blocks.div_ceil(row); + (rows <= limit).then_some([u32::try_from(row).ok()?, u32::try_from(rows).ok()?, u32::try_from(row).ok()?]) +} + +fn params24(values: &[u32]) -> Vec<u8> { + let mut all = [0u32; 24]; + for (to, v) in all.iter_mut().zip(values) { + *to = *v; + } + bytemuck::cast_slice(&all).to_vec() +} + impl FaceGpu { /// Bytes of weights this model keeps on the device. pub fn resident_bytes(&self) -> u64 { self.resident.load(Ordering::Relaxed) } - fn resident(&self, job: &ConvJob) -> Result<Option<Arc<Resident>>, String> { - let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(r) = layers.get(&job.layer) { - return Ok(Some(r.clone())); - } - let bytes = (job.weights.len() as u64 + job.cout as u64) * 4; - if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { - return Ok(None); - } - let d = self.dev; - let usage = wgpu::BufferUsages::STORAGE; - let weights = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("faces weights"), - contents: bytemuck::cast_slice(job.weights), - usage, - }); - let zero = [0f32; 1]; - let bias = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("faces bias"), - contents: bytemuck::cast_slice(job.bias.unwrap_or(&zero)), - usage, - }); - let r = Arc::new(Resident { weights, bias }); - self.resident.fetch_add(bytes, Ordering::Relaxed); - layers.insert(job.layer, r.clone()); - Ok(Some(r)) + fn upload(&self, label: &str, data: &[f32]) -> wgpu::Buffer { + let zero = [0f32; 4]; + self.dev.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytemuck::cast_slice(if data.is_empty() { &zero } else { data }), + usage: wgpu::BufferUsages::STORAGE, + }) } - fn run(&self, job: &ConvJob) -> Result<Option<Vec<f32>>, String> { + /// Whether `bytes` more may stay on the device; counts them if so. + fn keep(&self, bytes: u64) -> bool { + if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { + return false; + } + self.resident.fetch_add(bytes, Ordering::Relaxed); + true + } + + /// A convolution's weights and bias: kept on the device while the budget lasts, uploaded for this run past it. + fn resident(&self, job: &ConvJob) -> Arc<Resident> { + let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(r) = layers.get(&job.layer) { + return r.clone(); + } + let r = Arc::new(Resident { weights: self.upload("faces weights", job.weights), bias: self.upload("faces bias", job.bias.unwrap_or(&[])) }); + if self.keep((job.weights.len() as u64 + job.cout as u64) * 4) { + layers.insert(job.layer, r.clone()); + } + r + } + + /// A program's constant, kept on the device like the weights. + fn constant(&self, id: usize, data: &[f32]) -> wgpu::Buffer { + let mut consts = self.consts.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(b) = consts.get(&id) { + return b.clone(); + } + let b = self.upload("faces constant", data); + if self.keep(data.len() as u64 * 4) { + consts.insert(id, b.clone()); + } + b + } + + fn largest_binding(&self) -> u64 { + self.dev.limits.max_storage_buffer_binding_size.min(self.dev.limits.max_buffer_size) + } + + /// The dispatches of one convolution reading `input` and writing `output`, or `None` when it exceeds the device. + fn conv_dispatches(&self, job: &ConvJob, input: &wgpu::Buffer, output: &wgpu::Buffer) -> Result<Option<Vec<Dispatch>>, String> { let d = self.dev; let groups = job.groups.max(1); let (m, cg) = (job.cout / groups, job.cin / groups); @@ -264,22 +402,15 @@ impl FaceGpu { let kchunk = k.div_ceil(splits).div_ceil(16) * 16; let splits = k.div_ceil(kchunk.max(1)).max(1); let partial = total.checked_mul(splits).ok_or("a layer too large")?; - let largest = [job.input.len(), job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; - if largest > d.limits.max_storage_buffer_binding_size.min(d.limits.max_buffer_size) { - return Ok(None); - } - let flat = |count: usize| { - let blocks = count.div_ceil(256); - let row = blocks.clamp(1, 65_535); - (row, blocks.div_ceil(row), 1, row) - }; - let (wx, wy, wz, row) = if direct { flat(total) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits, flat(total).3) }; - let (rx, ry, _, _) = flat(total); + let cin_len = job.cin.checked_mul(job.h).and_then(|v| v.checked_mul(job.w)).ok_or("a layer too large")?; + let largest = [cin_len, job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; let limit = d.limits.max_compute_workgroups_per_dimension as usize; - if [wx, wy, wz, rx, ry].into_iter().any(|v| v > limit) { + let Some(sum) = flat(total.div_ceil(256), limit) else { return Ok(None) }; + let (wx, wy, wz) = if direct { (sum[0] as usize, sum[1] as usize, 1) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits) }; + if largest > self.largest_binding() || [wx, wy, wz].into_iter().any(|v| v > limit) { return Ok(None); } - let Some(res) = self.resident(job)? else { return Ok(None) }; + let res = self.resident(job); let params = Params { m: u32_of(m)?, n: u32_of(n)?, @@ -296,82 +427,93 @@ impl FaceGpu { pl: u32_of(job.pl)?, bias: u32::from(job.bias.is_some()), total: u32_of(total)?, - row: u32_of(row)?, + row: sum[2], splits: u32_of(splits)?, kchunk: u32_of(kchunk)?, _a: 0, _b: 0, }; - let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; - let scopes = [d.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), d.device.push_error_scope(wgpu::ErrorFilter::Validation)]; + let params = bytemuck::bytes_of(¶ms).to_vec(); + let main = |out: &wgpu::Buffer| Dispatch { + pipeline: if direct { &d.direct } else { &d.tiled }, + params: params.clone(), + buffers: [input.clone(), res.weights.clone(), res.bias.clone(), out.clone()], + groups: [wx as u32, wy as u32, wz as u32], + }; + if splits == 1 { + return Ok(Some(vec![main(output)])); + } + let partials = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces partial sums"), + size: partial as u64 * 4, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + Ok(Some(vec![ + main(&partials), + Dispatch { + pipeline: &d.reduce, + params, + buffers: [partials, res.weights.clone(), res.bias.clone(), output.clone()], + groups: [sum[0], sum[1], 1], + }, + ])) + } + + /// Record `list` into `enc` as one compute pass, with all their parameters in one uniform buffer. + fn record(&self, enc: &mut wgpu::CommandEncoder, list: &[Dispatch]) -> Result<(), String> { + let d = self.dev; + if list.is_empty() { + return Ok(()); + } + let align = (d.limits.min_uniform_buffer_offset_alignment as usize).max(256); + let mut blob = vec![0u8; list.len() * align]; + for (i, x) in list.iter().enumerate() { + blob.get_mut(i * align..i * align + x.params.len()).ok_or("parameters too large")?.copy_from_slice(&x.params); + } let uniform = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("faces params"), - contents: bytemuck::bytes_of(¶ms), + contents: &blob, usage: wgpu::BufferUsages::UNIFORM, }); - let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("faces input"), - contents: bytemuck::cast_slice(job.input), - usage: wgpu::BufferUsages::STORAGE, - }); - let bytes = total as u64 * 4; - let output = d.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("faces output"), - size: bytes, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, - mapped_at_creation: false, - }); - let partials = (splits > 1).then(|| { - d.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("faces partial sums"), - size: partial as u64 * 4, - usage: wgpu::BufferUsages::STORAGE, - mapped_at_creation: false, + let binds = list + .iter() + .enumerate() + .map(|(i, x)| { + let [a, b, c, out] = &x.buffers; + d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform, + offset: (i * align) as u64, + size: std::num::NonZeroU64::new(x.params.len() as u64), + }), + }, + wgpu::BindGroupEntry { binding: 1, resource: a.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: b.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: c.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: out.as_entire_binding() }, + ], + }) }) - }); - let staging = d.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("faces read-back"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind = d.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &d.layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: input.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: partials.as_ref().unwrap_or(&output).as_entire_binding() }, - ], - }); - let summing = partials.as_ref().map(|partials| { - d.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &d.layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: partials.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: res.weights.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 3, resource: res.bias.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 4, resource: output.as_entire_binding() }, - ], - }) - }); - let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); - { - let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces conv"), timestamp_writes: None }); - pass.set_pipeline(if direct { &d.direct } else { &d.tiled }); - pass.set_bind_group(0, &bind, &[]); - pass.dispatch_workgroups(u32_of(wx)?, u32_of(wy)?, u32_of(wz)?); - if let Some(summing) = &summing { - pass.set_pipeline(&d.reduce); - pass.set_bind_group(0, summing, &[]); - pass.dispatch_workgroups(u32_of(rx)?, u32_of(ry)?, 1); - } + .collect::<Vec<_>>(); + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces"), timestamp_writes: None }); + for (x, bind) in list.iter().zip(&binds) { + pass.set_pipeline(x.pipeline); + pass.set_bind_group(0, bind, &[]); + let [gx, gy, gz] = x.groups; + pass.dispatch_workgroups(gx, gy, gz); } - enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + Ok(()) + } + + /// Submit `enc`, which ends by copying into `staging` (`bytes` long), wait for it and read `staging` back. + fn finish(&self, enc: wgpu::CommandEncoder, staging: &wgpu::Buffer, scopes: [wgpu::ErrorScopeGuard; 2]) -> Result<Vec<f32>, String> { + let d = self.dev; let index = d.queue.submit([enc.finish()]); let slice = staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); @@ -389,7 +531,7 @@ impl FaceGpu { return Err(format!("the GPU reported an error: {e}")); } if let Err(e) = waited { - mark_broken(&format!("the GPU did not finish a convolution: {e}")); + mark_broken(&format!("the GPU did not finish a face model: {e}")); return Err(format!("the GPU did not finish: {e}")); } match rx.recv_timeout(Duration::from_secs(10)) { @@ -402,8 +544,244 @@ impl FaceGpu { data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::<Vec<f32>>() }; staging.unmap(); - Ok(Some(out)) + Ok(out) } + + fn scopes(&self) -> [wgpu::ErrorScopeGuard; 2] { + [self.dev.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), self.dev.device.push_error_scope(wgpu::ErrorFilter::Validation)] + } + + fn staging(&self, bytes: u64) -> wgpu::Buffer { + self.dev.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces read-back"), + size: bytes.max(4), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + } + + fn scratch(&self, bytes: u64) -> wgpu::Buffer { + self.dev.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces values"), + size: bytes.max(4), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) + } + + /// One convolution, uploaded and read back on its own. + fn run(&self, job: &ConvJob) -> Result<Option<Vec<f32>>, String> { + let total = job.cout.checked_mul(job.oh).and_then(|v| v.checked_mul(job.ow)).ok_or("a layer too large")?; + let bytes = total as u64 * 4; + if bytes > self.largest_binding() || job.input.len() as u64 * 4 > self.largest_binding() { + return Ok(None); + } + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let _turn = Turn::take(); + let d = self.dev; + let scopes = self.scopes(); + let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(job.input), + usage: wgpu::BufferUsages::STORAGE, + }); + let output = self.scratch(bytes); + let Some(list) = self.conv_dispatches(job, &input, &output)? else { + // leave the scopes balanced + let [oom, validation] = scopes; + let _ = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + return Ok(None); + }; + let staging = self.staging(bytes); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + self.record(&mut enc, &list)?; + enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + self.finish(enc, &staging, scopes).map(Some) + } + + /// The dispatch of one non-convolution step. + fn op_dispatch(&self, op: &Op, out_len: usize, args: [wgpu::Buffer; 3], out: &wgpu::Buffer) -> Result<Option<Dispatch>, String> { + let d = self.dev; + let limit = d.limits.max_compute_workgroups_per_dimension as usize; + let u = u32_of; + let n = u(out_len)?; + let per_value = |pipeline: &'static wgpu::ComputePipeline, rest: &[u32]| -> Option<Dispatch> { + let groups = flat(out_len.div_ceil(256), limit)?; + let mut all = vec![n, groups[2]]; + all.extend_from_slice(rest); + Some(Dispatch { + pipeline, + params: params24(&all), + buffers: [args[0].clone(), args[1].clone(), args[2].clone(), out.clone()], + groups: [groups[0], groups[1], 1], + }) + }; + let per_group = |pipeline: &'static wgpu::ComputePipeline, rest: &[u32]| -> Option<Dispatch> { + let groups = flat(out_len, limit)?; + let mut all = vec![n, groups[2]]; + all.extend_from_slice(rest); + Some(Dispatch { + pipeline, + params: params24(&all), + buffers: [args[0].clone(), args[1].clone(), args[2].clone(), out.clone()], + groups: [groups[0], groups[1], 1], + }) + }; + let four = |v: &[usize; 4]| -> Result<[u32; 4], String> { Ok([u(v[0])?, u(v[1])?, u(v[2])?, u(v[3])?]) }; + let k = &d.ops; + Ok(match op { + Op::Conv(_) => None, + Op::Unary(f) => per_value(&k.unary, &[u32::from(*f == Unary::Sigmoid)]), + Op::ChannelAffine { channels, plane } => per_value(&k.affine, &[u(*plane)?, u(*channels)?]), + Op::ChannelPrelu { channels, plane } => per_value(&k.prelu, &[u(*plane)?, u(*channels)?]), + Op::Binary { op, dims, a, b } => { + let kind = match op { + Binary::Add => 0, + Binary::Sub => 1, + Binary::Mul => 2, + }; + let mut rest = vec![kind]; + rest.extend(four(dims)?); + rest.extend(four(a)?); + rest.extend(four(b)?); + per_value(&k.binary, &rest) + } + Op::Transpose { dims, strides } => { + let mut rest = vec![0]; + rest.extend(four(dims)?); + rest.extend(four(strides)?); + per_value(&k.transpose, &rest) + } + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => { + per_value(&k.maxpool, &[u(*h)?, u(*w)?, u(*kh)?, u(*kw)?, u(*sh)?, u(*sw)?, u(*oh)?, u(*ow)?]) + } + Op::Resize { h, w, sh, sw, .. } => per_value(&k.resize, &[u(*h)?, u(*w)?, u(*sh)?, u(*sw)?]), + Op::GlobalAverage { plane, .. } => per_group(&k.gavg, &[u(*plane)?]), + Op::Gemv { k: kk, n: nn, trans_b, alpha } => per_group(&k.gemv, &[u(*kk)?, u(*nn)?, u32::from(*trans_b), alpha.to_bits()]), + }) + } + + /// A whole program for each input, every value staying on the device; one submission and one read-back. + fn program(&self, prog: &Program, inputs: &[&[f32]]) -> Result<Option<Vec<Vec<Vec<f32>>>>, String> { + let d = self.dev; + let largest = self.largest_binding(); + if prog.lens.iter().any(|l| *l as u64 * 4 > largest) || inputs.iter().any(|i| Some(&i.len()) != prog.lens.get(INPUT)) { + return Ok(None); + } + let out_bytes: u64 = prog.outputs.iter().map(|(_, v, _)| prog.lens.get(*v).copied().unwrap_or(0) as u64 * 4).sum(); + let total_bytes = out_bytes.checked_mul(inputs.len() as u64).ok_or("too many faces at once")?; + if total_bytes > d.limits.max_buffer_size { + return Ok(None); + } + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let _turn = Turn::take(); + let scopes = self.scopes(); + let staging = self.staging(total_bytes); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("faces program") }); + // buffers nothing reads any more, reused for later values (best fit) + let mut pool: Vec<(u64, wgpu::Buffer)> = Vec::new(); + let take = |pool: &mut Vec<(u64, wgpu::Buffer)>, bytes: u64| -> wgpu::Buffer { + let best = pool + .iter() + .enumerate() + .filter(|(_, (size, _))| *size >= bytes && *size <= bytes.saturating_mul(2)) + .min_by_key(|(_, (size, _))| *size) + .map(|(i, _)| i); + match best { + Some(i) => pool.swap_remove(i).1, + None => self.scratch(bytes), + } + }; + let mut at = 0u64; + let mut fits = true; + for input in inputs { + let mut values: Vec<Option<(u64, wgpu::Buffer)>> = vec![None; prog.lens.len()]; + // a fresh buffer: queued writes land before any of the encoder's work, so a pooled one could still be in use + let first = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(input), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + }); + if let Some(v) = values.get_mut(INPUT) { + *v = Some((input.len() as u64 * 4, first)); + } + let mut list = Vec::new(); + for step in &prog.steps { + let len = prog.lens.get(step.out).copied().ok_or("a step writes a value the program does not have")?; + let out = take(&mut pool, len as u64 * 4); + let mut args = Vec::with_capacity(3); + for a in &step.args { + args.push(match a { + Arg::Value(v) => { + values.get(*v).and_then(|v| v.as_ref()).map(|(_, b)| b.clone()).ok_or("a step reads a value before it is made")? + } + Arg::Const { id, data } => self.constant(*id, data), + }); + } + let dispatches = match &step.op { + Op::Conv(job) => { + let input = args.first().ok_or("a convolution without input")?; + self.conv_dispatches(job, input, &out)? + } + op => { + let arg = |i: usize| args.get(i).cloned().unwrap_or_else(|| d.dummy.clone()); + self.op_dispatch(op, len, [arg(0), arg(1), arg(2)], &out)?.map(|x| vec![x]) + } + }; + let Some(dispatches) = dispatches else { + fits = false; + break; + }; + list.extend(dispatches); + if let Some(v) = values.get_mut(step.out) { + *v = Some((len as u64 * 4, out)); + } + for f in &step.free { + if let Some(b) = values.get_mut(*f).and_then(Option::take) { + pool.push(b); + } + } + } + if !fits { + break; + } + self.record(&mut enc, &list)?; + for (_, v, _) in &prog.outputs { + let (bytes, buffer) = values.get(*v).and_then(|v| v.as_ref()).ok_or("an output was never made")?; + let len = prog.lens.get(*v).copied().unwrap_or(0) as u64 * 4; + enc.copy_buffer_to_buffer(buffer, 0, &staging, at, len.min(*bytes)); + at += len; + } + pool.extend(values.into_iter().flatten()); + } + if !fits { + let [oom, validation] = scopes; + let _ = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + return Ok(None); + } + let flat_out = self.finish(enc, &staging, scopes)?; + let mut rest = flat_out.as_slice(); + let mut per_input = Vec::with_capacity(inputs.len()); + for _ in inputs { + let mut outs = Vec::with_capacity(prog.outputs.len()); + for (_, v, _) in &prog.outputs { + let len = prog.lens.get(*v).copied().unwrap_or(0); + let (this, next) = rest.split_at_checked(len).ok_or("the read-back is short")?; + outs.push(this.to_vec()); + rest = next; + } + per_input.push(outs); + } + Ok(Some(per_input)) + } +} + +/// Run `f`, turning a panic into a broken device. +fn guarded<T>(f: impl FnOnce() -> Result<T, String>) -> Result<T, String> { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| { + mark_broken("a face model on the GPU panicked"); + Err("the GPU runner gave up".into()) + }) } impl Accelerator for FaceGpu { @@ -418,13 +796,23 @@ impl Accelerator for FaceGpu { if crate::switched_off().is_some() || job.macs() < MIN_MACS { return Ok(None); } - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run(job))).unwrap_or_else(|_| { - mark_broken("a face convolution panicked"); - Err("the GPU runner gave up".into()) - }) + guarded(|| self.run(job)) + } + + fn run_program(&self, program: &Program, inputs: &[&[f32]]) -> Result<Option<Vec<Vec<Vec<f32>>>>, String> { + if let Some(why) = broken_reason() { + return Err(format!("the GPU is not used any more: {why}")); + } + if crate::switched_off().is_some() || PER_CONV.load(Ordering::Relaxed) { + return Ok(None); + } + guarded(|| self.program(program, inputs)) } } +/// Tests and benchmarks only: run convolutions one by one (the first step's way) instead of whole programs. +static PER_CONV: AtomicBool = AtomicBool::new(false); + #[cfg(test)] mod tests { use super::*; @@ -513,6 +901,28 @@ mod tests { assert!(gpu.resident_bytes() > 0, "the weights stay on the device"); } + #[test] + fn every_operator_on_the_gpu_gives_what_the_cpu_gives() { + let Some(gpu) = gpu() else { return }; + let net = lightcraft_faces::net::Net::new(lightcraft_faces::synthetic::every_operator_graph()).unwrap(); + let inputs: Vec<lightcraft_faces::net::Tensor> = (0..3) + .map(|s| { + lightcraft_faces::net::Tensor::new(vec![1, 4, 8, 8], (0..256).map(|i| (((i * 31 + s * 7) % 37) as f32 - 18.0) * 0.05).collect()) + .unwrap() + }) + .collect(); + let cpu: Vec<_> = inputs.iter().map(|x| net.run(x.clone()).unwrap()).collect(); + let program = net.program(&[1, 4, 8, 8]).unwrap().unwrap(); + let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect(); + let got = gpu.program(&program, &slices).unwrap().unwrap(); + for (want, got) in cpu.iter().zip(&got) { + for ((name, want), got) in want.iter().zip(got) { + let worst = want.data.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(worst < 1e-4, "{name}: off by {worst}"); + } + } + } + /// The real models on the GPU agree with the CPU interpreter: `LC_FACE_MODELS=<folder>` holding /// `face_detection_yunet_2023mar.onnx`, `face_recognition_sface_2021dec.onnx` and (optionally) `glintr100.onnx`. #[test] @@ -555,7 +965,8 @@ mod tests { } } - /// Timings, CPU against GPU: `LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. + /// Timings, CPU against the GPU one convolution at a time and as whole programs (one face, and a batch of 16): + /// `LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. #[test] #[ignore = "a benchmark: needs LC_FACE_MODELS and a GPU"] fn timings() { @@ -573,21 +984,35 @@ mod tests { let (w, h) = (1024, 768); let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); let opts = lightcraft_faces::yunet::Options::default(); - let cpu = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + let cpu = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + PER_CONV.store(true, Ordering::Relaxed); det.accelerate(accelerator().unwrap(), false).unwrap(); - let fast = time(5, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); - eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + let per_conv = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + PER_CONV.store(false, Ordering::Relaxed); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let whole = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + assert!(det.net().accelerator().is_some()); + eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU per convolution {per_conv:.1} ms, GPU whole network {whole:.1} ms"); for (file, sha) in [ ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), ] { let e = lightcraft_faces::runtime::Embedder::load(&dir.join(file), &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); let (ew, eh) = e.input_size(); - let face: Vec<u8> = (0..ew * eh * 3).map(|i| (i * 13 % 251) as u8).collect(); - let cpu = time(3, &mut || drop(e.embed(&face).unwrap())); + let faces: Vec<Vec<u8>> = (0..16).map(|s| (0..ew * eh * 3).map(|i| ((i + s * 101) * 13 % 251) as u8).collect()).collect(); + let refs: Vec<&[u8]> = faces.iter().map(Vec::as_slice).collect(); + let cpu = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + PER_CONV.store(true, Ordering::Relaxed); e.accelerate(accelerator().unwrap(), false).unwrap(); - let fast = time(3, &mut || drop(e.embed(&face).unwrap())); - eprintln!("{file}: CPU {cpu:.1} ms, GPU {fast:.1} ms"); + let per_conv = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + PER_CONV.store(false, Ordering::Relaxed); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let whole = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + let batch = time(3, &mut || drop(e.embed_batch(&refs))) / 16.0; + assert!(e.net().accelerator().is_some()); + eprintln!( + "{file}: CPU {cpu:.1} ms, GPU per convolution {per_conv:.1} ms, GPU whole network {whole:.1} ms, batch of 16 {batch:.1} ms a face" + ); } } } diff --git a/crates/gpu/src/wgsl/faces_ops.wgsl b/crates/gpu/src/wgsl/faces_ops.wgsl new file mode 100644 index 000000000..aa7eba242 --- /dev/null +++ b/crates/gpu/src/wgsl/faces_ops.wgsl @@ -0,0 +1,188 @@ +// The face networks' other operators (lightcraft-faces `net::program`), so activations stay on the device between layers. +// Same bindings as faces_conv.wgsl: the parameters, three read-only operands and the output. Element kernels run one +// thread per output value; `gavg` and `gemv` one workgroup per output value. +// +// Parameters are 24 numbers: q(0) = values (or workgroups) to make, q(1) = workgroups along x, then per kernel. + +struct P { + v: array<vec4<u32>, 6>, +} + +@group(0) @binding(0) var<uniform> p: P; +@group(0) @binding(1) var<storage, read> a: array<f32>; +@group(0) @binding(2) var<storage, read> b: array<f32>; +@group(0) @binding(3) var<storage, read> c: array<f32>; +@group(0) @binding(4) var<storage, read_write> dst: array<f32>; + +fn q(i: u32) -> u32 { + return p.v[i / 4u][i % 4u]; +} + +fn index(wid: vec3<u32>, lid: u32) -> u32 { + return (wid.y * q(1) + wid.x) * 256u + lid; +} + +// q(2): 0 relu, 1 sigmoid +@compute @workgroup_size(256) +fn unary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let x = a[i]; + if (q(2) == 0u) { + dst[i] = max(x, 0.0); + } else { + dst[i] = 1.0 / (1.0 + exp(-x)); + } +} + +// q(2) plane, q(3) channels: x * b[c] + c[c] +@compute @workgroup_size(256) +fn affine(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let ch = (i / q(2)) % q(3); + dst[i] = a[i] * b[ch] + c[ch]; +} + +// q(2) plane, q(3) channels: x < 0 ? x * b[c] : x +@compute @workgroup_size(256) +fn prelu(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let x = a[i]; + let ch = (i / q(2)) % q(3); + dst[i] = select(x, x * b[ch], x < 0.0); +} + +// q(2): 0 add, 1 sub, 2 mul; q(3..7) output dims; q(7..11) a's strides; q(11..15) b's strides +@compute @workgroup_size(256) +fn binary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + var rest = i; + var ia = 0u; + var ib = 0u; + for (var d = 3; d >= 0; d = d - 1) { + let du = u32(d); + let size = q(3u + du); + let at = rest % size; + rest = rest / size; + ia = ia + at * q(7u + du); + ib = ib + at * q(11u + du); + } + let x = a[ia]; + let y = b[ib]; + if (q(2) == 0u) { + dst[i] = x + y; + } else if (q(2) == 1u) { + dst[i] = x - y; + } else { + dst[i] = x * y; + } +} + +// q(3..7) output dims; q(7..11) the input's strides for each output axis +@compute @workgroup_size(256) +fn transpose(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + var rest = i; + var at_in = 0u; + for (var d = 3; d >= 0; d = d - 1) { + let du = u32(d); + let size = q(3u + du); + at_in = at_in + (rest % size) * q(7u + du); + rest = rest / size; + } + dst[i] = a[at_in]; +} + +// q(2) h, q(3) w, q(4) kh, q(5) kw, q(6) sh, q(7) sw, q(8) oh, q(9) ow +@compute @workgroup_size(256) +fn maxpool(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let h = q(2); + let w = q(3); + let oh = q(8); + let ow = q(9); + let ox = i % ow; + let oy = (i / ow) % oh; + let ch = i / (ow * oh); + var m = -3.402823e38; + for (var ky = 0u; ky < q(4); ky = ky + 1u) { + for (var kx = 0u; kx < q(5); kx = kx + 1u) { + let y = oy * q(6) + ky; + let x = ox * q(7) + kx; + if (y < h && x < w) { + m = max(m, a[(ch * h + y) * w + x]); + } + } + } + dst[i] = m; +} + +// q(2) h, q(3) w, q(4) sh, q(5) sw: nearest upscale by whole numbers +@compute @workgroup_size(256) +fn resize(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let h = q(2); + let w = q(3); + let ow = w * q(5); + let oh = h * q(4); + let ox = i % ow; + let oy = (i / ow) % oh; + let ch = i / (ow * oh); + dst[i] = a[(ch * h + oy / q(4)) * w + ox / q(5)]; +} + +var<workgroup> partial: array<f32, 256>; + +fn reduce(lid: u32) { + for (var s = 128u; s > 0u; s = s >> 1u) { + workgroupBarrier(); + if (lid < s) { + partial[lid] = partial[lid] + partial[lid + s]; + } + } + workgroupBarrier(); +} + +// one workgroup per channel; q(2) plane +@compute @workgroup_size(256) +fn gavg(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let ch = wid.y * q(1) + wid.x; + let plane = q(2); + var sum = 0.0; + if (ch < q(0)) { + for (var j = lid; j < plane; j = j + 256u) { + sum = sum + a[ch * plane + j]; + } + } + partial[lid] = sum; + reduce(lid); + if (lid == 0u && ch < q(0)) { + dst[ch] = partial[0] / f32(plane); + } +} + +// one workgroup per output; q(2) k, q(3) n, q(4) 1 when B is [n][k], q(5) alpha's bits +@compute @workgroup_size(256) +fn gemv(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let o = wid.y * q(1) + wid.x; + let k = q(2); + let n = q(3); + var sum = 0.0; + if (o < q(0)) { + for (var j = lid; j < k; j = j + 256u) { + let wi = select(j * n + o, o * k + j, q(4) == 1u); + sum = sum + a[j] * b[wi]; + } + } + partial[lid] = sum; + reduce(lid); + if (lid == 0u && o < q(0)) { + dst[o] = bitcast<f32>(q(5)) * partial[0] + c[o]; + } +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..bfaf57fdf 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,13 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Run face models on the CPU only": "Gesichtsmodelle nur auf der CPU ausführen", + "Detection": "Erkennung", + "Recognition": "Wiedererkennung", + "Not loaded yet": "Noch nicht geladen", + "Checking the GPU…": "GPU wird geprüft …", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.", + "Checking the GPU for faces": "GPU für Gesichter wird geprüft" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..d153c911e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,13 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Run face models on the CPU only": "Ejecutar los modelos de caras solo en la CPU", + "Detection": "Detección", + "Recognition": "Reconocimiento", + "Not loaded yet": "Aún no cargado", + "Checking the GPU…": "Comprobando la GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.", + "Checking the GPU for faces": "Comprobando la GPU para las caras" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..3827ce3ac 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,13 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Run face models on the CPU only": "Exécuter les modèles de visages sur le processeur uniquement", + "Detection": "Détection", + "Recognition": "Reconnaissance", + "Not loaded yet": "Pas encore chargé", + "Checking the GPU…": "Vérification du GPU…", + "CPU": "Processeur", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", + "Checking the GPU for faces": "Vérification du GPU pour les visages" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..d9d83d109 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,13 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Run face models on the CPU only": "顔モデルを CPU だけで実行", + "Detection": "検出", + "Recognition": "認識", + "Not loaded yet": "未読み込み", + "Checking the GPU…": "GPU を確認中…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。", + "Checking the GPU for faces": "顔用の GPU を確認中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..f626e6433 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,13 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Run face models on the CPU only": "Executar os modelos de rostos só na CPU", + "Detection": "Detecção", + "Recognition": "Reconhecimento", + "Not loaded yet": "Ainda não carregado", + "Checking the GPU…": "Verificando a GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.", + "Checking the GPU for faces": "Verificando a GPU para rostos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..f08c3cb7d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,13 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Run face models on the CPU only": "Запускать модели лиц только на ЦП", + "Detection": "Обнаружение", + "Recognition": "Распознавание", + "Not loaded yet": "Ещё не загружена", + "Checking the GPU…": "Проверка ГП…", + "CPU": "ЦП", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.", + "Checking the GPU for faces": "Проверка ГП для лиц" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..a0783aa5f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,13 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Run face models on the CPU only": "Запускати моделі облич лише на ЦП", + "Detection": "Виявлення", + "Recognition": "Розпізнавання", + "Not loaded yet": "Ще не завантажено", + "Checking the GPU…": "Перевірка ГП…", + "CPU": "ЦП", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.", + "Checking the GPU for faces": "Перевірка ГП для облич" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..c2ef31084 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,13 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Run face models on the CPU only": "仅在 CPU 上运行人脸模型", + "Detection": "检测", + "Recognition": "识别", + "Not loaded yet": "尚未加载", + "Checking the GPU…": "正在检查 GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。", + "Checking the GPU for faces": "正在检查用于人脸的 GPU" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..b830bf434 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,13 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Run face models on the CPU only": "僅在 CPU 上執行臉孔模型", + "Detection": "偵測", + "Recognition": "辨識", + "Not loaded yet": "尚未載入", + "Checking the GPU…": "正在檢查 GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。", + "Checking the GPU for faces": "正在檢查用於臉孔的 GPU" } diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..27933f53d 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -157,6 +157,42 @@ pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// Where the face models run (the GPU only once a check shows it gives the CPU's results faster), and the switch that +/// keeps them on the CPU. +fn gpu_section(app: &mut LccApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Value) { + heading(ui, t, "Processor"); + let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false); + let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only")); + register(ui.ctx(), "check:settings.facesCpuOnly", r.rect); + if r.changed() { + match app.run("faces.gpu", json!({"cpuOnly": cpu})) { + Ok(_) => app.caches.faces_epoch += 1, + Err(e) => app.toast(ui.ctx(), e), + } + } + for (what, label) in [("detection", "Detection"), ("recognition", "Recognition")] { + let st = &gpu[what]; + let (text, why) = if st.is_null() { + (crate::i18n::tr("Not loaded yet").to_string(), None) + } else if st["checking"] == true { + (crate::i18n::tr("Checking the GPU…").to_string(), None) + } else if let Some(device) = st["device"].as_str() { + (device.to_string(), None) + } else { + (crate::i18n::tr("CPU").to_string(), st["why"].as_str()) + }; + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim)); + let r = ui.label(text); + register(ui.ctx(), format!("faces:device:{what}"), r.rect); + if let Some(why) = why { + r.on_hover_text(sentence(why)); + } + }); + } + hint(ui, t, "A face model uses the GPU only when a check shows it gives the same results faster."); +} + /// A download-style size: decimal megabytes, as file managers and model pages show them. fn mb(bytes: Option<u64>) -> String { match bytes { @@ -231,6 +267,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default(); let downloads = app.session.execute("faces.models.downloads", &json!({})).map(|v| v["downloads"].as_array().cloned().unwrap_or_default()).unwrap_or_default(); + gpu_section(app, ui, t, &list["gpu"]); heading(ui, t, "Face detector"); for m in all.iter().filter(|m| m["role"] == "detector") { model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime); diff --git a/docs/faces.md b/docs/faces.md index f4f4a4757..c767ce913 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -145,31 +145,40 @@ names alone do not guarantee the same speed, and those additional model files we ### On the GPU -On a machine with a usable GPU, the convolutions of the detector and the recogniser (most of their time) run on it -through wgpu (`crates/gpu/src/faces.rs`, `wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input -straight into workgroup memory, cuts K into slices when a layer's map is small, and a direct kernel for depthwise -layers). Everything else in the graph stays on the CPU interpreter, which remains the reference. After a model loads, -a background check runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when +On a machine with a usable GPU, the whole detector and recogniser run on it through wgpu (`crates/gpu/src/faces.rs`). +The interpreter lowers the graph to a flat program for the input's shape (`crates/faces/src/net/program.rs`): each +convolution (`wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input straight into workgroup memory, +cuts K into slices when a map is small, and a direct kernel for depthwise layers) and every other operator (activations, +batch norm, PRelu, broadcasting add/sub/mul, max pooling, nearest upscaling, transposes, global pooling and the fully +connected layer, `wgsl/faces_ops.wgsl`) is a kernel, every value stays on the device between layers in reused +buffers, and a run is one upload, one submission and one read-back. Shape-only operators make no step. A model that +needs something the program cannot express runs its convolutions one by one on the GPU instead. Several faces of one +photo go together (`Embedder::embed_batch`, up to 16 per submission). + +The CPU interpreter remains the reference. After a model loads, a background check (a row in the activity stack, "Checking +the GPU for faces") runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when every output is within 1e-3 of the CPU's (relative to the output's largest value) **and** it was faster; until then, -and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference), missing, -wrong or slower, the model runs on the CPU. The first GPU error drops it for that model. The weights stay on a device of -the scan's own (separate from the editor's) while the model is loaded, at most 768 MB per model. The log says which -device each model runs on. +and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference, or **Settings ▸ +Faces ▸ Run face models on the CPU only**), missing, wrong or slower, the model runs on the CPU. The first GPU error, +or a result of the wrong size or with values that are not finite, drops it for that model. Settings ▸ Faces shows +which device each model runs on (`faces.gpu`). The weights stay on a device of the scan's own (separate from the +editor's) while the model is loaded, at most 768 MB per model. -Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under heavy load from other -jobs (so the absolute numbers are noisy; the ratios held across three runs): +The background scan's pace also limits the GPU: paused or light, one model run at a time and the GPU busy at most a +quarter of the time (the editor renders on the same GPU); normal, two at once and three quarters; full, as fast as it +goes. -| Model | CPU | GPU | -| --- | ---: | ---: | -| YuNet (640², one photo) | 33–34 ms | 35–39 ms | -| SFace 2021dec | 25–28 ms | 22–27 ms | -| AuraFace v1 | 204–208 ms | 81–103 ms | +Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under very heavy load from +other jobs (load average 100–250, so the absolute numbers are noisy): + +| Model | CPU | GPU, one layer at a time | GPU, whole network | Batch of 16, per face | +| --- | ---: | ---: | ---: | ---: | +| YuNet (640², one photo) | 58–60 ms | 85–89 ms | 21 ms | – | +| SFace 2021dec | 40–42 ms | 47–51 ms | 9.6 ms | 8.1 ms | +| AuraFace v1 | 295–308 ms | 165–172 ms | 70–73 ms | 71 ms | -Each layer still goes to the GPU and back: for YuNet and SFace that round trip costs about what the layer saves, so on -most machines they stay on the CPU by the speed check, while AuraFace runs 2–2.5× faster. Keeping activations on the GPU -between layers (the remaining operators as kernels) and batching faces per dispatch are the next steps. `LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks -agreement on the real models and prints these timings. +agreement on the real models and every operator, and prints these timings. ### Agreement and regression coverage From f9c97ab57bebe082f33aeeb76e2f27a239e41f10 Mon Sep 17 00:00:00 2001 From: Gert Pellin <gert@pellin.be> Date: Wed, 14 Oct 2026 11:09:25 +0200 Subject: [PATCH 131/315] Metadata: read the Panasonic lens name from maker note tag 0x0051 (#1671) Panasonic writes no Exif LensModel. The lens name is in its maker note (LensType, 0x0051), and a RW2 or RWL keeps that maker note only in the Exif of its embedded JpgFromRaw (IFD0 0x002E), not in the raw's own Exif IFD. When LensModel is absent, read it from the maker note, and for a raw from the JpgFromRaw's Exif. Checked on the CC0 samples DC-GX9 gx9_1_1.RW2 (LUMIX G 25/F1.7) and DC-S5 P1047510.RW2 (LUMIX S 85/F1.8), also through the sparse header plan an import uses. --- crates/meta/src/exif.rs | 103 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..51da692e8 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -16,8 +16,14 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> { - let tiff = Tiff::parse(strip_exif_header(bytes))?; - Ok(from_tiff(&tiff)) + let data = strip_exif_header(bytes); + let tiff = Tiff::parse(data)?; + let mut m = from_tiff(&tiff); + // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) + if m.lens_model.is_none() { + m.lens_model = panasonic_lens(data, &tiff).or_else(|| panasonic_jpg_from_raw_lens(&tiff)); + } + Ok(m) } fn text(ifd: Option<&Ifd>, tag: u16) -> Option<String> { @@ -138,11 +144,104 @@ fn read_gps(g: &Ifd) -> Option<Gps> { Some(Gps { latitude: lat, longitude: lon, altitude }) } +/// Panasonic maker note tag `0x0051` (LensType in the ExifTool Panasonic tag names): the lens name, ASCII. +const PANASONIC_LENS_TYPE: u16 = 0x0051; +/// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included. +const PANASONIC_JPG_FROM_RAW: u16 = 0x002E; + +/// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). +fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option<String> { + let exif = tiff.exif()?; + if !exif.bytes(t::MAKER_NOTE)?.starts_with(b"Panasonic\0") { + return None; + } + let e = exif.get(t::MAKER_NOTE)?; + let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default(); + let mn = lightcraft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?; + text(Some(&mn.ifd), PANASONIC_LENS_TYPE) +} + +/// The lens name from the Exif of a RW2's (or Leica RWL's) embedded JpgFromRaw. +fn panasonic_jpg_from_raw_lens(tiff: &Tiff) -> Option<String> { + let jpg = tiff.ifds.first()?.bytes(PANASONIC_JPG_FROM_RAW)?; + let exif = crate::jpeg_segments(jpg).exif?; + let data = strip_exif_header(&exif); + panasonic_lens(data, &Tiff::parse(data).ok()?) +} + #[cfg(test)] pub(crate) mod tests { use super::*; use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value + /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). + fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec<u8> { + let note = |at: u32| { + let mut v = b"Panasonic\0\0\0".to_vec(); + order.put_u16(&mut v, 1); + order.put_u16(&mut v, if lens.is_some() { PANASONIC_LENS_TYPE } else { 0x0001 }); + order.put_u16(&mut v, 2); + let name = format!("{:<34}\0", lens.unwrap_or("0210")); + order.put_u32(&mut v, name.len() as u32); + order.put_u32(&mut v, at + 30); + order.put_u32(&mut v, 0); + v.extend_from_slice(name.as_bytes()); + v + }; + let build = |at: u32| { + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note(at))); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(25, 1)])); + if let Some(l) = lens_model { + exif.set(t::LENS_MODEL, Value::Ascii(l.into())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + }; + // the note's own offset is only known once it is laid out; its length doesn't change + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32; + build(at) + } + + #[test] + fn panasonic_maker_note_names_the_lens() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let m = read_exif(&panasonic_exif(order, Some("LUMIX G 25/F1.7"), None)); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX G 25/F1.7")); + assert_eq!(m.focal_length, Some(25.0)); + } + // Exif's own LensModel wins, and a note without 0x0051 leaves the lens empty + let m = read_exif(&panasonic_exif(ByteOrder::Little, Some("LUMIX G 25/F1.7"), Some("Other 25mm"))); + assert_eq!(m.lens_model.as_deref(), Some("Other 25mm")); + assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None); + } + + #[test] + fn rw2_reads_the_lens_from_its_jpg_from_raw() { + // a RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(panasonic_exif(ByteOrder::Little, Some("LUMIX S 85/F1.8"), None)); + jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpg.extend(app1); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + let mut exif = IfdBuilder::new(); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(85, 1)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); + ifd0.set_child(t::EXIF_IFD, exif); + let rw2 = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let m = read_exif(&rw2); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + assert_eq!(m.focal_length, Some(85.0)); + assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + } + pub(crate) fn sample_exif(order: ByteOrder) -> Vec<u8> { let mut exif = IfdBuilder::new(); exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)])); From aa62ba05f9af2ca05ecb24eff1dd784407faca79 Mon Sep 17 00:00:00 2001 From: Aleksandr Batyrev <aiiiotus99i@gmail.com> Date: Wed, 14 Oct 2026 14:16:35 +0500 Subject: [PATCH 132/315] fix(rawinfo): return failure for unreadable or invalid inputs --- contributors/people.toml | 3 + crates/raw/examples/rawinfo.rs | 115 +++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..cf5af42d2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -31,3 +31,6 @@ display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.FooVoo] +sync_github_name = true diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs index d5cc21b15..c1e05bedc 100644 --- a/crates/raw/examples/rawinfo.rs +++ b/crates/raw/examples/rawinfo.rs @@ -1,9 +1,11 @@ //! `cargo run --release -p lightcraft-raw --example rawinfo -- [--tree] FILE...` //! //! Prints the TIFF structure (with `--tree`), decode result, timings (MP/s) and basic statistics. +//! Exits unsuccessfully if any input cannot be read, decoded or developed, while inspecting the remaining files. use lightcraft_raw::{Method, RawData, decode, embedded_preview, probe}; use lightcraft_tiff::{Ifd, Tiff, Value}; +use std::process::ExitCode; use std::time::Instant; fn short(v: &Value) -> String { @@ -34,11 +36,23 @@ fn dump(ifd: &Ifd, depth: usize, label: &str) { } } -fn main() { - let args: Vec<String> = std::env::args().skip(1).collect(); +fn inspect_files(args: &[String]) -> usize { let tree = args.iter().any(|a| a == "--tree"); - for path in args.iter().filter(|a| !a.starts_with("--")) { - let bytes = std::fs::read(path).expect("read"); + let paths: Vec<_> = args.iter().filter(|a| !a.starts_with("--")).collect(); + if paths.is_empty() { + eprintln!("usage: rawinfo [--tree] FILE..."); + return 1; + } + let mut failures = 0; + for path in paths { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) => { + eprintln!("{path}: read error: {e}"); + failures += 1; + continue; + } + }; println!("== {path} ({:.1} MB) format {:?}", bytes.len() as f64 / 1e6, probe(&bytes)); if tree && let Ok(t) = Tiff::parse(&bytes) { for (i, ifd) in t.ifds.iter().enumerate() { @@ -119,15 +133,104 @@ fn main() { s[2] / n ); } - Err(e) => println!(" develop {m:?}: {e}"), + Err(e) => { + println!(" develop {m:?}: {e}"); + failures += 1; + } } } } - Err(e) => println!(" decode error: {e}"), + Err(e) => { + println!(" decode error: {e}"); + failures += 1; + } } match embedded_preview(&bytes) { Some(p) => println!(" preview: {} bytes", p.len()), None => println!(" preview: none"), } } + failures +} + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().skip(1).collect(); + if inspect_files(&args) == 0 { ExitCode::SUCCESS } else { ExitCode::FAILURE } +} + +#[cfg(test)] +mod tests { + use super::inspect_files; + use lightcraft_raw::{BlackLevel, Cfa, ColorData, DngWriteOptions, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect, write_dng}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!("lightcraft-rawinfo-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&path).expect("create test directory"); + Self(path) + } + + fn path(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn unreadable_input_is_a_failure_without_panicking() { + let dir = TempDir::new(); + assert_eq!(inspect_files(&[dir.path("absent.arw")]), 1); + } + + #[test] + fn invalid_inputs_are_counted_without_stopping_the_batch() { + let dir = TempDir::new(); + let path = dir.path("invalid.arw"); + std::fs::write(&path, b"not a raw file").expect("write invalid fixture"); + assert_eq!(inspect_files(&[path.clone(), path]), 2); + } + + #[test] + fn input_paths_are_required() { + assert_eq!(inspect_files(&[]), 1); + assert_eq!(inspect_files(&["--tree".into()]), 1); + } + + #[test] + fn valid_generated_dng_succeeds_after_an_unreadable_input() { + let dir = TempDir::new(); + let raw = RawImage { + format: RawFormat::Dng, + width: 16, + height: 16, + cpp: 1, + data: RawData::U16(vec![2048; 16 * 16]), + cfa: Some(Cfa::bayer("RGGB").expect("valid CFA")), + bits: 14, + black: BlackLevel::uniform(0.0), + white: vec![16383.0], + active_area: Rect::new(0, 0, 16, 16), + crop: Rect::new(0, 0, 16, 16), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + }; + let path = dir.path("valid.dng"); + std::fs::write(&path, write_dng(&raw, &DngWriteOptions::default()).expect("encode generated fixture")).expect("write fixture"); + assert_eq!(inspect_files(std::slice::from_ref(&path)), 0); + assert_eq!(inspect_files(&[dir.path("absent.arw"), path]), 1); + } } From 3c907e0c9d3b618a5ccf9f0aca9b4fa191d6c076 Mon Sep 17 00:00:00 2001 From: Sergio Perea <sperea@protonmail.com> Date: Wed, 14 Oct 2026 12:08:36 +0200 Subject: [PATCH 133/315] fix(export): a metadata/sharpen/conflict value that passes validation is the one exported `ExportOptions::validate` accepted `metadata`, `sharpen`, `sharpenAmount` and `conflict` case-insensitively, but `from_json` read them with serde's exact-case names and silently fell back to the default when that failed. `{"format": "jpeg", "metadata": "NONE"}` therefore passed validation and exported with `MetadataPolicy::All`: GPS, location and camera data went out in a file the caller asked to carry none. MCP, the control channel and the CLI all go through `from_params`, so all were affected. Each of the four enums gets a case-insensitive `parse` (the pattern `HdrCurve::parse` and `JxlEffort::parse` already use), and both `validate` and `from_json` call it, so validation and conversion can no longer disagree. Accepted inputs, error messages, defaults and `to_json` output are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/export.rs | 118 ++++++++++++++++++++++++++++-- crates/engine/src/tests_export.rs | 24 ++++++ 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..b3890077f 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -162,6 +162,17 @@ pub enum Conflict { Skip, } +impl Conflict { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "unique" => Some(Self::Unique), + "overwrite" => Some(Self::Overwrite), + "skip" => Some(Self::Skip), + _ => None, + } + } +} + /// A named set of `app.export` params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportPreset { @@ -191,6 +202,18 @@ pub enum SharpenFor { Glossy, } +impl SharpenFor { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "screen" => Some(Self::Screen), + "matte" => Some(Self::Matte), + "glossy" => Some(Self::Glossy), + _ => None, + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SharpenAmount { @@ -200,6 +223,17 @@ pub enum SharpenAmount { High, } +impl SharpenAmount { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "standard" => Some(Self::Standard), + "high" => Some(Self::High), + _ => None, + } + } +} + /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -634,6 +668,18 @@ pub enum MetadataPolicy { None, } +impl MetadataPolicy { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "all" => Some(Self::All), + "allexceptcamera" => Some(Self::AllExceptCamera), + "copyright" => Some(Self::Copyright), + "none" => Some(Self::None), + _ => None, + } + } +} + /// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or /// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -907,6 +953,10 @@ impl ExportOptions { let s = string(cmd, k, v)?; if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } }; + // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported + let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { + if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; for (k, v) in obj { if v.is_null() { continue; @@ -962,17 +1012,17 @@ impl ExportOptions { "naming" | "subfolder" | "path" | "dir" | "preset" => { string(cmd, k, v)?; } - "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, - "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, + "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, - "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { serde_json::from_value::<DngCompression>(v.clone()) .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; } - "metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?, + "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, "colorSpace" => { if OutputSpace::parse(string(cmd, k, v)?).is_none() { return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); @@ -1074,12 +1124,12 @@ impl ExportOptions { resize: Self::resize_from_json(p), ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), - sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), - sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), + sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), + sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), - conflict: enm(p, "conflict").unwrap_or(d.conflict), + conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { Some("none") => TiffCompression::None, Some("lzw") => TiffCompression::Lzw, @@ -1087,7 +1137,7 @@ impl ExportOptions { _ => d.tiff_compression, }, dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), - metadata: enm(p, "metadata").unwrap_or(d.metadata), + metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), @@ -3190,6 +3240,58 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the + /// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and + /// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and + /// `conflict` had the same gap. + #[test] + fn accepted_choices_are_the_ones_exported() { + use serde_json::json; + let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); + let metadata = [ + ("all", MetadataPolicy::All), + ("allExceptCamera", MetadataPolicy::AllExceptCamera), + ("copyright", MetadataPolicy::Copyright), + ("none", MetadataPolicy::None), + ]; + for (name, want) in metadata { + for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { + assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); + } + } + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] + { + assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); + } + for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { + assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); + } + for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { + assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); + } + // an explicit value nothing reads as is an error, never the permissive default + for v in ["", " none", "nothing", "all-except-camera"] { + let e = read("metadata", v).unwrap_err(); + assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); + } + assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); + assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); + // absent (or null) keeps the defaults + let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); + assert_eq!( + (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), + (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) + ); + // `removeLocation` stays independent of the policy's spelling + let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); + assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); + // stored settings (lenient `from_json`) read the same spellings + assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); + // and `to_json` still writes what the MCP schema lists + let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); + assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); + } + /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. #[test] diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 592f7349d..8617c9eb5 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -516,6 +516,30 @@ fn remove_location_holds_for_original_and_dng_exports() { let _ = std::fs::remove_dir_all(&dir); } +/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data +/// (it used to be read as `all` and carry both). +#[test] +fn metadata_none_in_any_case_exports_no_metadata() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap(); + let export = |s: &mut Session, extra: serde_json::Value| { + let mut p = json!({"format": "jpeg", "longEdge": 64}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes) + }; + let all = export(&mut s, json!({})); + assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}"); + for v in ["none", "NONE", "None"] { + let m = export(&mut s, json!({"metadata": v})); + assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}"); + } + let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true})); + assert!(m.gps.is_none() && m.city.is_none(), "{m:?}"); +} + /// An original raw whose own metadata holds GPS is refused under "Remove location info". #[test] fn remove_location_refuses_a_raw_that_embeds_gps() { From 0f398f6dbaafb274ef81be541f6cff0057e6b3bb Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 12:33:09 +0200 Subject: [PATCH 134/315] Edit panel: the histogram clipping buttons stay clear of the hovered scroll bar (fixes #1640) --- crates/ui-egui/src/panels/edit.rs | 9 ++++++++- crates/ui-egui/src/tests_panels.rs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..2e59508eb 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -679,8 +679,15 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } let (lo, hi) = h.clipping(); - for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] { + // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep + // clear of it, the left one too so they stay symmetric (#1640) + let scroll = ui.spacing().scroll; + let inset = 12.0 + scroll.bar_width + scroll.bar_outer_margin; + for (name, on, x, active) in + [("low", lo > 0.003, plot.left() + inset, app.ui.show_clipping), ("high", hi > 0.003, plot.right() - inset, app.ui.show_clipping)] + { let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0)); + register(ui.ctx(), format!("histogramClip:{name}"), tri); p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) }); paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) }); } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index b18c4e968..aa2c8ece5 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2365,3 +2365,20 @@ fn new_album_can_leave_the_selected_photos_out() { confirm(&mut h); assert!(photos(&h, "Nothing").is_empty()); } + +#[test] +fn the_histogram_clipping_buttons_stay_clear_of_the_hovered_scroll_bar() { + // every section open: the Edit panel scrolls, so its scroll bar runs past the histogram + let h = demo( + [1400.0, 700.0], + json!({"view": "detail", "right": "edit", "histogram": true, "openSections": ["light", "color", "effects", "detail", "optics"]}), + ); + let hist = widget(&h, "histogram"); + let (low, high) = (widget(&h, "histogramClip:low"), widget(&h, "histogramClip:high")); + // a hovered floating scroll bar grows to its full width at the panel's right edge, over the content + let scroll = h.view.ctx.global_style().spacing.scroll; + let bar_left = hist.right() - scroll.bar_outer_margin - scroll.bar_width; + assert!(high.right() <= bar_left, "the highlight clipping button {high:?} is under the scroll bar (from x = {bar_left})"); + // and the shadow one keeps the same inset from its edge + assert!(((low.left() - hist.left()) - (hist.right() - high.right())).abs() < 0.5, "{low:?} {high:?} in {hist:?}"); +} From a142179f2028537776a5ee501a0e0b9b760b9fa2 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Wed, 14 Oct 2026 12:56:47 +0200 Subject: [PATCH 135/315] LR-MERGE-HDR: keep the reference's clipped highlights where other frames moved; deghost catches ghosts in dark skies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A star moving across a night bracket became a black hole: where the reference was clipped, frames in which the star had moved read the dark sky and replaced it. Such frames are now left out there (a clipped reference pixel means at least that bright). Deghost compared ≤ 1024 px versions and, in dark areas, let the brighter frames predict themselves, so their ghosts looked consistent; it now uses ≤ 2048 px maps (same feather share) and trusts the reference down to 0.1 % of its clip. Aligned frames are warped one at a time, each replacing its original, instead of all copies at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/merge/src/hdr.rs | 47 ++++++++++++++++++++++++--------------- crates/merge/tests/hdr.rs | 45 +++++++++++++++++++++++++++++++++++++ docs/merge.md | 12 +++++++--- 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 59aa5286c..95750ba0b 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -12,9 +12,11 @@ //! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer //! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none //! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a -//! tiny weight so fully saturated pixels still get a value. -//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test -//! against the reference: the reference radiance (or, where the reference is clipped/too dark, +//! tiny weight so fully saturated pixels still get a value. Where the reference is at or near its clip, the scene is +//! at least that bright: a frame reading far darker there saw something that moved (a star, the moon, a light) and is +//! left out, so a clipped highlight of the reference is never replaced by content from another moment. +//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 2048 px version, a per-pixel consistency test +//! against the reference: the reference radiance (or, where the reference is clipped or black, //! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that //! disagree by more than a threshold (in log units, with a noise floor) are masked there. Masks //! are dilated and feathered (Gaussian), upsampled, and remove the frame's weight. Their union @@ -87,6 +89,10 @@ pub struct HdrResult { pub lens: Vec<lightcraft_raw::Opcode>, } +/// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few +/// pixels there. +const DEGHOST_EDGE: usize = 2048; + #[inline] fn smoothstep(a: f32, b: f32, x: f32) -> f32 { let t = ((x - a) / (b - a)).clamp(0.0, 1.0); @@ -161,15 +167,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) alignments[i] = a; } check(progress, 0.35, "Resampling")?; - let warped: Vec<(usize, Rgb32f, Vec<bool>)> = (0..n) - .into_par_iter() - .filter(|&i| i != reference && alignments[i].model != "identity") - .map(|i| { - let (img, v) = align::warp(&frames[i].image, &alignments[i].h); - (i, img, v) - }) - .collect(); - for (i, img, v) in warped { + // one frame at a time (the warp is parallel inside): each warped copy replaces its frame before the next is made + for i in (0..n).filter(|&i| i != reference && alignments[i].model != "identity") { + let (img, v) = align::warp(&frames[i].image, &alignments[i].h); frames[i].image = img; valid[i] = Some(v); } @@ -215,6 +215,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) let i_px = y * w + x; let mut acc = [0f64; 3]; let mut ws = 0f64; + // the reference at or near its clip: the radiance is at least this + let mr = maxc(fr[reference].image.data[i_px]); + let bound = if mr >= 0.80 * clips[reference] { mr / k[reference] } else { 0.0 }; for i in 0..n { if vd[i].as_ref().is_some_and(|v| !v[i_px]) { continue; @@ -227,6 +230,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) } if i == reference { wt += 1e-9; + } else if bound > 0.0 { + // a frame far darker than that saw something that moved there + wt *= smoothstep(0.35, 0.6, m / k[i] / bound); } if i != reference && let Some((maps, fac)) = gs @@ -362,35 +368,40 @@ fn deghost_maps( ) -> (Vec<Plane>, usize) { let n = frames.len(); let (w, h) = (frames[0].width(), frames[0].height()); - let fac = w.max(h).div_ceil(1024).max(1); + let fac = w.max(h).div_ceil(DEGHOST_EDGE).max(1); + // the dilation/feather radius was chosen for a 1024 px map: keep it the same share of the picture + let r = (r as f32 * (w.max(h) as f32 / fac as f32 / 1024.0).max(1.0)).round() as usize; let small: Vec<(Rgb32f, Vec<f32>)> = (0..n).into_par_iter().map(|i| shrink(&frames[i].image, valid[i].as_deref(), fac)).collect(); let (sw, sh) = (small[0].0.width, small[0].0.height); let rpos = order.iter().position(|&i| i == reference).unwrap_or(0); - let good = |i: usize, p: usize| -> bool { + // `dark`: the share of the clip below which a frame's (averaged) pixel is too noisy to predict from + let good = |i: usize, p: usize, dark: f32| -> bool { let f = &frames[i]; let m = maxc(small[i].0.data[p]); - let dark = if f.raw { 0.01 } else { 0.04 }; small[i].1[p] > 0.99 && m < 0.8 * f.clip && m > dark * f.clip }; + let dark = |i: usize| if frames[i].raw { 0.01 } else { 0.04 }; // predicted reference radiance per pixel let pred: Vec<[f32; 3]> = (0..sw * sh) .into_par_iter() .map(|p| { let rad = |i: usize| small[i].0.data[p].map(|v| v / k[i]); - if good(reference, p) { + // the reference predicts down to a tenth of the other frames' floor (the map's averaging tames its noise): in a + // dark sky the brighter frames must not predict themselves, or their ghosts would look consistent + if good(reference, p, dark(reference) * 0.1) { return rad(reference); } let bright = maxc(small[reference].0.data[p]) >= 0.8 * frames[reference].clip; if bright { for q in (0..rpos).rev() { - if good(order[q], p) { + if good(order[q], p, dark(order[q])) { return rad(order[q]); } } rad(order[0]) } else { for &i in &order[rpos + 1..] { - if good(i, p) { + if good(i, p, dark(i)) { return rad(i); } } diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index 68b9ba4b9..7368e56a3 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -161,3 +161,48 @@ fn deghost_removes_a_moving_object() { let mean = high.ghost.data.iter().sum::<f32>() / high.ghost.data.len() as f32; assert!(mean < 0.08, "the overlay is local (mean {mean})"); } + +/// A dark night sky (≈ 0.3 % of the reference's white) with a soft star of peak `peak` (Gaussian, σ = 3 px) at (cx, cy) +/// in truth coordinates. +fn night_with_star(cx: f32, cy: f32, peak: f32) -> Rgb32f { + Rgb32f::from_fn(W + 2 * M, H + 2 * M, |x, y| { + let sky = 0.003 * (1.0 + 0.3 * (x as f32 / (W + 2 * M) as f32)); + let d2 = (x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2); + [sky + peak * (-d2 / 18.0).exp(); 3] + }) +} + +/// Night brackets of a star that moves between the frames (the sky turns during a tripod bracket): its core clips in the +/// reference and in the brighter frame, not in the darkest one. +fn moving_star_frames(xs: [f32; 3]) -> Vec<Frame> { + let ks = [0.0625f32, 1.0, 8.0]; + (0..3).map(|i| shoot(&night_with_star(xs[i], 210.0 + M as f32, 10.0), ks[i], 0.0, 0.0, 30 + i as u64)).collect() +} + +#[test] +fn a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved() { + let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32]; + let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + assert_eq!(r.reference, 1); + // the reference saw the star clipped here, so the scene is at least that bright; the frames where the star had moved + // read the dark sky there and must not replace it (which left a black hole in real night brackets) + let core = r.radiance.get(320, 210); + let m = core[0].max(core[1]).max(core[2]); + assert!(m > 0.9, "the reference's clipped star core was erased: {core:?}"); +} + +#[test] +fn deghost_removes_a_bright_frames_ghost_in_a_dark_sky() { + let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32]; + let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::High }, &no_progress).unwrap(); + // where only the brightest frame had the star, the sky is what the reference saw: no ring of its unclipped halo + let mut worst = 0f32; + for y in 196..224 { + for x in 346..374 { + let p = r.radiance.get(x, y); + worst = worst.max(p[1]); + } + } + assert!(worst < 0.02, "the brightest frame's star is left as a ghost (peak {worst})"); + assert!(r.ghost.get(360, 210) > 0.5, "the overlay marks the ghost"); +} diff --git a/docs/merge.md b/docs/merge.md index 7c298cb10..537ab0f2c 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -25,8 +25,11 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max -channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise -floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. +channel))`. Where the reference is at or near its clip the scene is at least that bright, so a frame that reads far +darker there (an object moved: a star, the moon, a passing light) is left out and the clipped highlight stays (always +on: a moving star otherwise left a black hole). Deghost: a per-pixel consistency test on ≤ 2048 px versions against the +reference radiance (with a noise floor; the reference predicts down to 0.1 % of its clip, so a brighter frame's ghost in +a night sky is caught), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. Lens corrections: frames are merged uncorrected, and the DNG's `OpcodeList3` carries the reference frame's (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or a camera's correction a raw reader converts, e.g. Sony, Panasonic), re-expressed for its default crop (`Opcode::cropped`). So Optics ▸ Enable Lens @@ -52,7 +55,10 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) - HDR, 3 brackets ±2 EV with 14-bit noise and handheld shifts: alignment error < 0.3 px, exposure error < 0.02 EV, radiance median log error < 0.03 (noise-limited, bias < 0.01), recovered - highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High. + highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High; a star that moves + across a night sky keeps its clipped core and, with High, leaves no ghost of the brightest frame. +- Real brackets (Sony A7R VI, 67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % from 3 to 90 % of + their clip in all three channels; measured exposure steps match EXIF within 0.03 EV. - Panorama, 5 views over 182° with a 1.3× exposure drift: relative rotations < 0.1°, focal < 1 %, BA RMS ≈ 0.35 px; median log error vs ground truth < 0.02; no seam steps. From 6fb0d22326b3e1f0c4720cf6f809334760992d00 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Wed, 14 Oct 2026 12:56:59 +0200 Subject: [PATCH 136/315] LR-MERGE-HDR: decode full-size merge frames one at a time; AHD writes its tiles straight into the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 7-frame bracket of 67 MP raws ran out of memory: the merge decoded the frames four at a time, and the AHD demosaic collected every tile before copying them into a second full-size picture. Full-size frames now decode one after the other (the demosaic is parallel inside; previews stay parallel), and AHD fills the picture by rows of tiles. Peak memory of 67 MP HDR merges: 3 frames 4.8 → 3.8 GB, 4 frames 6.3 → 4.7 GB, about the same time; AHD output is pixel-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/merge.rs | 26 ++++++++++++-------------- crates/raw/src/demosaic/ahd.rs | 25 +++++++++++++------------ docs/merge.md | 3 +++ 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..01000f429 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -105,20 +105,18 @@ impl MergeJob { use rayon::prelude::*; let done = std::sync::atomic::AtomicUsize::new(0); let n = self.sources.len(); - let frames: std::result::Result<Vec<Frame>, String> = self - .sources - .par_iter() - .map(|(_, path)| { - let bytes = (self.read)(path)?; - // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) - let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) - .map_err(|e| format!("{path}: {e}")); - let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); - f - }) - .collect(); - frames + let one = |(_, path): &(PhotoId, String)| { + let bytes = (self.read)(path)?; + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); + let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); + f + }; + // Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at + // once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory. + if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() } } /// Run the merge. `progress(fraction, stage)` returns false to cancel. diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 3f17413ff..d97b56215 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -19,20 +19,21 @@ const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect(); - let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles - .par_iter() - .map(|&(x0, y0)| { - let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0)); - (x0, y0, tw, th, tile(m, x0, y0, tw, th)) - }) - .collect(); let mut out = Rgb32f::new(w, h); - for (x0, y0, tw, th, px) in results { - for y in 0..th { - out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); - } + if w == 0 || h == 0 { + return out; } + // one row of tiles per task, written straight into the picture (collecting the tiles first held a second copy) + out.data.par_chunks_mut(w * TILE).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w); + for x0 in (0..w).step_by(TILE) { + let tw = TILE.min(w - x0); + let px = tile(m, x0, y0, tw, th); + for y in 0..th { + band[y * w + x0..y * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); + } + } + }); out } diff --git a/docs/merge.md b/docs/merge.md index 537ab0f2c..c3d86fb8b 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -74,4 +74,7 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its caption also lists them. - Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- HDR memory: every frame is held as 32-bit float RGB (~0.86 GB per 67 MP frame at the peak, plus ~1.2 GB); full-size + photos are decoded one at a time. Measured on 67 MP raws: 3 frames 3.8 GB, 4 frames 4.7 GB, 5 frames 5.5 GB (was + 4.8 / 6.3 GB for 3 / 4). Half-float frames would halve the per-frame share. - The browser build has no file access for merges yet. From 0cf062f508685da7461315854812ac60fc3ffcc2 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 12:58:40 +0200 Subject: [PATCH 137/315] LR-PROF-BROWSER: long profile names are cut short in a narrow panel (fixes #1634) --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/profiles.rs | 22 +++---- crates/ui-egui/src/tests_profiles_layout.rs | 70 +++++++++++++++++++++ 3 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/tests_profiles_layout.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..783e4ce10 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -109,6 +109,8 @@ mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; #[cfg(test)] +mod tests_profiles_layout; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] mod tests_sam_setup; diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 6294d1e64..d6564d083 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -12,7 +12,7 @@ use super::presets::cover_uv; use crate::icons::{Icon, paint}; use crate::state::RightPanel; use crate::theme::Tokens; -use crate::widgets::{divider, icon_button, register}; +use crate::widgets::{divider, icon_button, one_line, register}; use crate::{HoverPreview, LightcraftApp}; /// Long edge of the variant thumbnails (px). @@ -35,13 +35,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim); - ui.painter().text( - pos2(cr.left() + 84.0, cr.center().y), - Align2::LEFT_CENTER, - crate::i18n::profile_label(&d.profile.id, name), - t.font(14.0), - t.text_label, - ); + // a long name is cut short (with …) at the panel's edge + let g = one_line(ui.painter(), crate::i18n::profile_label(&d.profile.id, name), t.font(14.0), t.text_label, cr.width() - 84.0 - 24.0); + ui.painter().galley(pos2(cr.left() + 84.0, cr.center().y - g.size().y / 2.0), g, t.text_label); if d.profile.id != "lc.color" { super::edit::control(app, ui, &d, "profile.amount", true); ui.add_space(6.0); @@ -174,13 +170,9 @@ fn cell_ui( painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); } let name_color = if applied { t.text } else { t.text_label }; - painter.text( - pos2(cell.left() + 2.0, thumb.bottom() + 12.0), - Align2::LEFT_CENTER, - crate::i18n::profile_label(p_id, p_name), - t.font(12.0), - name_color, - ); + // cut short (with …) to the cell, so it never runs into its neighbour; the full name on hover + let g = one_line(painter, crate::i18n::profile_label(p_id, p_name), t.font(12.0), name_color, cell.width() - 2.0); + painter.galley(pos2(cell.left() + 2.0, thumb.bottom() + 12.0 - g.size().y / 2.0), g, name_color); // favourite star (always shown when set, on hover otherwise) let fav = app.session.profile_favorites.iter().any(|f| f == p_id); let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); diff --git a/crates/ui-egui/src/tests_profiles_layout.rs b/crates/ui-egui/src/tests_profiles_layout.rs new file mode 100644 index 000000000..ccc521151 --- /dev/null +++ b/crates/ui-egui/src/tests_profiles_layout.rs @@ -0,0 +1,70 @@ +//! The profile browser in a narrow right panel: names stay inside their cells (#1634). + +use egui::{Rect, pos2, vec2}; +use serde_json::json; + +use crate::{LightcraftApp, Services, headless::HeadlessView}; + +const LONG_ID: &str = "lut:kodak-portra-400-plus-one-stop-overexposed"; +const LONG_NAME: &str = "Kodak Portra 400 Plus One Stop Overexposed"; + +fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::Shape::Text(text) => out.push(text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)), + _ => {} + } +} + +fn panel(width: f32) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: LONG_ID.into(), + name: LONG_NAME.into(), + group: "Film Stocks".into(), + file: String::new(), + }); + app.run("develop.profile", json!({"id": LONG_ID})).unwrap(); + let photo = app.session.active().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 4000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + crate::panels::profiles::show(&mut app, ui, photo); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_shapes(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + (texts, widgets) +} + +#[test] +fn profile_names_stay_inside_their_cells_in_a_narrow_panel() { + let width = 250.0; + let (texts, widgets) = panel(width); + let cell = widgets.iter().find(|(id, _)| id == &format!("profileCell:{LONG_ID}")).unwrap().1; + // the cell's name sits below its thumbnail; the header's copy is above every cell + let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME && t.pos.y >= cell.top()).unwrap(); + assert!(name.galley.elided, "a name wider than its cell is cut short"); + let bounds = name.visual_bounding_rect(); + assert!(bounds.left() >= cell.left() && bounds.right() <= cell.right(), "{bounds:?} spills out of {cell:?}"); + let applied = texts.iter().find(|t| t.galley.job.text == LONG_NAME && t.pos.y < cell.top()).unwrap(); + assert!(applied.visual_bounding_rect().right() <= width, "the applied profile's name leaves the panel"); + // built-in names too: each one stays out of its neighbour's cell + for p in lightcraft_engine::presets::PROFILES { + let cell = widgets.iter().find(|(id, _)| id == &format!("profileCell:{}", p.id)).unwrap().1; + let name = texts.iter().find(|t| t.galley.job.text == p.name && cell.contains(pos2(cell.center().x, t.pos.y))).unwrap(); + let bounds = name.visual_bounding_rect(); + assert!(bounds.left() >= cell.left() && bounds.right() <= cell.right(), "{}: {bounds:?} spills out of {cell:?}", p.name); + } +} From 971ad25b74e2198602acf7adb0c8ae80517d3655 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Wed, 14 Oct 2026 22:22:15 +1100 Subject: [PATCH 138/315] docs: leave upstream's remaining-hours figure unchanged (the estimate was ours) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2d073ad11..df475a653 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -69,7 +69,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | HDR, print, video output | 15% | ~35% | estimated | | **Non-raw file formats** | 100% | **~70%** | estimated; raw is in [raw-parity.md](raw-parity.md) (~50%) | -Remaining: **20–34 h** (AVIF decode 8–15 h if a permissive decoder is written or found; Render to DNG in the catalog 1–2 h; HLG 3–5 h; catalog import depth 8–12 h). +Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is written or found; PSD write 4–8 h; lossy DNG and Render to DNG 4–8 h; HLG 3–5 h; catalog import depth 8–12 h). ## Revision history From b52762797fcff3955938f32863acc87fa1066953 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Wed, 14 Oct 2026 13:37:13 +0200 Subject: [PATCH 139/315] LR-MERGE-HEADLESS: HDR for Each Bracket Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photo ▸ Photo Merge ▸ HDR for Each Bracket Set (`merge.hdrBatch`) merges many brackets in one go with the last HDR options, as Lightroom merges a selection of stacks: one HDR per stack among the photos, then per bracket among the unstacked ones (`Catalog::bracket_sets`: photos taken less than about a second after the previous one's shutter closed, at exposures at least ½ EV apart; bursts at one exposure and earlier merge results are left out). The app runs the merges one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); the command runs them inline for the CLI and MCP (`lightcraft-cli merge hdr-batch FILES…`) and reports merged and failed sets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/lightcraft-cli/src/main.rs | 10 +-- crates/catalog/src/stacks.rs | 94 ++++++++++++++++++++++++++++ crates/engine/src/cmd/merge.rs | 31 ++++++++- crates/engine/src/merge.rs | 4 ++ crates/engine/src/tests_merge.rs | 31 +++++++++ crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/merge.rs | 40 +++++++++++- crates/ui-egui/src/tests_activity.rs | 45 +++++++++++++ docs/merge.md | 9 ++- docs/parity-checklist.md | 7 ++- docs/whats-new.md | 9 +++ 20 files changed, 279 insertions(+), 11 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..1b2c4acb2 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -5,7 +5,7 @@ //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] //! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] -//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… +//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… //! lightcraft-cli synth-merge hdr|panorama -o DIR //! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu] //! lightcraft-cli commands [--json] @@ -79,9 +79,10 @@ USAGE: -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) - lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… + lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and - prints the result as JSON. Options: + prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken + less than a second apart at different exposures), e.g. a whole folder. Options: --deghost none|low|medium|high --no-align --bracket N (HDR panorama) --projection auto|spherical|cylindrical|perspective --boundary-warp 0..100 --auto-crop --fill-edges --no-auto-settings @@ -373,7 +374,8 @@ fn merge(args: &[String]) -> Result<(), String> { Some("hdr") => "merge.hdr", Some("panorama" | "pano") => "merge.panorama", Some("hdr-panorama") => "merge.hdrPanorama", - _ => return Err("merge: expected hdr, panorama or hdr-panorama".into()), + Some("hdr-batch") => "merge.hdrBatch", + _ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()), }; let mut p = serde_json::Map::new(); let mut files = Vec::new(); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 6ffb11f52..1c6063256 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -231,6 +231,60 @@ impl Catalog { } } +impl Catalog { + /// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge + /// results), then the unstacked ones in capture order, a set running while each starts within about a second of the + /// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at + /// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out. + pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec<Vec<PhotoId>> { + let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some(); + let mut sets: Vec<Vec<PhotoId>> = Vec::new(); + let mut stacks = HashSet::new(); + let mut loose = Vec::new(); + for id in ids { + match self.stack_of(*id) { + Some(s) if stacks.insert(s.id) => { + let set: Vec<PhotoId> = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect(); + if set.len() >= 2 { + sets.push(set); + } + } + Some(_) => {} + None => loose.push(*id), + } + } + // (capture second, file name, id, shutter seconds, EV) + let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !merged(p)) + .filter_map(|p| { + let t = iso_seconds(p.captured.as_deref()?)?; + let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?; + let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64; + let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64; + Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2())) + }) + .collect(); + timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2))); + timed.dedup_by_key(|x| x.2); + let mut runs: Vec<Vec<(i64, &str, PhotoId, f64, f64)>> = Vec::new(); + for x in timed { + match runs.last_mut() { + Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x), + _ => runs.push(vec![x]), + } + } + for r in runs { + let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4))); + if r.len() >= 2 && hi - lo >= 0.5 { + sets.push(r.iter().map(|x| x.2).collect()); + } + } + sets + } +} + /// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, /// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. pub fn iso_seconds(s: &str) -> Option<i64> { @@ -358,6 +412,46 @@ mod tests { assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); } + #[test] + fn bracket_sets_by_time_and_exposure() { + // (time, shutter, ISO, file name) + let shots = [ + ("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket + ("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"), + ("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"), + ("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it + ("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket + ("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"), + ("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure + ("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"), + ("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart + ("2026-06-27T23:35:01", "2", 100, "N2.ARW"), + ("2026-06-27T23:35:04", "2", 800, "N3.ARW"), + ("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times + ("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"), + ]; + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for (t, sh, iso, name) in shots { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(t.into()); + p.meta.shutter = sh.into(); + p.meta.iso = Some(iso); + p.meta.aperture = Some(5.6); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let op = c.group_ops(ids[11], &[ids[12]], true).unwrap(); + c.apply(op).unwrap(); + let sets = c.bracket_sets(&ids); + assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]); + // a stack's merge result on top is left out; a stack selected twice is merged once + let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]); + } + #[test] fn auto_stack_by_capture_time() { let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..cb9904244 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -1,4 +1,4 @@ -//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`. +//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`. //! //! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with //! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames @@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> { Ok(info) } +/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]), +/// one after the other. A set that fails is reported and the others still run. +fn run_batch(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "merge.hdrBatch"; + let (kind, finish) = crate::merge::parse("merge.hdr", p)?; + let ids: Vec<PhotoId> = s.targets(p); + let sets = s.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(bad(ID, crate::merge::NO_BRACKET_SETS)); + } + let (mut merged, mut failed) = (Vec::new(), Vec::new()); + for set in sets { + match s.merge_now(kind.clone(), finish.clone(), &set) { + Ok(v) => merged.push(v), + Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::<Vec<_>>(), "error": e.to_string()})), + } + } + Ok(json!({"merged": merged, "failed": failed})) +} + pub fn specs() -> Vec<CommandSpec> { vec![ cmd!( @@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> { can_merge, |s, p| run(s, "merge.hdrPanorama", p) ), + cmd!( + "merge.hdrBatch", + "HDR for Each Bracket Set", + ["Photo", "Photo Merge"], + None, + "{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}", + can_merge, + run_batch + ), ] } diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 01000f429..fe182cebc 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -20,6 +20,10 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +/// Why `merge.hdrBatch` found nothing to merge. +pub const NO_BRACKET_SETS: &str = + "no bracket sets among these photos: select stacks, or photos taken less than a second apart at different exposures"; + /// Long edge of the frames of a preview merge. pub const PREVIEW_EDGE: usize = 1024; diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index cb42dbc2a..366e72476 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -168,3 +168,34 @@ fn panorama_command_with_auto_crop() { assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// HDR for Each Bracket Set: two stacks of brackets → two merged DNGs, each next to its first photo; photos that form +/// no set are refused. +#[test] +fn hdr_batch_merges_each_stack() { + let dir = temp_dir("hdr-batch"); + let mut paths = Vec::new(); + // different exposures, or the import would take set B for duplicates of set A + for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5])] { + for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &evs).unwrap().into_iter().enumerate() { + let p = dir.join(format!("{set}_{i}.dng")); + std::fs::write(&p, b).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + assert_eq!(ids.len(), 6); + let err = s.execute("merge.hdrBatch", &json!({"ids": [ids[0]]})).unwrap_err().to_string(); + assert!(err.contains("no bracket sets"), "{err}"); + s.execute("stack.group", &json!({"ids": ids[..3]})).unwrap(); + s.execute("stack.group", &json!({"ids": ids[3..]})).unwrap(); + let r = s.execute("merge.hdrBatch", &json!({"ids": [ids[0], ids[3]], "autoSettings": false})).unwrap(); + let merged = r["merged"].as_array().unwrap(); + assert_eq!(merged.len(), 2, "{r}"); + assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); + let names: Vec<&str> = merged.iter().map(|m| m["path"].as_str().unwrap()).collect(); + assert!(names[0].ends_with("A_0-HDR.dng") && names[1].ends_with("B_0-HDR.dng"), "{names:?}"); + assert_eq!(s.catalog.photos().count(), 8); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..a595ba580 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -636,6 +636,7 @@ "HDR panorama": "HDR-Panorama", "HDR panoramas": "HDR-Panoramen", "HDR with Last Settings": "HDR mit letzten Einstellungen", + "HDR for Each Bracket Set": "HDR für jede Belichtungsreihe", "HDR…": "HDR…", "Has Edits": "Enthält Bearbeitungen", "Has GPS": "Enthält GPS", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..a8970e651 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -619,6 +619,7 @@ "HDR panorama": "Panorámica HDR", "HDR panoramas": "panorámicas HDR", "HDR with Last Settings": "HDR con los últimos ajustes", + "HDR for Each Bracket Set": "HDR para cada horquillado", "HDR…": "HDR…", "Has Edits": "Con ediciones", "Has GPS": "Con GPS", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..c6efb0e92 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -600,6 +600,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "panoramas HDR", "HDR with Last Settings": "HDR avec les derniers réglages", + "HDR for Each Bracket Set": "HDR pour chaque bracketing", "HDR…": "HDR…", "Has Edits": "Avec retouches", "Has GPS": "Avec GPS", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..e0f04f153 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -506,6 +506,7 @@ "HDR panorama": "HDRパノラマ", "HDR panoramas": "HDRパノラマ", "HDR with Last Settings": "前回の設定でHDR合成", + "HDR for Each Bracket Set": "ブラケットごとにHDR合成", "Has Edits": "編集", "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..775e56eba 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -609,6 +609,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "panoramas HDR", "HDR with Last Settings": "HDR com os últimos ajustes", + "HDR for Each Bracket Set": "HDR para cada bracketing", "HDR…": "HDR…", "Has Edits": "Tem edições", "Has GPS": "Tem GPS", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..7dde476e8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -501,6 +501,7 @@ "HDR Panorama with Last Settings": "HDR-панорама с последними настройками", "HDR Panorama…": "HDR-панорама…", "HDR with Last Settings": "HDR с последними настройками", + "HDR for Each Bracket Set": "HDR для каждого брекетинга", "HDR…": "HDR…", "Heal": "Лечение", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..58d3dd001 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -747,6 +747,7 @@ "HDR panorama": "HDR-панорама", "HDR panoramas": "HDR-панорами", "HDR with Last Settings": "HDR з останніми налаштуваннями", + "HDR for Each Bracket Set": "HDR для кожної серії експозицій", "Has Edits": "Має зміни", "Has GPS": "Має GPS", "High Contrast B&W": "Контрастне чорно-біле", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..9c026269b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -738,6 +738,7 @@ "HDR Panorama with Last Settings": "使用上次设置合并 HDR 全景", "HDR Panorama…": "HDR 全景…", "HDR with Last Settings": "使用上次设置合并 HDR", + "HDR for Each Bracket Set": "为每组包围曝光合并 HDR", "HDR…": "HDR…", "Heal": "修复", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..364483649 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -738,6 +738,7 @@ "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景", "HDR Panorama…": "HDR 全景…", "HDR with Last Settings": "使用上次設定合併 HDR", + "HDR for Each Bracket Set": "為每組包圍曝光合併 HDR", "HDR…": "HDR…", "Heal": "修復", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..802d9cb49 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1238,6 +1238,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), + "merge.hdrBatch" => crate::merge::start_batch(app, p), "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..4374ac591 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -110,6 +110,8 @@ pub struct MergeState { pub ids: Vec<PhotoId>, /// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings. pub last: std::collections::HashMap<String, MergeDialog>, + /// HDR for Each Bracket Set: the sets still to merge after the running one. + pub batch: std::collections::VecDeque<Vec<PhotoId>>, } impl MergeState { @@ -194,6 +196,31 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin start_final(app, &opts) } +/// HDR for Each Bracket Set (`merge.hdrBatch`): merge each bracket set among the photos in the background, one after +/// the other, with the options last used for HDR (the defaults the first time). +pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { + if app.merge.final_task.is_some() { + return Err("a merge is already running".into()); + } + let ids = app.session.targets(p); + let sets = app.session.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(lightcraft_engine::merge::NO_BRACKET_SETS.into()); + } + let n = sets.len(); + app.merge.batch = sets.into(); + next_in_batch(app)?; + Ok(json!({"started": true, "sets": n})) +} + +/// Start the next bracket set of a batch, if any. +fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> { + let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) }; + app.merge.ids = ids; + let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr")); + start_final(app, &opts).map(|_| ()) +} + /// Per frame: keep the preview in sync with the dialog's options, collect finished jobs. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { // preview for the open dialog @@ -272,12 +299,23 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { app.merge.last_result = Some(v); app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what)); } - Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), + Err(_) if t.cancel.load(Ordering::Relaxed) => { + // cancelling one merge of a batch stops the batch + app.merge.batch.clear(); + app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)) + } Err(e) => { app.ui.status = e.to_string(); app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); } } + // a failed set doesn't stop the others + while !app.merge.batch.is_empty() { + match next_in_batch(app) { + Ok(()) => break, + Err(e) => app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)), + } + } } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 34c7f7491..493998fd3 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -360,6 +360,51 @@ fn final_merge_shows_a_row_and_cancels() { let _ = std::fs::remove_dir_all(dir); } +/// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the +/// rest. +#[test] +fn batch_merge_runs_each_set_in_turn() { + let dir = std::env::temp_dir().join(format!("lc-activity-batch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + // different exposures, or the import would take set B for duplicates of set A + for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5]), ("C", [-1.0, 1.0, 3.0])] { + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap().into_iter().enumerate() { + let p = dir.join(format!("{set}_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 9); + for set in ids.chunks(3) { + session.execute("stack.group", &json!({"ids": set})).unwrap(); + } + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(T); + let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap(); + assert_eq!(r["sets"], 3); + assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued"); + let rows = h.app.session.activity.list(); + assert_eq!((rows.len(), rows.first().map(|r| r.kind)), (1, Some("merge"))); + // the first merge finishes and the second starts by itself + assert!(h.step_until(T, |h| h.app.merge.batch.len() == 1 && h.app.merge.final_task.is_some())); + assert_eq!(h.app.session.catalog.len(), 10); + // cancelling the second drops the third + let rows = h.app.session.activity.list(); + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + assert!(h.app.merge.batch.is_empty()); + assert_eq!(h.app.session.catalog.len(), 10, "nothing more merged"); + assert!(h.app.session.activity.list().is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn find_missing_shows_an_indeterminate_row() { let mut h = demo(); diff --git a/docs/merge.md b/docs/merge.md index c3d86fb8b..8a2622aba 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -10,12 +10,17 @@ All algorithms are our own implementations from published papers (cited in the m *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as - `<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. + `<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. *Photo Merge ▸ HDR for Each Bracket Set* + merges many brackets in one go with the last HDR options: one HDR per stack among the selected photos, then per + bracket (photos taken less than a second apart, after the shutter time, at exposures at least ½ EV apart; a burst at + one exposure is skipped), one after the other in the activity stack. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` -- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; + - `merge.hdrBatch {ids?, align, deghost, autoSettings, stack}` → `{merged: [{id, path, …}], failed: [{photos, error}]}` +- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [options] FILES…` (`hdr-batch` finds the brackets + among the files, e.g. a whole folder); `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). ## What it does diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..2dfc22064 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -350,7 +350,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | -| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | ## P. Enhance (ENH) @@ -556,7 +556,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | | MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt | | MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | | -| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast` | | +| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast`, `cmd:merge.hdrBatch` | | | MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | | | MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | | | MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`): one HDR per stack or bracket among the photos, one after the other | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0ce5f4613 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,15 @@ ## 2026-10-14 +### HDR merges of moving lights, and many brackets at once +- A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer + leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a + dark sky is now removed too. +- Photo ▸ Photo Merge ▸ HDR for Each Bracket Set merges every bracket among the selected photos with the last HDR + settings, one after the other: each stack, and photos taken less than a second apart at different exposures. + From the command line: `lightcraft-cli merge hdr-batch FOLDER`. +- Merging uses about a quarter less memory: three 67 MP raws need 3.8 GB instead of 4.8 GB. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 857ffcd41c1994faf2c090c863e691a88754d647 Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Wed, 14 Oct 2026 13:37:20 +0200 Subject: [PATCH 140/315] =?UTF-8?q?LR-MERGE-HDR:=20=F0=9F=9F=A1=20after=20?= =?UTF-8?q?testing=20on=20real=20brackets;=20gap=2029?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tested on real Sony A7R VI brackets (67 MP): the merged radiance agrees with every frame within ~1 % in all three channels and the measured exposure steps match EXIF within 0.03 EV, and the black-hole stars and the merge memory are fixed. What keeps the row 🟡 goes to gap 29: AI Denoise doesn't run on merged (linear) DNGs as Lightroom's does, JPEG brackets have no camera response curve, and every frame is held as 32-bit float (~0.86 GB per 67 MP frame). Merge area 100 % → 88 % in target-app-parity.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/gaps.md | 6 ++++-- docs/merge.md | 1 + docs/parity-checklist.md | 12 ++++++------ docs/target-app-parity.md | 5 +++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index ee6e58d1c..11125a67c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** patch (gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 29 added: HDR merge on real brackets — AI Denoise on merged DNGs, JPEG response curves, per-frame memory) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -42,6 +42,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 25 | **File writes**: lossy DNG; AVIF import; Render to DNG as a catalog command (the export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [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) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | +| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, @@ -49,7 +50,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Gaps by kind -- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19 +- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19, 29 - **UI / UX:** 4, 23, 24, 28 - **File format:** 2, 6, 7, 10, 13, 20, 25 (raw ones in [raw-parity.md](raw-parity.md)) - **Hardware:** 14, 17, 22, 27 @@ -72,6 +73,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain | | 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice | | 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 | diff --git a/docs/merge.md b/docs/merge.md index 8a2622aba..3485e02d1 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -74,6 +74,7 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) panoramas carry no lens corrections, nor does an HDR whose frames don't share an orientation (they are turned before merging). - No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light). +- AI Denoise doesn't run on merged DNGs (they are linear RGB; the denoiser takes Bayer raws), unlike Lightroom's. - Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion, not patch synthesis. - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2dfc22064..67ab83c53 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets: moving clipped highlights fixed, gaps remain; LR-MERGE-HEADLESS: HDR for Each Bracket Set) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -40,7 +40,7 @@ cited here still exists. Formerly docs/parity.md. | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | -| O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — | +| O. Merge (MERGE) | 3 | 1 | 0 | 0 | — | — | | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | +| **Total** | 408 | 54 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 49.7% of 173. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -347,7 +347,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | @@ -790,7 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`): one HDR per stack or bracket among the photos, one after the other | +| 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba62abb40..21e732c98 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (merge 100% → 88%: LR-MERGE-HDR 🟡 after testing on real brackets) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -75,7 +75,7 @@ from the checklist sections and the deep-dive documents. | Remove, heal, Enhance (denoise, Raw Details, Super Resolution, Lens Blur) | 7% | 45% | 30% | 70–130 h | checklist I 75%, P 0% | | Presets, profiles, versions, history, sync | 5% | 95% | 85% | 8–15 h | | | Export | 6% | 89% | 75% | 15–30 h | checklist S 88.9% | -| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 100%, HDR 70% (no HDR display) | +| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 88% (HDR merge 🟡: AI Denoise on merged DNGs, JPEG response, memory; gap 29), HDR 70% (no HDR display) | | Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap | | Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ | | Video | 1% | 0% | 0% | 25–45 h | catalogued only | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | From 73b9cc3708d822bfe2d09dc1f2b2281453f13bb9 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Tue, 13 Oct 2026 19:30:39 -0400 Subject: [PATCH 141/315] LR-LIB-SMARTALBUM: a view can hold its photos until it is refreshed A view is asked again after every edit, so a photo that an edit makes stop matching it leaves the grid at once and every photo after it moves up: a keyword given in a smart album of photos without keywords takes the photo away from under the cursor, and in a random order, with no landmarks, the shift reads as a new shuffle (the seed does not change). View > Hold View Until Refreshed (`library.holdView`, off by default, kept with the library's preferences): a photo the view shows stays in it when an edit makes it stop matching a smart album's rules, Picks or the filter bar. View > Refresh View (`library.refresh`, also in the sort menu while the hold is on), Reshuffle, another source, filter or sort, or new rules for the smart album shown, shows what matches now. - Photos that start matching still appear at once. - A photo that is deleted, or taken out of the album or folder shown, still leaves at once: a held photo has to be where the view looks (`Catalog::query_keeping`, `within`). - A source with no filter over it holds nothing and is queried as before. - A refresh keeps the seed. - The grid's count no longer reads "3 of 2" when a held view shows more than now match its source. Each edit still asks the whole library again; that cost is not addressed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/catalog/src/query.rs | 15 +- crates/engine/src/cmd/library.rs | 38 +++++ crates/engine/src/lib.rs | 82 ++++++++-- crates/engine/src/library.rs | 5 + crates/engine/src/tests_hold_view.rs | 200 +++++++++++++++++++++++++ crates/engine/src/tests_library.rs | 24 +++ crates/ui-egui/locales/de.json | 2 + crates/ui-egui/locales/es.json | 2 + crates/ui-egui/locales/fr.json | 2 + crates/ui-egui/locales/ja.json | 2 + crates/ui-egui/locales/pt-br.json | 2 + crates/ui-egui/locales/ru.json | 2 + crates/ui-egui/locales/uk.json | 2 + crates/ui-egui/locales/zh-hans.json | 2 + crates/ui-egui/locales/zh-hant.json | 2 + crates/ui-egui/src/menubar.rs | 14 ++ crates/ui-egui/src/panels/bottombar.rs | 8 + crates/ui-egui/src/panels/chips.rs | 3 +- docs/parity-checklist.md | 5 +- docs/whats-new.md | 9 ++ 20 files changed, 405 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/tests_hold_view.rs diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9956703d..a9aa66b7f 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -369,8 +369,21 @@ pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by file name, then id). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> { + self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter) + } + + /// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort` + /// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart + /// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library, + /// the album, the folder; not deleted). + pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> { let root = filter.library_root(); - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); + let within_root = within.library_root(); + let mut v: Vec<&Photo> = self + .photos() + .map(|p| p.as_ref()) + .filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref()))) + .collect(); v.sort_by(|a, b| { let o = match sort.key { SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..910091648 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -510,6 +510,44 @@ pub fn specs() -> Vec<CommandSpec> { ok() } ), + cmd!( + "library.refresh", + "Refresh View", + ["View"], + None, + "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", + always, + |s, _| { + s.refresh_view(); + ok() + } + ), + cmd!( + "library.holdView", + "Hold View Until Refreshed", + ["View"], + None, + "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", + always, + |s, p| { + let on = match p.get("on") { + None | Some(Value::Null) => !s.hold_view, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad("library.holdView", "on must be true or false")), + }; + if on != s.hold_view { + let was = s.hold_view; + s.hold_view = on; + if let Err(e) = s.save_prefs() { + // not kept: not changed either, so asking again does what was asked + s.hold_view = was; + return Err(e); + } + s.refresh_view(); + } + Ok(json!({"hold": s.hold_view})) + } + ), cmd!( "library.shuffle", "Reshuffle", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..c1f1c0931 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -190,6 +190,13 @@ pub struct Session { /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, + /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop + /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. + pub hold_view: bool, + /// The view that is held and the photos in it, before stacks are arranged ([`Session::hold_view`]). Another + /// source, filter or sort is another view, and starts from what matches; so does a smart album whose rules + /// were changed. `None` for a view that nothing can stop matching (a source with no filter over it). + held: Option<(String, Vec<PhotoId>)>, /// Identifies the current `visible` list: a new value (unique in the process) every time it /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, @@ -345,6 +352,8 @@ impl Session { selection: Selection::default(), visible: Vec::new().into(), visible_key: None, + hold_view: false, + held: None, visible_gen: 0, total: None, undo: Vec::new(), @@ -853,6 +862,16 @@ impl Session { pub fn visible(&mut self) -> &[PhotoId] { let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); + // What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were + // changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are + // no other view. + let asks = matches!(self.source, LibrarySource::Picks) + || matches!(self.source, LibrarySource::Album(a) if self.catalog.album(a).is_some_and(|al| al.is_smart())); + let rules = match self.source { + LibrarySource::Album(a) if asks => self.catalog.album(a).map(|al| format!("{:?}", al.smart)).unwrap_or_default(), + _ => String::new(), + }; + let view = format!("{}|{rules}", key.1); if self.source == LibrarySource::Missing && self.media.availability.is_background() { // the view fills in as the background checks find files gone key.1.push_str(&format!("|{}", self.media.availability.generation())); @@ -860,18 +879,33 @@ impl Session { if self.visible_key.as_ref() != Some(&key) { // "in the last N days" rules count back from the session's clock lightcraft_catalog::rules::set_now(Some((self.clock)())); - let mut f = self.source.to_filter(&self.filter, &self.catalog); - if self.source == LibrarySource::Folder { - // no folder chosen: nothing (an empty path matches nothing) - let b = self.browse.clone().unwrap_or_default(); - f.folder = Some(b.path); - f.subfolders = b.subfolders; - } - if self.source == LibrarySource::LibraryFolder { - // no folder chosen: nothing (`.` names no folder) - f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); - } - let mut visible = self.catalog.query(&f, &self.sort); + let f = self.view_filter(self.source, &self.filter); + // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's + // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for + // a view that is only a question, else in the album or folder shown. Taking a photo out of an album + // or the library takes it out of the view at once, held or not. + // (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no + // cost for the hold) + let holds = self.hold_view && (asks || self.filter != Filter::default()); + let held = self.held.as_ref().filter(|(held, _)| holds && *held == view).map(|(_, ids)| ids); + let mut visible = match held { + Some(ids) => { + let keep: std::collections::HashSet<PhotoId> = ids.iter().copied().collect(); + let within = if asks { + self.view_filter(LibrarySource::All, &Filter::default()) + } else if self.source == LibrarySource::RecentlyAdded { + // (how far back it reaches was worked out for `f`: not a second pass over the library) + Filter { imported_from: f.imported_from.clone(), ..Default::default() } + } else { + self.view_filter(self.source, &Filter::default()) + }; + self.catalog.query_keeping(&f, &self.sort, &keep, &within) + } + None => self.catalog.query(&f, &self.sort), + }; + // what the view shows now is what it holds until it is refreshed (taken before stacks are arranged, so + // a photo folded into a stack is held with it) + self.held = holds.then(|| (view, visible.clone())); if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { // the shown folder lost its last photo (deleted, moved, removed): everything, not // an empty grid under the name of a folder that is gone from the sidebar @@ -921,6 +955,28 @@ impl Session { &self.visible } + /// `source` as a filter over `base` (the filter bar's, or none), with the folder it shows. + fn view_filter(&self, source: LibrarySource, base: &Filter) -> Filter { + let mut f = source.to_filter(base, &self.catalog); + if source == LibrarySource::Folder { + // no folder chosen: nothing (an empty path matches nothing) + let b = self.browse.clone().unwrap_or_default(); + f.folder = Some(b.path); + f.subfolders = b.subfolders; + } + if source == LibrarySource::LibraryFolder { + // no folder chosen: nothing (`.` names no folder) + f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); + } + f + } + + /// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]). + pub fn refresh_view(&mut self) { + self.held = None; + self.visible_key = None; + } + /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source /// shows. fn folder_holds_photos(&self) -> bool { @@ -1022,6 +1078,8 @@ mod tests_forget_local; #[cfg(test)] mod tests_history_cursor; #[cfg(test)] +mod tests_hold_view; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 0305623f2..be6fab1dd 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -232,6 +232,8 @@ struct PrefsFile { /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option<u32>, + /// A view holds the photos it shows until it is refreshed (`library.holdView`). + hold_view: bool, } fn presets_json(s: &Session) -> String { @@ -455,6 +457,8 @@ impl Session { self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); + self.hold_view = prefs.hold_view; + self.refresh_view(); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); @@ -699,6 +703,7 @@ impl Session { cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), + hold_view: self.hold_view, }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests_hold_view.rs new file mode 100644 index 000000000..3bb998b8c --- /dev/null +++ b/crates/engine/src/tests_hold_view.rs @@ -0,0 +1,200 @@ +//! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop +//! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are. + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::{LibrarySource, Session}; + +/// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random. +fn unkeyworded() -> Session { + let mut s = Session::with_demo(); + let all: Vec<u64> = s.visible_cloned().iter().map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": all, "keywords": []})).unwrap(); + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}}); + let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + s +} + +fn hold(s: &mut Session) { + assert_eq!(s.execute("library.holdView", &json!({"on": true})).unwrap()["hold"], true); +} + +fn keyword(s: &mut Session, id: PhotoId) { + s.execute("photo.setMeta", &json!({"ids": [id.0], "addKeywords": ["travel"]})).unwrap(); +} + +#[test] +fn a_keyword_given_in_a_held_view_leaves_the_photo_and_the_order_where_they_are() { + // Given a smart album of the photos without keywords, at random, held + let mut s = unkeyworded(); + hold(&mut s); + let (before, seed) = (s.visible_cloned(), s.sort.seed); + assert!(before.len() > 10); + // When photos in it are given a keyword, one after the other + for at in [3, 7, 0] { + keyword(&mut s, before[at]); + // Then nothing moves: the same photos in the same places, under the same shuffle + assert_eq!(s.visible_cloned(), before, "after a keyword on the photo at {at}"); + assert_eq!(s.sort.seed, seed); + } + // When the view is refreshed, Then they are gone and the others keep their order + s.execute("library.refresh", &json!({})).unwrap(); + let after = s.visible_cloned(); + let expected: Vec<PhotoId> = before.iter().copied().filter(|id| ![before[3], before[7], before[0]].contains(id)).collect(); + assert_eq!(after, expected); + assert_eq!(s.sort.seed, seed, "a refresh is not a reshuffle"); +} + +#[test] +fn a_view_that_is_not_held_follows_every_edit_as_before() { + let mut s = unkeyworded(); + assert!(!s.hold_view, "off unless asked for"); + let before = s.visible_cloned(); + keyword(&mut s, before[3]); + let after = s.visible_cloned(); + assert!(!after.contains(&before[3]), "the photo leaves at once"); + assert_eq!(after, before.iter().copied().filter(|id| *id != before[3]).collect::<Vec<_>>(), "the others keep their order"); +} + +#[test] +fn a_held_view_still_takes_in_what_starts_matching_and_lets_go_of_what_is_deleted() { + let mut s = unkeyworded(); + let all = s.visible_cloned(); + // one photo has a keyword before the view is held: it is not in it + keyword(&mut s, all[5]); + hold(&mut s); + let before = s.visible_cloned(); + assert!(!before.contains(&all[5])); + // its keyword taken off again: it matches, and comes in at its place in the shuffle + s.execute("photo.setMeta", &json!({"ids": [all[5].0], "keywords": []})).unwrap(); + assert_eq!(s.visible_cloned(), all, "back where the shuffle puts it"); + // a photo that was kept after it stopped matching, then deleted, is not kept in the view + keyword(&mut s, all[2]); + assert!(s.visible_cloned().contains(&all[2])); + s.execute("library.select", &json!({"ids": [all[2].0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(!s.visible_cloned().contains(&all[2]), "Recently Deleted is somewhere else"); +} + +#[test] +fn another_source_filter_sort_or_shuffle_is_another_view() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + // Reshuffle: a new order of what matches now + keyword(&mut s, before[1]); + assert!(s.visible_cloned().contains(&before[1])); + s.execute("library.shuffle", &json!({})).unwrap(); + assert!(!s.visible_cloned().contains(&before[1]), "a reshuffle refreshes"); + // another sort + let now = s.visible_cloned(); + keyword(&mut s, now[1]); + s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); + assert!(!s.visible_cloned().contains(&now[1]), "another sort refreshes"); + // the filter bar + let now = s.visible_cloned(); + keyword(&mut s, now[1]); + s.execute("library.filter", &json!({"rating": 0, "text": "a"})).unwrap(); + assert!(!s.visible_cloned().contains(&now[1]), "another filter refreshes"); + s.execute("library.filter", &json!({"text": ""})).unwrap(); + // away and back + let now = s.visible_cloned(); + let source = s.source; + keyword(&mut s, now[1]); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + let _ = s.visible_cloned(); + s.source = source; + assert!(!s.visible_cloned().contains(&now[1]), "coming back to it refreshes"); +} + +#[test] +fn picks_and_the_filter_bar_hold_too_and_an_album_lets_go_of_what_is_taken_out_of_it() { + let mut s = Session::with_demo(); + let all = s.visible_cloned(); + // Picks + s.execute("library.select", &json!({"ids": [all[0].0, all[1].0, all[2].0]})).unwrap(); + s.execute("photo.flag", &json!({"flag": "pick"})).unwrap(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + hold(&mut s); + let picks = s.visible_cloned(); + assert!(picks.len() >= 3, "{picks:?}"); + s.execute("library.select", &json!({"ids": [picks[1].0]})).unwrap(); + s.execute("photo.flag", &json!({"flag": "none"})).unwrap(); + assert_eq!(s.visible_cloned(), picks, "a pick unflagged stays until the view is refreshed"); + s.execute("library.refresh", &json!({})).unwrap(); + assert_eq!(s.visible_cloned(), picks.iter().copied().filter(|id| *id != picks[1]).collect::<Vec<_>>()); + // the filter bar over an album: a rating under the filter's stays, a photo taken out of the album goes + let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec<u64> = all.iter().take(5).map(|p| p.0).collect(); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.filter", &json!({"rating": 3})).unwrap(); + let shown = s.visible_cloned(); + assert_eq!(shown.len(), 5); + s.execute("library.select", &json!({"ids": [shown[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 1})).unwrap(); + assert_eq!(s.visible_cloned().len(), 5, "rated under the filter: held"); + s.execute("album.removePhotos", &json!({"id": album, "ids": [shown[0].0, shown[1].0]})).unwrap(); + let left = s.visible_cloned(); + assert!(!left.contains(&shown[0]) && !left.contains(&shown[1]), "out of the album is out of the view: {left:?}"); + assert_eq!(left.len(), 3); + assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(album))); +} + +#[test] +fn turning_the_hold_off_lets_go_at_once() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + keyword(&mut s, before[4]); + assert!(s.visible_cloned().contains(&before[4])); + assert_eq!(s.execute("library.holdView", &json!({})).unwrap()["hold"], false, "toggles without `on`"); + assert!(!s.visible_cloned().contains(&before[4])); +} + +#[test] +fn changing_the_rules_of_the_smart_album_shown_is_another_view() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + let LibrarySource::Album(album) = s.source else { panic!("a smart album is shown") }; + // the album now asks for photos with a keyword: none has one, and none is kept from what it asked before + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "gt", "value": 0}]}}); + s.execute("album.setRules", &json!({"id": album.0, "rules": rules})).unwrap(); + assert!(s.visible_cloned().is_empty(), "the old rules' photos are not held under the new ones"); + // and undoing the change brings the first question back, with what matches it + s.undo_step().unwrap(); + assert_eq!(s.visible_cloned(), before); +} + +#[test] +fn a_source_with_no_filter_over_it_holds_nothing() { + // All Photos, an album or a folder with no filter: nothing an edit does can make a photo stop matching, so + // nothing is kept (and nothing is paid for the hold) + let mut s = Session::with_demo(); + hold(&mut s); + let all = s.visible_cloned(); + keyword(&mut s, all[0]); + assert_eq!(s.visible_cloned(), all); + assert!(s.held.is_none()); + s.execute("library.filter", &json!({"rating": 1})).unwrap(); + let _ = s.visible_cloned(); + assert!(s.held.is_some(), "a filter is a question: its view is held"); +} + +#[test] +fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + keyword(&mut s, before[2]); + hold(&mut s); + assert!(s.visible_cloned().contains(&before[2]), "on, asked for again: the held photos stay"); + assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err()); + assert!(s.hold_view, "a refused call changes nothing"); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..131049d13 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -675,3 +675,27 @@ fn an_album_filter_loop_is_refused() { assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused"); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None); } + +#[test] +fn the_hold_of_the_view_is_kept_with_the_library_and_holds_nothing_over_from_another() { + let dir = temp_dir("holdview"); + let mut s = open(&dir, true); + assert!(!s.hold_view, "off in a library that never asked for it"); + s.execute("library.holdView", &json!({"on": true})).unwrap(); + // a filter over the demo photos, and one of them edited out from under it: held + s.execute("library.filter", &json!({"rating": 0, "flag": "none"})).unwrap(); + let shown = s.visible_cloned(); + let id = *shown.first().expect("the seeded library has unflagged photos"); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.flag", &json!({"flag": "pick"})).unwrap(); + assert!(s.visible_cloned().contains(&id)); + s.close_library().unwrap(); + drop(s); + // opened again: the hold is on, and what was held is not (a library opens on what matches) + let mut s = open(&dir, false); + assert!(s.hold_view); + s.execute("library.filter", &json!({"rating": 0, "flag": "none"})).unwrap(); + assert!(!s.visible_cloned().contains(&id)); + s.close_library().unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..a38116ab2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -581,6 +581,7 @@ "Friday": "Freitag", "Full": "Vollständig", "Full Screen Preview": "Vollbildvorschau", + "Hold View Until Refreshed": "Ansicht bis zum Aktualisieren beibehalten", "Lights Out": "Licht aus", "Full screen mode": "Vollbildmodus", "Full size": "Volle Größe", @@ -1173,6 +1174,7 @@ "Refining the mask's detail…": "Maskendetails werden verfeinert…", "Refresh Source": "Quelle aktualisieren", "Refresh Source (/)": "Quelle aktualisieren (/)", + "Refresh View": "Ansicht aktualisieren", "Reject": "Ablehnen", "Reject below focus": "Unter Schärfegrenzwert ablehnen", "Rejected": "Abgelehnt", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..aee0b24e1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -563,6 +563,7 @@ "Friday": "Viernes", "Full": "Completa", "Full Screen Preview": "Vista previa a pantalla completa", + "Hold View Until Refreshed": "Mantener vista hasta actualizar", "Lights Out": "Luces apagadas", "Full screen mode": "Modo de pantalla completa", "Full size": "Tamaño completo", @@ -1145,6 +1146,7 @@ "Refining the mask's detail…": "Refinando el detalle de la máscara…", "Refresh Source": "Actualizar origen", "Refresh Source (/)": "Actualizar origen (/)", + "Refresh View": "Actualizar vista", "Reject": "Rechazar", "Reject below focus": "Rechazar por debajo del enfoque", "Rejected": "Rechazada", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..d9c05f1bf 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -545,6 +545,7 @@ "Friday": "Vendredi", "Full": "Complet", "Full Screen Preview": "Aperçu en plein écran", + "Hold View Until Refreshed": "Conserver la vue jusqu’à l’actualisation", "Lights Out": "Lumières éteintes", "Full screen mode": "Mode plein écran", "Full size": "Taille complète", @@ -1119,6 +1120,7 @@ "Refining the mask's detail…": "Affinage du détail du masque…", "Refresh Source": "Actualiser la source", "Refresh Source (/)": "Actualiser la source (/)", + "Refresh View": "Actualiser la vue", "Reject": "Rejeter", "Reject below focus": "Rejeter en dessous de la netteté", "Rejected": "Rejetée", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..5ec9e8bdc 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -509,6 +509,7 @@ "Has Edits": "編集", "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", + "Hold View Until Refreshed": "更新するまで表示を保持", "Home": "ホーム", "Horizontal inset (% of short edge)": "水平方向の余白(短辺に対する%)", "Hours": "時間", @@ -684,6 +685,7 @@ "Refine Saturation": "彩度を微調整", "Refining the mask's detail…": "マスクの細部を微調整中…", "Refresh Source": "コピー元を更新", + "Refresh View": "表示を更新", "Reject": "不採用", "Reliability": "信頼性", "Relink Photo": "写真の参照先を再設定", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..44a4910af 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -554,6 +554,7 @@ "Friday": "Sexta-feira", "Full": "Completa", "Full Screen Preview": "Pré-visualização em tela cheia", + "Hold View Until Refreshed": "Manter visualização até atualizar", "Lights Out": "Luzes apagadas", "Full screen mode": "Modo tela cheia", "Full size": "Tamanho completo", @@ -1137,6 +1138,7 @@ "Refining the mask's detail…": "Refinando os detalhes da máscara…", "Refresh Source": "Atualizar origem", "Refresh Source (/)": "Atualizar origem (/)", + "Refresh View": "Atualizar visualização", "Reject": "Rejeitar", "Reject below focus": "Rejeitar abaixo do foco", "Rejected": "Rejeitada", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..2a9f10379 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -455,6 +455,7 @@ "Friday": "Пятница", "Full": "Полный", "Full Screen Preview": "Полноэкранный просмотр", + "Hold View Until Refreshed": "Удерживать вид до обновления", "Lights Out": "Выключить свет", "Full screen mode": "Полноэкранный режим", "Full size": "Полный размер", @@ -942,6 +943,7 @@ "Refine Mask Edges": "Уточнить края маски", "Refresh Source": "Обновить источник", "Refresh Source (/)": "Обновить источник (/)", + "Refresh View": "Обновить вид", "Reject": "Отклонить", "Reject below focus": "Отклонять при недостаточной резкости", "Rejected": "Отклонено", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5d79898d5 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -96,6 +96,7 @@ "Focal Length": "Фокусна відстань", "Grab bag": "Випадкова добірка", "Group name": "Назва групи", + "Hold View Until Refreshed": "Утримувати вигляд до оновлення", "Home": "Домашня тека", "Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)", "ISO Speed": "Чутливість ISO", @@ -233,6 +234,7 @@ "Red": "Червоний", "Red Eye": "Червоні очі", "Redo": "Повторити", + "Refresh View": "Оновити вигляд", "Remove": "Видалити", "Rename": "Перейменувати", "Rename…": "Перейменувати…", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..5c0bc0901 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -515,6 +515,7 @@ "HDR panoramas": "HDR 全景", "Has Edits": "有编辑", "Has GPS": "有 GPS", + "Hold View Until Refreshed": "刷新前保持视图", "Home": "主文件夹", "Horizontal inset (% of short edge)": "水平边距 (占短边 %)", "Hours": "小时", @@ -631,6 +632,7 @@ "Refine Edges": "细化边缘", "Refine Saturation": "细化饱和度", "Refining the mask's detail…": "正在细化蒙版细节…", + "Refresh View": "刷新视图", "Reliability": "可靠性", "Relink Photo": "重新链接照片", "Reload Changed Files": "重新载入已更改的文件", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..863d4d3cd 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -515,6 +515,7 @@ "HDR panoramas": "HDR 全景", "Has Edits": "有編輯", "Has GPS": "有 GPS", + "Hold View Until Refreshed": "重新整理前保持檢視", "Home": "個人資料夾", "Horizontal inset (% of short edge)": "水平邊距(短邊百分比)", "Hours": "小時", @@ -631,6 +632,7 @@ "Refine Edges": "調整邊緣", "Refine Saturation": "調整飽和度", "Refining the mask's detail…": "正在調整遮色片細節…", + "Refresh View": "重新整理檢視", "Reliability": "可靠度", "Relink Photo": "重新連結照片", "Reload Changed Files": "重新載入已變更的檔案", diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..78996f409 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -167,6 +167,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Stacks", "view.filterBar", "library.clearFilter", + "library.refresh", + "library.holdView", "---", "compare.swap", "compare.makeSelect", @@ -329,6 +331,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), "view.navigator" => Some(u.navigator), + "library.holdView" => Some(app.session.hold_view), "view.fullScreenPreview" => Some(u.fullscreen), "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), "view.enterFullScreen" => Some(app.window_is_fullscreen), @@ -987,6 +990,17 @@ mod tests { }) } + #[test] + fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { + let mut a = app(); + let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; + let items = view(&a); + assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); + assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); + a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); + assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); + } + /// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), /// only while sorting at random, and the direction items are off for a shuffle. #[test] diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index b986b8995..4872378da 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -272,6 +272,14 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { if cur.key == Random && ui.button(crate::i18n::tr("Reshuffle")).clicked() { let _ = app.run("library.shuffle", json!({})); } + // a view that holds its photos shows what matches now when asked to + if app.session.hold_view { + let r = ui.button(crate::i18n::tr("Refresh View")); + crate::widgets::register(ui.ctx(), "button:refreshView", r.rect); + if r.clicked() { + let _ = app.run("library.refresh", json!({})); + } + } ui.separator(); // a shuffle has no direction worth choosing ui.add_enabled_ui(cur.key != Random, |ui| { diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index d1acd15a5..510931a51 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -18,7 +18,8 @@ const TAIL: f32 = 170.0; /// "14 of 120 photos" when a filter narrows the source, else "14 photos". pub fn count_text(matching: usize, total: Option<usize>, filtering: bool) -> String { match total { - Some(t) if filtering && t != matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t), + // (a held view can show more than now match its source: no "3 of 2") + Some(t) if filtering && t > matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t), _ => crate::i18n::tr_format!("{matching} photos", matching = matching), } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..02416ec1a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-LIB-SMARTALBUM: a view can hold its photos until refreshed) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -96,7 +96,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:<id>`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:<id>`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:library.holdView`, `cmd:library.refresh`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | @@ -822,4 +822,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) | | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | +| 2026-10-13 | minor | LR-LIB-SMARTALBUM: `cmd:library.holdView` and `cmd:library.refresh`: a view can hold the photos it shows until it is refreshed; status unchanged (✅) | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..a46a67a72 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -8,6 +8,15 @@ ## 2026-10-13 +### A view can hold its photos until you refresh it +- View ▸ Hold View Until Refreshed (off unless you turn it on; kept with the library). With it on, an edit that makes + a photo stop matching the view keeps it in the view: a keyword given in a smart album of photos without keywords, + a flag taken off in Picks, a rating that falls under the filter bar's. With it off, as before, the photo leaves the + grid at once and every photo after it moves up. View ▸ Refresh View (also in the sort menu while the hold is on), + Reshuffle, another source, filter or sort, or new rules for the smart album shows what matches now. +- Photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder + shown still takes it out of the view. + ### HDR merges keep the lens correction - An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it From 540a281ccda0f2dce928e630e5f43caa37271db3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Tue, 13 Oct 2026 12:49:11 -0400 Subject: [PATCH 142/315] PERF-SYNC: a Synchronize Folder scan reads off the folder's listing whether a photo and its sidecar are there The scan asked the disk about every library photo in the folder: is its file there, and has it a sidecar under any of four names. On a network share each question is a round trip, and the scan had just listed the folder. Measured on a hard disk over SMB, for unchanged folders of 1,600 to 2,400 photos: 4.6 to 7.1 s of questions after a listing of 4 to 5 s. - The walk hands each folder's listing over (`walk::files_in_noting`: every entry's name and kind, hidden ones too, and whether that is all of them). The scan walks the folder once, then checks the photos beside the probing of new files. - `sync::Listing` answers "is it there" / "is it a file" where that holds on any file system: a name the folder lists is there; a plain ASCII name is not there when the folder was listed to its end, all its names are ASCII and none is that name in another case. Everything else is asked of the disk as before (a link, a name Windows reads another way, a folder that was not walked, and every name not listed in a folder that holds a name outside ASCII). Now under 0.05 s for those folders. Two things differ on purpose: a file the folder lists is there though it can't be opened by its path (a folder that may be listed but not searched, an over-long path), where such photos were listed as missing; and the answers are the folder as the walk saw it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/examples/sync_scan_bench.rs | 47 ++++++ crates/engine/src/import.rs | 30 +++- crates/engine/src/sidecar.rs | 19 ++- crates/engine/src/sync.rs | 179 ++++++++++++++++++-- crates/engine/src/sync/tests_listing.rs | 191 ++++++++++++++++++++++ crates/engine/src/walk.rs | 97 ++++++++++- docs/whats-new.md | 17 ++ 7 files changed, 553 insertions(+), 27 deletions(-) create mode 100644 crates/engine/examples/sync_scan_bench.rs create mode 100644 crates/engine/src/sync/tests_listing.rs diff --git a/crates/engine/examples/sync_scan_bench.rs b/crates/engine/examples/sync_scan_bench.rs new file mode 100644 index 000000000..3b9bc51c1 --- /dev/null +++ b/crates/engine/examples/sync_scan_bench.rs @@ -0,0 +1,47 @@ +//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and +//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes +//! nothing there, and probes no file (the library is made from the folder's own listing). +//! +//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>` +use std::time::Instant; + +use lightcraft_catalog::{Op, Photo, Source}; +use lightcraft_engine::Session; +use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with}; + +fn main() { + let Some(folder) = std::env::args().nth(1) else { + eprintln!("usage: sync_scan_bench <folder>"); + return; + }; + let mut s = Session::new(); + let t = Instant::now(); + let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None); + println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64()); + for f in &files { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00"); + if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() { + eprintln!("could not add {name}"); + } + } + for run in 0..2 { + let input = match SyncInput::new(&mut s, &folder, true) { + Ok(input) => input, + Err(e) => { + eprintln!("{e}"); + return; + } + }; + let t = Instant::now(); + let changes = scan_with(input, &SyncProgress::default()); + println!( + "run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)", + t.elapsed().as_secs_f64(), + changes.new.len(), + changes.missing.len(), + changes.metadata.len() + ); + } +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e..821454c72 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -380,6 +380,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> { /// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) { + expand_noting(paths, skip, limits, None) +} + +/// [`expand_within`], handing the listing of every folder that is walked to `note` +/// ([`crate::walk::files_in_noting`]). +pub(crate) fn expand_noting( + paths: &[String], + skip: Option<&Path>, + limits: crate::walk::Limits, + mut note: Option<crate::walk::NoteFolder>, +) -> (Vec<String>, bool) { let mut out = Vec::new(); let mut truncated = false; for p in paths { @@ -388,7 +399,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk: continue; } if p.is_dir() { - let w = crate::walk::files_in(p, skip, limits, is_supported); + let w = match note.as_mut() { + Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), + None => crate::walk::files_in(p, skip, limits, is_supported), + }; if w.truncated { log::warn!( "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", @@ -630,6 +644,11 @@ impl ScanInput { (input, paths.to_vec()) } + /// The folder a scan never enters (the library's own). + pub(crate) fn skip(&self) -> Option<&Path> { + self.skip.as_deref() + } + /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> { std::sync::Arc::clone(&self.library) @@ -648,9 +667,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> { /// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it /// has) when `progress.cancel` is set. -pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { - use std::sync::atomic::Ordering::Relaxed; +pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { let files = expand(paths, input.skip.as_deref()); + scan_files(input, files, progress) +} + +/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). +pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &ScanProgress) -> ScanOutput { + use std::sync::atomic::Ordering::Relaxed; let by_path = library_files(&input.library, &files); let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); progress.total.store(todo.len(), Relaxed); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..41d1c5f57 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -65,15 +65,18 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf { /// An existing sidecar for `original`: the preferred naming first, then the other one (also /// accepting an upper-case `.XMP`). pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> { + sidecar_candidates(original, naming).find(|p| p.is_file()) +} + +/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, +/// then under the other naming, each as `.xmp` and as `.XMP`. +pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> { let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; - [naming, other] - .into_iter() - .flat_map(|n| { - let p = sidecar_path(original, n); - let upper = p.with_extension("XMP"); - [p, upper] - }) - .find(|p| p.is_file()) + [naming, other].into_iter().flat_map(move |n| { + let p = sidecar_path(original, n); + let upper = p.with_extension("XMP"); + [p, upper] + }) } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 4342ec6ad..6f236796e 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -38,7 +38,7 @@ //! not cancelled midway. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source}; @@ -203,18 +203,37 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { let namings = crate::sidecar::namings_in(&labels, naming, &ids); let photos: Vec<(Arc<Photo>, SidecarNaming)> = ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect(); + // The folder is walked once, and its listing answers for the library's photos in it: on a + // network share that is one request for a folder's names where it was several for each + // photo. Then both halves wait on the disk at once: the new files are probed (on their own + // threads) beside the checks of the photos, which ask the disk only what the listing can't say. + // (how many photos there are to check is known before the walk: the count shows while it runs) + progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed); + let mut listing = Listing::default(); + let (files, _) = crate::import::expand_noting( + std::slice::from_ref(&folder), + scan.skip(), + crate::walk::Limits::default(), + Some(&mut |dir, names, complete| listing.note(dir, names, complete)), + ); + let listing = &listing; // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] - let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress)); + let (out, checks) = (crate::import::scan_files(scan, files, &progress.files), check_photos(&photos, &labels, listing, progress)); #[cfg(not(target_arch = "wasm32"))] let (out, checks) = std::thread::scope(|sc| { let photos = &photos; let labels = &labels; - let checks = sc.spawn(move || check_photos(photos, labels, progress)); - let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files); + let checks = sc.spawn(move || check_photos(photos, labels, listing, progress)); + let out = crate::import::scan_files(scan, files, &progress.files); // (a check that panicked counts as nothing found; the scan still reports the rest) (out, checks.join().unwrap_or_default()) }); + log::debug!( + "synchronize: {} folders listed; the disk was asked {} times about the library's photos", + listing.folders.len(), + listing.asked.load(std::sync::atomic::Ordering::Relaxed) + ); let mut changes = FolderChanges { path: folder, seen, ..Default::default() }; for ((p, _), check) in photos.iter().zip(checks) { let Some(file) = crate::cmd::missing::checked_path(p) else { continue }; @@ -335,11 +354,136 @@ enum Check { News, } -fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { +/// The listings of the folders a scan walked: what is in each, by name. It answers "is this file +/// there" for the library's photos and their sidecars without asking the disk, which on a +/// network share is a round trip for every name (and a photo without a sidecar took five). +/// +/// It answers only what holds on any file system. A name is there when the folder lists exactly +/// that name ([`asks_the_disk`] aside). It is not there when the folder was listed to its end, +/// every name in the folder is ASCII, the name asked about is plain ASCII ([`plain_name`]), and +/// no name in the folder is that name in another case. Anything else is not answered, and the +/// disk is asked as before: a name that differs in case only, a link, a folder that was not +/// walked or not to its end, and every name that is not listed in a folder that holds a name +/// outside ASCII. (File systems hold names outside ASCII equal in more ways than can be listed +/// here: by case, where the capital sharp s is `SS`; by how accents are written; and some pass +/// over marks altogether.) +/// +/// Where the listing and a look-up would differ, the listing is the folder as the scan saw it: +/// a file it lists is there though it could not be opened by its path (a folder that may be +/// listed but not searched, a path longer than the system takes), and a share that drops after +/// the walk does not turn every photo the listing answers for into a missing one. +#[derive(Default)] +pub(crate) struct Listing { + folders: HashMap<PathBuf, Listed>, + /// How often the disk was asked after all (for the tests, and the log). + pub(crate) asked: std::sync::atomic::AtomicUsize, +} + +/// One folder's listing. +struct Listed { + /// name → what it is. + names: HashMap<String, crate::walk::EntryKind>, + /// The names, lower-cased (only kept for a folder whose names are all ASCII): what a plain + /// name asked about could be in another case. + lower: std::collections::HashSet<String>, + /// Every entry was read, and every name is ASCII: a plain name that is not listed, in any + /// case, is not there. + sure: bool, +} + +/// Whether the disk is asked about `name` whatever the listing holds: a name Windows takes for +/// a device (`NUL`, `aux.jpg`: there in every folder, or not openable where it is listed). +fn asks_the_disk(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase(); + // (`COM` and `LPT` with any one digit, the superscript ones Windows also takes among them) + let numbered = ["com", "lpt"].iter().any(|port| { + let mut rest = stem.strip_prefix(port).unwrap_or_default().chars(); + matches!((rest.next(), rest.next()), (Some(d), None) if d.is_ascii_digit() || ['\u{b9}', '\u{b2}', '\u{b3}'].contains(&d)) + }); + numbered || ["con", "prn", "aux", "nul", "conin$", "conout$", "clock$"].contains(&stem.as_str()) +} + +/// Whether `name` can only be found under itself in some case: ASCII, and nothing in it that a +/// file system resolves another way (a short name with `~`, a stream after `:`, dots or spaces +/// at its end, which Windows drops). +fn plain_name(name: &str) -> bool { + name.is_ascii() && !name.contains(['~', ':']) && !name.ends_with(['.', ' ']) && !name.is_empty() +} + +impl Listing { + /// Take the listing of `folder` from the walk ([`crate::walk::files_in_noting`]). + pub(crate) fn note(&mut self, folder: &Path, entries: Vec<(std::ffi::OsString, crate::walk::EntryKind)>, complete: bool) { + let mut sure = complete; + let mut names = HashMap::with_capacity(entries.len()); + let mut lower = std::collections::HashSet::new(); + for (name, kind) in entries { + match name.into_string() { + Ok(name) => { + sure &= name.is_ascii(); + lower.insert(name.to_ascii_lowercase()); + names.insert(name, kind); + } + // (a name that is no text is not one a photo's path can be compared with) + Err(_) => sure = false, + } + } + if !sure { + lower.clear(); + } + self.folders.insert(folder.to_path_buf(), Listed { names, lower, sure }); + } + + /// What the listing of `path`'s folder says of it: its kind when it is listed under exactly + /// this name, `Some(None)` when it is surely not there, `None` when the listing can't say. + fn kind(&self, path: &Path) -> Option<Option<crate::walk::EntryKind>> { + let name = path.file_name()?.to_str()?; + // (the path ends in that name: no `/.` or separator after it, which the disk would resolve) + if !path.to_str()?.ends_with(name) { + return None; + } + if asks_the_disk(name) { + return None; + } + let folder = self.folders.get(path.parent()?)?; + if let Some(kind) = folder.names.get(name) { + return Some(Some(*kind)); + } + (folder.sure && plain_name(name) && !folder.lower.contains(&name.to_ascii_lowercase())).then_some(None) + } + + /// `Path::exists`, where the listing can say. + pub(crate) fn exists(&self, path: &Path) -> Option<bool> { + match self.kind(path)? { + Some(crate::walk::EntryKind::Other) => None, + Some(_) => Some(true), + None => Some(false), + } + } + + /// `Path::is_file`, where the listing can say. + pub(crate) fn is_file(&self, path: &Path) -> Option<bool> { + match self.kind(path)? { + Some(crate::walk::EntryKind::Other) => None, + Some(kind) => Some(kind == crate::walk::EntryKind::File), + None => Some(false), + } + } + + /// The listing's answer, or the disk's where it has none. + fn or_ask(&self, answer: Option<bool>, ask: impl FnOnce() -> bool) -> bool { + answer.unwrap_or_else(|| { + self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + ask() + }) + } +} + +fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, listing: &Listing) -> Check { let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing }; - if !Path::new(file).exists() { + let file = Path::new(file); + if !listing.or_ask(listing.exists(file), || file.exists()) { Check::Missing - } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) { + } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, listing) { Check::News } else { Check::Nothing @@ -348,12 +492,12 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { /// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the /// disk, not work for the CPU). Photos not reached before a cancel count as nothing found. -fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> { +fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec<Check> { use std::sync::atomic::Ordering::Relaxed; progress.to_check.store(photos.len(), Relaxed); let cancel = &progress.files.cancel; let check = |p: &Photo, naming: SidecarNaming| { - let c = check_photo(p, naming, labels); + let c = check_photo(p, naming, labels, listing); progress.checked.fetch_add(1, Relaxed); c }; @@ -421,10 +565,17 @@ impl Session { /// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo /// (imported, or edited here) and says something the library doesn't. -fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool { +fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, listing: &Listing) -> bool { let Source::File { path } = &p.source else { return false }; - let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false }; - let Ok(meta) = std::fs::metadata(&file) else { return false }; + // ([`crate::sidecar::find_sidecar`], each name looked up in the listing before the disk) + // (a name the listing holds is read for its date next, and one that can't be read is passed + // over for the next name, as a name that is no file was) + let found = crate::sidecar::sidecar_candidates(path, naming).find_map(|c| { + let there = listing.or_ask(listing.is_file(&c), || c.is_file()); + let meta = there.then(|| std::fs::metadata(&c).ok()).flatten().filter(std::fs::Metadata::is_file)?; + Some((c, meta)) + }); + let Some((file, meta)) = found else { return false }; if meta.len() > MAX_SIDECAR_BYTES { return false; } @@ -708,3 +859,7 @@ impl SyncCommit { self.report } } + +#[cfg(test)] +#[path = "sync/tests_listing.rs"] +mod tests_listing; diff --git a/crates/engine/src/sync/tests_listing.rs b/crates/engine/src/sync/tests_listing.rs new file mode 100644 index 000000000..e54ed76ef --- /dev/null +++ b/crates/engine/src/sync/tests_listing.rs @@ -0,0 +1,191 @@ +//! Synchronize Folder answers "is the photo's file there" and "has it a sidecar" from the +//! listing of the folder its scan has just made, instead of asking the disk about every photo. +//! +//! On a network share each question is a round trip (about 5 ms a file on a hard disk over SMB, +//! measured), and a photo without a sidecar took five of them; the listing has every name. +//! The listing answers only what it can be sure of on any file system: where a name could match +//! another way (case, letters outside ASCII, a link), the disk is asked as before. +use super::*; +use crate::walk::EntryKind::{File, Folder, Other}; + +fn listing(folders: &[(&str, &[(&str, crate::walk::EntryKind)], bool)]) -> Listing { + let mut l = Listing::default(); + for (dir, names, complete) in folders { + l.note(Path::new(dir), names.iter().map(|(n, k)| (std::ffi::OsString::from(n), *k)).collect(), *complete); + } + l +} + +/// Given the listing of a folder, when it is asked about a name, then it says "there" for a +/// name it lists, "not there" for one nothing in the folder could be taken for, and nothing +/// (ask the disk) wherever a file system could see it otherwise. +#[test] +fn the_listing_answers_only_what_it_is_sure_of() { + let l = listing(&[ + ( + "/pics/trip", + &[("IMG_1.CR3", File), ("IMG_1.xmp", File), ("img_2.jpg", File), ("sub", Folder), ("link.jpg", Other), (".hidden.xmp", File)], + true, + ), + ("/pics/cut", &[("IMG_1.CR3", File)], false), + ("/pics/names", &[("IMG_1.CR3", File), ("Größe.JPG", File)], true), + // (the capital sharp s, which a file system that folds case takes for `SS`) + ("/pics/sharp", &[("IMG_1.CR3", File), ("STRA\u{1e9e}E.jpg", File)], true), + ("/pics/devices", &[("aux.jpg", File), ("COM\u{b9}.jpg", File), ("IMG_1.CR3", File)], true), + ]); + let (there, gone, ask) = (Some(true), Some(false), None); + for (path, exists, is_file) in [ + ("/pics/trip/IMG_1.CR3", there, there), + ("/pics/trip/IMG_1.xmp", there, there), + ("/pics/trip/.hidden.xmp", there, there), + ("/pics/trip/sub", there, gone), + ("/pics/trip/IMG_9.CR3", gone, gone), + ("/pics/trip/IMG_1.XMP", ask, ask), + ("/pics/trip/IMG_2.JPG", ask, ask), + ("/pics/trip/link.jpg", ask, ask), + ("/pics/trip/SUB", ask, ask), + // a folder the walk did not list to its end, or not at all + ("/pics/cut/IMG_1.CR3", there, there), + ("/pics/cut/IMG_2.CR3", ask, ask), + ("/pics/other/IMG_1.CR3", ask, ask), + // a folder that holds a name outside ASCII: what it lists is there, and nothing is surely not there (file + // systems hold such names equal in more ways than one) + ("/pics/names/IMG_1.CR3", there, there), + ("/pics/names/Größe.JPG", there, there), + ("/pics/names/IMG_2.CR3", ask, ask), + ("/pics/names/größe.JPG", ask, ask), + ("/pics/trip/Größe.JPG", ask, ask), + ("/pics/sharp/strasse.jpg", ask, ask), + ("/pics/sharp/STRASSE.xmp", ask, ask), + ("/pics/sharp/IMG_2.CR3", ask, ask), + // names Windows resolves another way: a short name, a stream, dots or spaces at the end, a device + ("/pics/trip/IMG_10~1.CR3", ask, ask), + ("/pics/trip/IMG_1.CR3::$DATA", ask, ask), + ("/pics/trip/IMG_1.CR3.", ask, ask), + ("/pics/trip/IMG_1.CR3 ", ask, ask), + ("/pics/devices/aux.jpg", ask, ask), + ("/pics/devices/NUL", ask, ask), + ("/pics/devices/nul.xmp", ask, ask), + ("/pics/devices/COM1 .jpg", ask, ask), + ("/pics/devices/COM\u{b9}.jpg", ask, ask), + ("/pics/trip/con", ask, ask), + ("/pics/trip/PRN.txt", ask, ask), + ("/pics/trip/com2.jpg", ask, ask), + ("/pics/trip/LPT9.xmp", ask, ask), + ("/pics/trip/lpt0", ask, ask), + ("/pics/trip/nul.tar.gz", ask, ask), + ("/pics/trip/CONIN$", ask, ask), + ("/pics/trip/com10.jpg", gone, gone), + ("/pics/trip/computer.jpg", gone, gone), + ("/pics/devices/IMG_1.CR3", there, there), + ("/pics/devices/auxiliary.jpg", ask, ask), + ("/pics/trip/auxiliary.jpg", gone, gone), + // a path that does not end in its file's name is the disk's to resolve + ("/pics/trip/IMG_1.CR3/", ask, ask), + ("/pics/trip/IMG_1.CR3/.", ask, ask), + ("/pics/trip/", ask, ask), + ("", ask, ask), + ] { + assert_eq!((l.exists(Path::new(path)), l.is_file(Path::new(path))), (exists, is_file), "{path:?}"); + } + // a name that is no text: nothing in its folder is surely not there + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let mut l = Listing::default(); + l.note( + Path::new("/pics/raw"), + vec![(std::ffi::OsString::from("IMG_1.CR3"), File), (std::ffi::OsString::from_vec(vec![0x66, 0xff, 0x6f]), File)], + true, + ); + assert_eq!((l.exists(Path::new("/pics/raw/IMG_1.CR3")), l.exists(Path::new("/pics/raw/IMG_2.CR3"))), (there, ask)); + } +} + +fn photo(c: &mut Catalog, path: &Path) -> Arc<Photo> { + let id = c.alloc_photo_id(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + c.apply(lightcraft_catalog::Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: path.to_string_lossy().to_string() }, &name, "JPEG", 6, 4, "2020-01-01T00:00:00")), + }) + .unwrap(); + Arc::clone(c.photo(id).unwrap()) +} + +/// Given a folder on disk whose photos are there or gone, with sidecars under every name a +/// sidecar can have, when each photo is checked with the folder's listing, then the answer is +/// the one the disk gives; and for photos with plain names the disk was asked nothing but the +/// sidecars' dates. +#[test] +fn a_check_from_the_listing_is_the_check_from_the_disk() { + let dir = std::env::temp_dir().join(format!("lc-listing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let news = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="5"/></rdf:RDF></x:xmpmeta>"#; + for name in [ + "plain.jpg", + "stem.jpg", + "stem.xmp", + "full.jpg", + "full.jpg.xmp", + "upper.jpg", + "upper.XMP", + "sub/deep.jpg", + "sub/deep.xmp", + "Größe.jpg", + "Größe.xmp", + "dir.jpg.xmp", + ] { + std::fs::write(dir.join(name), if name.ends_with("mp") || name.ends_with("MP") { news.as_bytes() } else { b"jpeg" }).unwrap(); + } + let mut c = Catalog::new(); + let photos: Vec<Arc<Photo>> = + ["plain.jpg", "stem.jpg", "full.jpg", "upper.jpg", "sub/deep.jpg", "Größe.jpg", "gone.jpg", "sub/gone.jpg", "dir.jpg"] + .iter() + .map(|n| photo(&mut c, &dir.join(n))) + .collect(); + // (`dir.jpg` is gone, though a sidecar of its name is there) + let mut l = Listing::default(); + let walked = + crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete)); + assert_eq!(walked.files.len(), 12); + let nothing = Listing::default(); + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + for p in &photos { + let from_disk = check_photo(p, naming, &c, ¬hing); + assert_eq!(check_photo(p, naming, &c, &l), from_disk, "{:?} {naming:?}", p.file_name); + } + } + let expected = [Check::Nothing, Check::News, Check::News, Check::News, Check::News, Check::News, Check::Missing, Check::Missing, Check::Missing]; + assert_eq!(photos.iter().map(|p| check_photo(p, SidecarNaming::Stem, &c, &l)).collect::<Vec<_>>(), expected); + + // what the disk was asked about the same photos, with the listing and without: fewer questions with it + let asked = |l: &Listing| { + for p in &photos { + check_photo(p, SidecarNaming::Stem, &c, l); + } + l.asked.load(std::sync::atomic::Ordering::Relaxed) + }; + let mut with = Listing::default(); + crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete)); + let (with, without) = (asked(&with), asked(&Listing::default())); + // (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where + // names are found in any case, 21 where they are not) + assert!(without >= 20, "{without}"); + // (with: this folder holds `Größe.*`, so what it does not list is still asked about: the four names of the + // sidecar `plain.jpg` has not, the two `full.jpg` has not, `upper.xmp`, and the two photos that are gone. What + // it lists is not asked about, nor anything in `sub`, whose names are all ASCII) + assert_eq!(with, 9, "asked {with} times with the listing, {without} without"); + + // a folder of plain names only: the disk is asked nothing at all + let sub = dir.join("sub"); + let mut only_plain = Listing::default(); + crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete)); + let mut c2 = Catalog::new(); + for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] { + let p = photo(&mut c2, &sub.join(name)); + assert_eq!(check_photo(&p, SidecarNaming::Stem, &c2, &only_plain), expected, "{name}"); + } + assert_eq!(only_plain.asked.load(std::sync::atomic::Ordering::Relaxed), 0, "every answer came from the listing"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 1c1b53b4a..f8a3ba251 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -47,10 +47,35 @@ fn hidden(p: &Path) -> bool { p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) } +/// What a folder's listing says an entry is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + File, + Folder, + /// A link, or an entry whose kind the listing did not say. + Other, +} + +/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind +/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk +/// stopped inside the folder, or an entry could not be read). +pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool); + /// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`) /// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops /// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped. -pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked { +pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked { + walk(root, skip, limits, keep, None) +} + +/// [`files_in`], handing the listing of every folder it reads to `note`: who walks a folder to +/// find its files has, with that, the answer to "is this name there" for everything in it, and +/// need not ask the disk again (a round trip for each name on a network share). +pub fn files_in_noting(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool, note: NoteFolder) -> Walked { + walk(root, skip, limits, keep, Some(note)) +} + +fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool, mut note: Option<NoteFolder>) -> Walked { let mut out = Walked::default(); // canonical folders reached through a link, so a link loop is walked at most once let mut linked: HashSet<PathBuf> = HashSet::new(); @@ -77,17 +102,41 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl } let Ok(rd) = std::fs::read_dir(&path) else { continue }; let mut children: Vec<(PathBuf, bool)> = Vec::new(); - for e in rd.flatten() { + // (the folder's listing, for who asked for it; all of it unless something below says otherwise) + let mut listed = note.is_some().then(Vec::new); + let mut complete = true; + for e in rd { + let Ok(e) = e else { + complete = false; + continue; + }; if out.entries >= limits.max_entries { out.truncated = true; + complete = false; break; } out.entries = out.entries.saturating_add(1); let p = e.path(); - if hidden(&p) { + // (a hidden entry's kind is only asked for when the listing is wanted; every other + // entry's is asked once) + let is_hidden = hidden(&p); + if is_hidden && listed.is_none() { continue; } - let Ok(ft) = e.file_type() else { continue }; + let ft = e.file_type(); + if let Some(listed) = listed.as_mut() { + let kind = match &ft { + Ok(t) if t.is_symlink() => EntryKind::Other, + Ok(t) if t.is_dir() => EntryKind::Folder, + Ok(t) if t.is_file() => EntryKind::File, + _ => EntryKind::Other, + }; + listed.push((e.file_name(), kind)); + } + if is_hidden { + continue; + } + let Ok(ft) = ft else { continue }; let dir = if ft.is_symlink() { // a link (or a Windows junction): a folder is entered once, a file is a file match std::fs::metadata(&p) { @@ -105,6 +154,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl }; children.push((p, dir)); } + if let (Some(note), Some(listed)) = (note.as_mut(), listed) { + note(&path, listed, complete); + } children.sort(); let below = depth.saturating_add(1); stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d))); @@ -123,6 +175,43 @@ mod tests { d } + /// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked + /// with its listings noted, then the walk finds what it finds without them, and each folder's + /// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor + /// folder) and says it is all of them; a walk that stops inside a folder says it is not. + #[test] + fn a_walk_notes_each_folders_listing() { + let root = tmp("noting"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + for name in ["a.jpg", ".hidden", "sub/b.jpg"] { + std::fs::write(root.join(name), b"x").unwrap(); + } + #[cfg(unix)] + std::os::unix::fs::symlink(root.join("a.jpg"), root.join("link.jpg")).unwrap(); + let mut noted: Vec<(PathBuf, Vec<(String, EntryKind)>, bool)> = Vec::new(); + let walked = files_in_noting(&root, None, Limits::default(), |_| true, &mut |dir, entries, complete| { + let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + noted.push((dir.to_path_buf(), entries, complete)); + }); + assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not"); + let named = |n: &str, k: EntryKind| (n.to_string(), k); + let mut top = vec![named(".hidden", EntryKind::File), named("a.jpg", EntryKind::File), named("sub", EntryKind::Folder)]; + if cfg!(unix) { + top.insert(2, named("link.jpg", EntryKind::Other)); + } + assert_eq!(noted, [(root.clone(), top, true), (root.join("sub"), vec![named("b.jpg", EntryKind::File)], true)]); + + // stopped inside the first folder: its listing is not all of it + let mut cut = Vec::new(); + let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| { + cut.push((dir.to_path_buf(), entries.len(), complete)) + }); + assert!(walked.truncated); + assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false))); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() { let d = tmp("order"); diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..591513d84 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -53,6 +53,23 @@ else (issue #1504): trimming or clearing the cache can't delete files outside it, and thumbnails are never read from or written through such a link. +### Synchronize Folder asks a network share less +- A Synchronize Folder scan no longer asks the disk about every photo of the library in the folder (is its file + there, has it a sidecar): it has just listed the folder, and reads the answers off the listing. On a network share + each of those questions is a round trip. Measured on a hard disk over SMB, for folders of 1,600 to 2,400 photos + that had not changed: 4.6 to 7.1 s of questions after the listing (about 3 ms a photo), now under 0.05 s. The + listing itself took 4 to 5 s and is as it was. +- The disk is still asked wherever a listing can't be sure what a file system would say: about a name that is + there in another case, a link, a name Windows reads another way (`aux.jpg`, a `~`, a `:`, a dot or a space at the + end), a folder the scan did not walk to its end, and, in a folder that holds any name with a character outside + ASCII (an umlaut, a curly apostrophe, a dash), about every name the folder does not list. Names it does list are + answered there too. In such a folder the scan saves less, and its checks now start after the listing where they + ran beside it. +- A photo the folder lists is there for the scan, also where its file could not be opened by its path (a folder + that may be listed but not searched, a path longer than the system takes): such photos used to be listed as + missing. And a share that drops after the scan has listed a folder no longer turns that folder's photos into + missing ones. + ### Large libraries: Synchronize Folder, import and sidecars start sooner - Opening Synchronize Folder, an import review or an import no longer makes a table of every photo in the library before it looks at the first file. In a library of 3.5 million photos that held the window for about 6 s before From 5f1037a19205b147808ad747004b22f582ffcb91 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Tue, 13 Oct 2026 20:24:41 -0400 Subject: [PATCH 143/315] PERF-EDIT: an edit is followed from what it touched, not worked out from every photo Everything derived from the catalog was keyed on its revision, which every op raises. So each edit (a keyword, a rating, every tick of a slider) had the grid's list queried and sorted again from every photo, the source counted by a second full query, and the side panels tally the whole library again. In a library of 67,000 photos the frame after one keyword took about 156 ms with both side panels open (an idle frame: 2 ms). The catalog now remembers which photos its latest ops touched and what of them they changed (`Catalog::touched_since`, `aspects_since`, `Aspects`). It answers "not known" for an op that changes more than photos of its own (an album, a stack, the keyword list), for changes too long ago, and for a revision it did not make itself (a library just loaded). - The view (`Session::visible`): when the same view is asked again after a small edit, the photos the edit touched are taken out of the list it had, asked about again, and put back at their place in the order (`Catalog::follow`). 37 ms -> 0.6 ms. Worked out whole as before: more than 256 photos touched, a change that is not photos' own, an album in its own order, Recently Added, Missing Photos, and a view that asks about "the last N days" (it changes with the clock too). - The source's count: taken from the list when no filter is set, else counted without sorting (`Catalog::count`). 37 ms -> 0. - The side panels (`Caches`): the folder tree, keyword tree, date groups, people's names and library counts are kept through an edit that changed nothing they read. A rating or a slider changes none; a keyword leaves the folders, dates and counts. The frame after a keyword: about 156 ms -> 54 ms with both side panels open, 71 ms -> 2 ms with the grid alone (`tests_view_cost.rs`, a synthetic library, run with --ignored --nocapture). Still paid on each edit: the count beside every album and smart album in the left panel (about 25 ms for a smart album there), and the filter bar's lists. Tests: 1,440 random edits of every photo-local kind across sources, sorts, filters and the hold, each compared with the list worked out whole; 600 random edits comparing every kept tally with a fresh one; the clock; the record itself, with its failure paths. `ui.inspect` -> `perf` reports `viewFollowed` and `viewWorkedOut`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/catalog/src/lib.rs | 155 +++++++++++++++++ crates/catalog/src/query.rs | 66 ++++++-- crates/catalog/src/tests.rs | 78 +++++++++ crates/engine/src/lib.rs | 116 ++++++++++--- crates/engine/src/library.rs | 2 + crates/engine/src/tests_hold_view.rs | 8 +- crates/engine/src/tests_view_follow.rs | 221 +++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 70 ++++---- crates/ui-egui/src/tests_tallies.rs | 149 +++++++++++++++++ crates/ui-egui/src/tests_view_cost.rs | 155 +++++++++++++++++ docs/whats-new.md | 14 ++ 12 files changed, 957 insertions(+), 79 deletions(-) create mode 100644 crates/engine/src/tests_view_follow.rs create mode 100644 crates/ui-egui/src/tests_tallies.rs create mode 100644 crates/ui-egui/src/tests_view_cost.rs diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ed4cb75b4..aa2f5745b 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -281,6 +281,71 @@ pub struct Catalog { /// Increments on every applied op. #[serde(skip)] pub revision: u64, + /// Which photos the latest ops touched ([`Catalog::touched_since`]). + #[serde(skip)] + changes: Changes, +} + +/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a +/// rating changes no folder's count; a slider changes none at all). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Aspects(u16); + +impl Aspects { + pub const NONE: Aspects = Aspects(0); + pub const RATING: Aspects = Aspects(1); + pub const FLAG: Aspects = Aspects(1 << 1); + pub const LABEL: Aspects = Aspects(1 << 2); + /// Develop settings, versions and history. + pub const DEVELOP: Aspects = Aspects(1 << 3); + /// Title, caption, keywords, people, place and the rest of the metadata. + pub const META: Aspects = Aspects(1 << 4); + pub const CAPTURED: Aspects = Aspects(1 << 5); + /// Where its file is. + pub const FILE: Aspects = Aspects(1 << 6); + pub const ANALYSIS: Aspects = Aspects(1 << 7); + /// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local. + pub const PRESENCE: Aspects = Aspects(1 << 8); + /// Everything: the photo may count differently for any tally. + pub const ALL: Aspects = Aspects(u16::MAX); + + pub const fn with(self, other: Aspects) -> Aspects { + Aspects(self.0 | other.0) + } + + /// Whether any of `other` is among these. + pub const fn any_of(self, other: Aspects) -> bool { + self.0 & other.0 != 0 + } +} + +/// The most photos remembered as touched; older changes are then answered with "anything may have changed". +const MAX_TOUCHED: usize = 8192; + +/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can +/// follow a small edit without being worked out again from every photo. +#[derive(Clone, Debug, Default)] +struct Changes { + /// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first. + touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>, + /// What the op being applied changes of its photo. + now: Aspects, + /// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of + /// the list…): what it changed for which photos is not known. + broad: u64, + /// Changes up to this revision are no longer all in `touched`. + floor: u64, + /// The revision the last op recorded here made. When the catalog's revision is another, it was set from + /// outside (a library just loaded takes a range of its own, so that nothing kept from another library is + /// taken for this one's): nothing is known of how the catalog got to where it is. + seen: u64, +} + +impl PartialEq for Changes { + /// Two catalogs are the same catalog whatever each remembers of how it got there. + fn eq(&self, _: &Self) -> bool { + true + } } impl Catalog { @@ -479,20 +544,110 @@ impl Catalog { // ---- writes fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> { + self.touch(id); self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id)) } + + /// The op being applied changes photo `id` (or adds or removes it). + fn touch(&mut self, id: PhotoId) { + // (the revision the op will make: `apply` counts up once it is done) + self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now)); + while self.changes.touched.len() > MAX_TOUCHED { + if let Some((revision, _, _)) = self.changes.touched.pop_front() { + self.changes.floor = revision; + } + } + } + + /// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were + /// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only + /// other things of a photo is still right. + pub fn aspects_since(&self, revision: u64) -> Option<Aspects> { + if !self.remembers(revision) { + return None; + } + Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a))) + } + + /// Whether every change since the catalog was at `revision` is remembered photo by photo. + fn remembers(&self, revision: u64) -> bool { + self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision + } + + /// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not + /// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may + /// have changed. + pub fn touched_since(&self, revision: u64) -> Option<Vec<PhotoId>> { + if !self.remembers(revision) { + return None; + } + let mut ids: Vec<PhotoId> = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect(); + ids.sort_unstable(); + ids.dedup(); + Some(ids) + } fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> { self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id)) } /// Apply an op; returns its inverse. On error nothing changes. pub fn apply(&mut self, op: Op) -> Result<Op> { + if self.changes.seen != self.revision { + // the revision was set from outside: what is remembered is of another past + self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() }; + } let inv = self.apply_inner(op)?; self.revision += 1; + self.changes.seen = self.revision; Ok(inv) } fn apply_inner(&mut self, op: Op) -> Result<Op> { + // An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind + // can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's + // name. Listed kind by kind, so a new one has to be put on one side. + match &op { + // (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the + // rare kinds whose reach nobody has to think about) + Op::AddPhoto { photo } => { + self.changes.now = Aspects::ALL; + self.touch(photo.id); + } + Op::RemovePhoto { id } => { + self.changes.now = Aspects::ALL; + self.touch(*id); + } + Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => { + self.changes.now = Aspects::ALL + } + Op::SetRating { .. } => self.changes.now = Aspects::RATING, + Op::SetFlag { .. } => self.changes.now = Aspects::FLAG, + Op::SetLabel { .. } => self.changes.now = Aspects::LABEL, + Op::SetMeta { .. } => self.changes.now = Aspects::META, + Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED, + Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE, + Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS, + Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => { + self.changes.now = Aspects::DEVELOP + } + // (its ops say for themselves, one by one) + Op::Batch { .. } => {} + Op::AddAlbum { .. } + | Op::RemoveAlbum { .. } + | Op::RenameAlbum { .. } + | Op::MoveAlbum { .. } + | Op::SetAlbumOrder { .. } + | Op::SetAlbumPhotos { .. } + | Op::SetAlbumCover { .. } + | Op::SetAlbumRules { .. } + | Op::AddStack { .. } + | Op::RemoveStack { .. } + | Op::SetStack { .. } + | Op::SetLabelName { .. } + | Op::SetBrowsed { .. } + | Op::SetKeyword { .. } + | Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1), + } Ok(match op { Op::AddPhoto { photo } => { if self.photos.contains_key(&photo.id) { diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9aa66b7f..eba2a0a74 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -94,6 +94,27 @@ pub struct Sort { pub seed: u64, } +impl Sort { + /// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last). + pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering { + let o = match self.key { + SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), + SortKey::ImportDate => a.imported.cmp(&b.imported), + SortKey::EditDate => a.edited.cmp(&b.edited), + SortKey::FileName => cmp_name(&a.file_name, &b.file_name), + SortKey::Rating => a.rating.cmp(&b.rating), + SortKey::FileSize => a.file_size.cmp(&b.file_size), + SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)), + } + // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the + // order cameras number frames, so the order doesn't depend on how ids were allocated; + // the id only keeps it stable. + .then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) + .then(a.id.cmp(&b.id)); + if self.ascending { o } else { o.reverse() } + } +} + impl Default for Sort { fn default() -> Self { Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 } @@ -384,26 +405,37 @@ impl Catalog { .map(|p| p.as_ref()) .filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref()))) .collect(); - v.sort_by(|a, b| { - let o = match sort.key { - SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), - SortKey::ImportDate => a.imported.cmp(&b.imported), - SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => cmp_name(&a.file_name, &b.file_name), - SortKey::Rating => a.rating.cmp(&b.rating), - SortKey::FileSize => a.file_size.cmp(&b.file_size), - SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), - } - // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the - // order cameras number frames, so the order doesn't depend on how ids were allocated; - // the id only keeps it stable. - .then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) - .then(a.id.cmp(&b.id)); - if sort.ascending { o } else { o.reverse() } - }); + v.sort_by(|a, b| sort.order(a, b)); v.into_iter().map(|p| p.id).collect() } + /// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops + /// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos + /// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a + /// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What + /// the query would give, without asking every photo again. + pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> { + let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect(); + let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect(); + let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root)); + for id in touched { + let Some(p) = self.photo(*id) else { continue }; + let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref())); + if filter.matches_in(p, self, root.as_deref()) || kept { + // (the list is in order without the touched photos, and stays so as each is put back) + let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt())); + ids.insert(at, *id); + } + } + ids + } + + /// How many photos match `filter`: [`Catalog::query`]'s length without its sorting. + pub fn count(&self, filter: &Filter) -> usize { + let root = filter.library_root(); + self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count() + } + /// Year → month → day counts for the "By Date" section (newest first). pub fn date_groups(&self) -> Vec<DateGroup> { use std::collections::BTreeMap; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..518be154a 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -737,3 +737,81 @@ fn cmp_name_ignores_case_without_allocating() { assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less); assert_eq!(cmp_name("a", "ab"), Less); } + +/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be +/// said photo by photo. +#[test] +fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { + let mut c = Catalog::default(); + let photo = |c: &mut Catalog, name: &str| { + let id = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), + }) + .unwrap(); + id + }; + let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let start = c.revision; + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + // a rating, then a flag on another photo, as one batch + c.apply(Op::SetRating { id: a, rating: 3 }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a])); + assert_eq!(c.aspects_since(start), Some(Aspects::RATING)); + let after_rating = c.revision; + c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once"); + assert_eq!(c.touched_since(after_rating), Some(vec![a, b])); + assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING))); + assert_eq!(c.touched_since(c.revision), Some(vec![])); + // a photo that goes counts for everything + let before = c.revision; + c.apply(Op::RemovePhoto { id: b }).unwrap(); + assert_eq!(c.touched_since(before), Some(vec![b])); + assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE))); + // an op that changes more than photos of its own: nothing is said of what came before it + let before = c.revision; + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + assert_eq!(c.touched_since(before), None); + assert_eq!(c.aspects_since(start), None); + assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); + // a revision that is not this catalog's past + assert_eq!(c.touched_since(c.revision + 5), None); + // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after + // the next op nothing of before it + let old = c.revision; + c.revision = 7 << 32; + assert_eq!(c.touched_since(old), None); + assert_eq!(c.aspects_since(c.revision), None); + let loaded = c.revision; + c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); + assert_eq!(c.touched_since(loaded), Some(vec![a])); + assert_eq!(c.touched_since(old), None); + // an op that fails changes nothing, and what is remembered never says less than what happened + let before = c.revision; + assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); + assert_eq!(c.revision, before); + c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); + assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); + // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch + // with an op that changes more than photos is not + let before = c.revision; + assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); + assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); + c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap(); + // (the photo the failed batch asked for and never found may be named too: more is said, never less) + assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + let before = c.revision; + c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap(); + assert_eq!(c.touched_since(before), None); + // more ops than are remembered: the oldest are answered with "anything" + let long_ago = c.revision; + for i in 0..9000u32 { + c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap(); + } + assert_eq!(c.touched_since(long_ago), None); + assert_eq!(c.touched_since(c.revision - 10), Some(vec![a])); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c1f1c0931..5e6fcb039 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -172,6 +172,9 @@ pub struct Interaction { pub original: Arc<DevelopSettings>, } +/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. +const MAX_FOLLOWED: usize = 256; + /// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -193,10 +196,18 @@ pub struct Session { /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. pub hold_view: bool, - /// The view that is held and the photos in it, before stacks are arranged ([`Session::hold_view`]). Another - /// source, filter or sort is another view, and starts from what matches; so does a smart album whose rules - /// were changed. `None` for a view that nothing can stop matching (a source with no filter over it). - held: Option<(String, Vec<PhotoId>)>, + /// The view last worked out, the catalog's revision then, and the photos in it before stacks are arranged. + /// A small edit is followed from it: only the photos the edit touched are asked about again and put at their + /// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]). + /// Another source, filter or sort is another view, and so is a smart album whose rules were changed. + base: Option<(String, u64, Vec<PhotoId>)>, + /// How many times the view followed an edit from the list it had, and how many times it was worked out from + /// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit. + pub view_followed: u64, + pub view_worked_out: u64, + /// Tests: work the view out whole every time (what following has to equal). + #[cfg(test)] + never_follow: bool, /// Identifies the current `visible` list: a new value (unique in the process) every time it /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, @@ -353,7 +364,11 @@ impl Session { visible: Vec::new().into(), visible_key: None, hold_view: false, - held: None, + base: None, + view_followed: 0, + view_worked_out: 0, + #[cfg(test)] + never_follow: false, visible_gen: 0, total: None, undo: Vec::new(), @@ -887,25 +902,50 @@ impl Session { // (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no // cost for the hold) let holds = self.hold_view && (asks || self.filter != Filter::default()); - let held = self.held.as_ref().filter(|(held, _)| holds && *held == view).map(|(_, ids)| ids); - let mut visible = match held { - Some(ids) => { - let keep: std::collections::HashSet<PhotoId> = ids.iter().copied().collect(); - let within = if asks { - self.view_filter(LibrarySource::All, &Filter::default()) - } else if self.source == LibrarySource::RecentlyAdded { - // (how far back it reaches was worked out for `f`: not a second pass over the library) - Filter { imported_from: f.imported_from.clone(), ..Default::default() } - } else { - self.view_filter(self.source, &Filter::default()) - }; - self.catalog.query_keeping(&f, &self.sort, &keep, &within) + let within = holds.then(|| { + if asks { + self.view_filter(LibrarySource::All, &Filter::default()) + } else if self.source == LibrarySource::RecentlyAdded { + // (how far back it reaches was worked out for `f`: not a second pass over the library) + Filter { imported_from: f.imported_from.clone(), ..Default::default() } + } else { + self.view_filter(self.source, &Filter::default()) } - None => self.catalog.query(&f, &self.sort), + }); + // Views whose order or photos are not the query's alone are worked out whole each time: an album in + // its own order, Recently Added (how far back it reaches moves with every import), Missing Photos. + let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate + && self.filter == Filter::default() + && self.catalog.album(a).is_some_and(|al| !al.is_smart())); + let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); + // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes + // with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole. + let clocked = f.depends_on_now() + || (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now))); + let base = self.base.take().filter(|(of, _, _)| *of == view); + // A small edit to the same view: the photos it touched are taken out, asked about again, and put back + // at their place in the order. The others neither stopped matching nor moved. + #[cfg(test)] + let clocked = clocked || self.never_follow; + let followed = base.as_ref().filter(|_| !whole && !clocked).and_then(|(_, revision, ids)| { + let touched = self.catalog.touched_since(*revision).filter(|touched| touched.len() <= MAX_FOLLOWED)?; + Some(self.catalog.follow(ids, &touched, &f, &self.sort, within.as_ref())) + }); + match followed { + Some(_) => self.view_followed += 1, + None => self.view_worked_out += 1, + } + let mut visible = match (followed, &base, &within) { + (Some(ids), _, _) => ids, + (None, Some((_, _, held)), Some(within)) => { + let keep: std::collections::HashSet<PhotoId> = held.iter().copied().collect(); + self.catalog.query_keeping(&f, &self.sort, &keep, within) + } + _ => self.catalog.query(&f, &self.sort), }; - // what the view shows now is what it holds until it is refreshed (taken before stacks are arranged, so - // a photo folded into a stack is held with it) - self.held = holds.then(|| (view, visible.clone())); + // what the view shows now is what the next edit is followed from, and what it holds until it is + // refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it) + self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone())); if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { // the shown folder lost its last photo (deleted, moved, removed): everything, not // an empty grid under the name of a folder that is gone from the sidebar @@ -973,10 +1013,25 @@ impl Session { /// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]). pub fn refresh_view(&mut self) { - self.held = None; + self.base = None; self.visible_key = None; } + /// [`Session::visible`] worked out from every photo, whatever was followed before: what following an edit + /// has to equal. + /// Nothing the session keeps is changed by asking: the next edit is followed from where the last one left the + /// view, so a list followed many times over is compared, not only one followed once. + #[cfg(test)] + pub(crate) fn visible_in_full(&mut self) -> Vec<PhotoId> { + let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out); + self.never_follow = true; + self.visible_key = None; + let whole = self.visible().to_vec(); + self.never_follow = false; + (self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; + whole + } + /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source /// shows. fn folder_holds_photos(&self) -> bool { @@ -990,6 +1045,16 @@ impl Session { if self.source == LibrarySource::Missing { return None; } + if self.filter == Filter::default() && !self.hold_view { + // nothing narrows the source: what the view lists is all of it, and is counted already (not a view + // that holds photos that stopped matching: those are not of the source any more) + let _ = self.visible(); + if let Some((_, revision, ids)) = &self.base + && *revision == self.catalog.revision + { + return Some(ids.len()); + } + } let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); if self.total.as_ref().map(|t| &t.0) != Some(&key) { let mut f = self.source.to_filter(&Filter::default(), &self.catalog); @@ -1001,7 +1066,8 @@ impl Session { if self.source == LibrarySource::LibraryFolder { f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); } - let n = self.catalog.query(&f, &self.sort).len(); + // (how many, not which: nothing to put in order) + let n = self.catalog.count(&f); self.total = Some((key, n)); } self.total.as_ref().map(|t| t.1) @@ -1112,4 +1178,6 @@ mod tests_spots; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_view_follow; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index be6fab1dd..e115bcf3a 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -424,6 +424,8 @@ impl Session { static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32; self.catalog = catalog; + // (nothing of the list the last library showed is this one's) + self.refresh_view(); self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { self.pending_sidecar_repairs.append(&mut entry.retry_sidecars); diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests_hold_view.rs index 3bb998b8c..f7082498c 100644 --- a/crates/engine/src/tests_hold_view.rs +++ b/crates/engine/src/tests_hold_view.rs @@ -181,10 +181,10 @@ fn a_source_with_no_filter_over_it_holds_nothing() { let all = s.visible_cloned(); keyword(&mut s, all[0]); assert_eq!(s.visible_cloned(), all); - assert!(s.held.is_none()); - s.execute("library.filter", &json!({"rating": 1})).unwrap(); - let _ = s.visible_cloned(); - assert!(s.held.is_some(), "a filter is a question: its view is held"); + // a photo deleted from All Photos leaves it, held or not + s.execute("library.select", &json!({"ids": [all[0].0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(!s.visible_cloned().contains(&all[0])); } #[test] diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests_view_follow.rs new file mode 100644 index 000000000..bf0656f86 --- /dev/null +++ b/crates/engine/src/tests_view_follow.rs @@ -0,0 +1,221 @@ +//! The view follows a small edit from the list it had ([`Session::visible`]): whatever the edit, the result has to +//! be the list worked out from every photo. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A small generator of numbers that is the same on every run. +struct Dice(u64); + +impl Dice { + fn roll(&mut self, sides: usize) -> usize { + self.0 = lightcraft_catalog::mix64(self.0); + (self.0 % sides.max(1) as u64) as usize + } +} + +/// A library of `n` photos in three folders, with ratings, flags, keywords and dates spread over them, an album +/// holding every third photo, a smart album of the photos without keywords, and a stack. +fn library(n: usize) -> (Session, Vec<PhotoId>, u64, u64) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for i in 0..n { + let id = s.catalog.alloc_photo_id(); + let name = format!("IMG_{:04}.jpg", (i * 37) % n); + let mut p = Photo::new( + id, + Source::File { path: format!("/pics/{}/{name}", i % 3) }, + &name, + "JPEG", + 60, + 40, + &format!("2026-01-{:02}T10:00:00", 1 + i % 9), + ); + p.captured = Some(format!("2025-{:02}-{:02}T10:00:{:02}", 1 + i % 12, 1 + i % 28, i % 60)); + s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let pick = |s: &mut Session, ids: Vec<u64>, command: &str, params: Value| { + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute(command, ¶ms).unwrap(); + }; + let every = |k: usize, of: usize| ids.iter().enumerate().filter(|(i, _)| i % of == k).map(|(_, id)| id.0).collect::<Vec<u64>>(); + pick(&mut s, every(0, 4), "photo.rate", json!({"rating": 3})); + pick(&mut s, every(1, 4), "photo.rate", json!({"rating": 5})); + pick(&mut s, every(0, 5), "photo.flag", json!({"flag": "pick"})); + pick(&mut s, every(0, 2), "photo.setMeta", json!({"addKeywords": ["travel"]})); + pick(&mut s, every(0, 7), "photo.setMeta", json!({"addKeywords": ["weddings"]})); + let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": every(0, 3)})).unwrap(); + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}}); + let smart = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + pick(&mut s, vec![ids[10].0, ids[11].0, ids[12].0], "stack.group", json!({})); + (s, ids, album, smart) +} + +/// One edit at random: most touch a photo or a few (what it is rated, flagged, keyworded, developed; when it was +/// taken; where its file is; whether it is in the library at all), some change an album, a stack or the library's +/// past, and now and then a photo is added or removed for good. +fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) { + let chosen: Vec<u64> = (0..1 + dice.roll(3)).map(|_| ids[dice.roll(ids.len())].0).collect(); + let one = PhotoId(chosen[0]); + let n = dice.roll(1000); + // (ops a command would refuse here, or on a photo that is gone, are edits that were not made: also a case) + match dice.roll(9) { + 0 => { + let _ = + s.commit("Capture time", Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)) }); + return; + } + 1 => { + let name = format!("REN_{n:04}.jpg"); + let source = Source::File { path: format!("/pics/{}/{name}", n % 3) }; + let _ = s.commit("Locate", Op::Relink { id: one, file_name: name, source, format: None }); + return; + } + 2 if n.is_multiple_of(4) => { + let id = s.catalog.alloc_photo_id(); + let name = format!("NEW_{n:04}.jpg"); + let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", n % 3) }, &name, "JPEG", 60, 40, "2026-02-01T10:00:00"); + p.captured = Some(format!("2025-{:02}-11T09:00:00", 1 + n % 12)); + if s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).is_ok() { + ids.push(id); + } + return; + } + 2 if n % 4 == 1 => { + let _ = s.commit("Remove", Op::RemovePhoto { id: one }); + return; + } + 2 => { + let _ = s.commit("Local", Op::SetLocal { id: one, local: n.is_multiple_of(2) }); + return; + } + _ => {} + } + // (a photo in Recently Deleted can't be chosen: the edit is then not made, which is an edit too) + let _ = s.execute("library.select", &json!({"ids": chosen})); + let flag = ["pick", "reject", "none"][dice.roll(3)]; + let keyword = ["travel", "weddings", "pets"][dice.roll(3)]; + let _ = match dice.roll(12) { + 0 | 1 => s.execute("photo.rate", &json!({"rating": dice.roll(6)})), + 2 => s.execute("photo.flag", &json!({"flag": flag})), + 3 | 4 => s.execute("photo.setMeta", &json!({"addKeywords": [keyword]})), + 5 | 6 => s.execute("photo.setMeta", &json!({"keywords": []})), + 7 if n.is_multiple_of(2) => s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.1 * (n % 9) as f64})), + 7 => s.execute("stack.group", &json!({})), + 8 => s.execute("photo.delete", &json!({})), + 9 => s.execute("album.addPhotos", &json!({"id": album, "ids": chosen})), + 10 => s.execute("album.removePhotos", &json!({"id": album, "ids": chosen})), + _ => s.undo_step().map(|_| Value::Null), + }; +} + +#[test] +fn following_an_edit_gives_the_list_worked_out_from_every_photo() { + let (mut s, mut ids, album, smart) = library(240); + let mut dice = Dice(7); + let sources = [ + json!({"kind": "all"}), + json!({"kind": "picks"}), + json!({"kind": "album", "id": smart}), + json!({"kind": "album", "id": album}), + json!({"kind": "libraryFolder", "path": "/pics/1"}), + json!({"kind": "recentlyDeleted"}), + ]; + let sorts = [ + json!({"key": "random", "seed": 11}), + json!({"key": "captureDate", "ascending": false}), + json!({"key": "rating", "ascending": true}), + json!({"key": "fileName", "ascending": true}), + json!({"key": "editDate", "ascending": false}), + json!({"key": "importDate", "ascending": true}), + ]; + let filters = [json!({"rating": 0, "text": ""}), json!({"rating": 3, "text": ""}), json!({"rating": 0, "text": "travel"})]; + let mut checked = 0; + for (i, source) in sources.iter().enumerate() { + for (j, sort) in sorts.iter().enumerate() { + let filter = &filters[(i + j) % filters.len()]; + s.execute("library.source", source).unwrap(); + s.execute("library.sort", sort).unwrap(); + s.execute("library.filter", filter).unwrap(); + let _ = s.visible_cloned(); + // (every other view holds its photos until refreshed: what stays then has to be what asking every photo + // would keep) + s.execute("library.holdView", &json!({"on": (i + j) % 2 == 1})).unwrap(); + let _ = s.visible_cloned(); + for step in 0..40 { + edit(&mut s, &mut dice, &mut ids, album); + let followed = s.visible_cloned(); + let whole = s.visible_in_full(); + assert_eq!(followed, whole, "source {source}, sort {sort}, filter {filter}, hold {}, edit {step}", s.hold_view); + checked += 1; + } + } + } + assert_eq!(checked, 1440); + // and it was following that was checked: most edits touch a photo or two, and the view kept up with those + // without asking every photo again + assert!(s.view_followed > 400, "followed {} times, worked out {} times", s.view_followed, s.view_worked_out); +} + +#[test] +fn a_view_that_asks_about_the_last_days_follows_the_clock_too() { + let (mut s, ids, _, _) = library(60); + // Given a smart album of the photos taken in the last 30 days, a day after the latest of them + let now = std::sync::Arc::new(std::sync::Mutex::new("2026-01-01T12:00:00".to_string())); + let clock = now.clone(); + s.clock = Box::new(move || clock.lock().unwrap().clone()); + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": 30}]}}); + let recent = s.execute("album.createSmart", &json!({"name": "Recent", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": recent})).unwrap(); + s.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); + let shown = s.visible_cloned(); + assert!(!shown.is_empty() && shown.len() < ids.len(), "{} of {}", shown.len(), ids.len()); + // When a photo is rated two months later + *now.lock().unwrap() = "2026-03-05T12:00:00".to_string(); + let followed = s.view_followed; + s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + // Then the photos nobody touched have left with the days: the view was asked whole, not followed + assert!(s.visible_cloned().is_empty(), "{:?}", s.visible_cloned()); + assert_eq!(s.visible_cloned(), s.visible_in_full()); + assert_eq!(s.view_followed, followed); + // And the same through a view that only looks into that album + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "album", "op": "is", "value": recent}]}}); + if let Ok(r) = s.execute("album.createSmart", &json!({"name": "In Recent", "rules": rules})) { + s.execute("library.source", &json!({"kind": "album", "id": r["id"]})).unwrap(); + let _ = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + assert_eq!(s.visible_cloned(), s.visible_in_full()); + assert_eq!(s.view_followed, followed, "a view that looks into a clocked album is asked whole too"); + } +} + +#[test] +fn an_edit_to_many_photos_or_to_more_than_photos_is_worked_out_whole() { + let (mut s, ids, album, _) = library(600); + s.execute("library.sort", &json!({"key": "random", "seed": 3})).unwrap(); + let _ = s.visible_cloned(); + let (followed, whole) = (s.view_followed, s.view_worked_out); + // one photo rated: followed + s.execute("library.select", &json!({"ids": [ids[5].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 1})).unwrap(); + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole)); + // five hundred photos rated at once: worked out from every photo + s.execute("library.select", &json!({"ids": ids.iter().take(500).map(|id| id.0).collect::<Vec<_>>()})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 1)); + // an album's photos changed: what that does to which view is not asked photo by photo + s.execute("album.addPhotos", &json!({"id": album, "ids": [ids[1].0]})).unwrap(); + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2)); + // nothing changed: neither + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2)); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 891914d1d..3316e3a0f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -113,7 +113,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()}, + "perf": {"viewFollowed": app.session.view_followed, "viewWorkedOut": app.session.view_worked_out, "frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()}, "loupe": app.loupe_shown.map(|(p, src)| json!({ "photo": p.0, "source": src, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..b2fc4975e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,12 +119,16 @@ mod tests_switch_library; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_tallies; +#[cfg(test)] mod tests_temp_slider; #[cfg(test)] mod tests_titlebar; #[cfg(test)] mod tests_unsaved; #[cfg(test)] +mod tests_view_cost; +#[cfg(test)] mod tests_wb_selector; #[cfg(test)] mod tests_zoom; @@ -1592,28 +1596,35 @@ pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { BuildHasherDefault::<DefaultHasher>::default().hash_one(parts) } +/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only +/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's +/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the +/// library saved on every edit. +fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool { + revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads)) +} + +/// What the tallies below read of a photo, besides whether it is in the library. +const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE); +const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META); +const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS); +const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED); +const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG); + impl Caches { /// The folders the library's photos were imported from. pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> { - match &self.folder_tree { - Some((r, t)) if *r == cat.revision => t.clone(), - _ => { - let t = std::sync::Arc::new(cat.folder_tree()); - self.folder_tree = Some((cat.revision, t.clone())); - t - } - } + let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree())); + self.folder_tree = Some((cat.revision, t.clone())); + t } /// The library's keyword tree. pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> { - match &self.keyword_tree { - Some((r, t)) if *r == cat.revision => t.clone(), - _ => { - let t = std::sync::Arc::new(cat.keyword_tree()); - self.keyword_tree = Some((cat.revision, t.clone())); - t - } - } + let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree())); + self.keyword_tree = Some((cat.revision, t.clone())); + t } /// The Keywording box's read-only names for the selection: what exported files carry /// (`export`), or the keywords with those containing them. @@ -1689,8 +1700,9 @@ impl Caches { /// All Photos / Picks / Recently Deleted counts. pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { if let Some((r, c)) = self.counts - && r == cat.revision + && still_right(cat, r, READS_FLAG) { + self.counts = Some((cat.revision, c)); return c; } let mut c = LibraryCounts::default(); @@ -1709,25 +1721,17 @@ impl Caches { } /// Everyone named on a face in the library (for completing a name as it is typed). pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<String>> { - match &self.person_names { - Some((r, v)) if *r == cat.revision => v.clone(), - _ => { - let v = std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>()); - self.person_names = Some((cat.revision, v.clone())); - v - } - } + let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v); + let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>())); + self.person_names = Some((cat.revision, v.clone())); + v } /// The By Date tree. pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> { - match &self.date_groups { - Some((r, g)) if *r == cat.revision => g.clone(), - _ => { - let g = std::sync::Arc::new(cat.date_groups()); - self.date_groups = Some((cat.revision, g.clone())); - g - } - } + let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g); + let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups())); + self.date_groups = Some((cat.revision, g.clone())); + g } /// Cameras, lenses and keywords in the library (filter bar pickers). pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> { diff --git a/crates/ui-egui/src/tests_tallies.rs b/crates/ui-egui/src/tests_tallies.rs new file mode 100644 index 000000000..ffc7408c5 --- /dev/null +++ b/crates/ui-egui/src/tests_tallies.rs @@ -0,0 +1,149 @@ +//! The side panels' tallies of the library (folders, keywords, dates, people, counts) are kept through an edit that +//! cannot have changed them, and are never kept through one that did. + +use std::sync::Arc; + +use serde_json::json; + +use crate::{LightcraftApp, Services}; + +/// A library of photos that are files in folders, taken on different days (the demo's photos are in no folder), +/// some of them keyworded. +fn app() -> LightcraftApp { + use lightcraft_catalog::{Op, Photo, Source}; + let mut s = lightcraft_engine::Session::new(); + let mut ids = Vec::new(); + for i in 0..60usize { + let id = s.catalog.alloc_photo_id(); + let name = format!("IMG_{i:03}.jpg"); + let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", i % 4) }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + p.captured = Some(format!("2025-{:02}-{:02}T10:00:00", 1 + i % 12, 1 + i % 28)); + s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id.0); + } + s.execute("library.select", &json!({"ids": ids.iter().step_by(2).collect::<Vec<_>>()})).unwrap(); + s.execute("photo.setMeta", &json!({"addKeywords": ["travel"]})).unwrap(); + LightcraftApp::new(s, Services { png: None, ..Default::default() }) +} + +/// The tallies as the panels would show them now. +fn shown(a: &mut LightcraftApp) -> String { + let cat = &a.session.catalog; + format!( + "{:?}\n{:?}\n{:?}\n{:?}\n{:?}", + a.caches.folder_tree(cat), + a.caches.keyword_tree(cat), + a.caches.date_groups(cat), + a.caches.person_names(cat), + a.caches.counts(cat) + ) +} + +/// The same tallies made from every photo. +fn fresh(a: &LightcraftApp) -> String { + let cat = &a.session.catalog; + format!( + "{:?}\n{:?}\n{:?}\n{:?}\n{:?}", + cat.folder_tree(), + cat.keyword_tree(), + cat.date_groups(), + cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>(), + crate::Caches::default().counts(cat) + ) +} + +#[test] +fn a_tally_kept_through_an_edit_is_the_tally_made_from_every_photo() { + let mut a = app(); + let ids: Vec<u64> = a.session.visible_cloned().iter().map(|p| p.0).collect(); + let mut dice = 5u64; + let mut roll = |sides: usize| { + dice = lightcraft_catalog::mix64(dice); + (dice % sides as u64) as usize + }; + assert_eq!(shown(&mut a), fresh(&a)); + assert!(!a.caches.folder_tree(&a.session.catalog).is_empty() && !a.caches.date_groups(&a.session.catalog).is_empty()); + for step in 0..600 { + let id = ids[roll(ids.len())]; + // (a photo in Recently Deleted can't be chosen: the edit is then not made, which is an edit too) + let _ = a.session.execute("library.select", &json!({"ids": [id]})); + let keyword = ["travel", "weddings", "pets|dogs"][roll(3)]; + let flag = ["pick", "reject", "none"][roll(3)]; + // where a file is, when a photo was taken, a photo that comes or goes for good + { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let n = roll(1000); + let moved = match roll(8) { + 0 => { + let name = format!("MOVED_{n:03}.jpg"); + let source = Source::File { path: format!("/elsewhere/{}/{name}", n % 3) }; + Some(a.session.commit("Locate", Op::Relink { id: PhotoId(id), file_name: name, source, format: None })) + } + 1 => Some( + a.session + .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)) }), + ), + 2 if n.is_multiple_of(3) => Some(a.session.commit("Remove", Op::RemovePhoto { id: PhotoId(id) })), + 2 if n % 3 == 1 => { + let new = a.session.catalog.alloc_photo_id(); + let name = format!("NEW_{n:03}.jpg"); + let p = Photo::new(new, Source::File { path: format!("/new/{name}") }, &name, "JPEG", 60, 40, "2026-02-01T10:00:00"); + Some(a.session.commit("Add", Op::AddPhoto { photo: Box::new(p) })) + } + _ => None, + }; + if moved.is_some() { + if roll(3) > 0 { + assert_eq!(shown(&mut a), fresh(&a), "after edit {step}"); + } + continue; + } + } + let _ = match roll(11) { + 0 | 1 => a.session.execute("photo.rate", &json!({"rating": roll(6)})), + 2 => a.session.execute("photo.flag", &json!({"flag": flag})), + 3 | 4 => a.session.execute("photo.setMeta", &json!({"addKeywords": [keyword]})), + 5 => a.session.execute("photo.setMeta", &json!({"keywords": []})), + 6 => a.session.execute("photo.setMeta", &json!({"title": format!("Title {}", roll(4))})), + 7 => a.session.execute("develop.set", &json!({"control": "light.exposure", "value": 0.1 * roll(9) as f64})), + 8 => a.session.execute("photo.delete", &json!({})), + 9 => a.session.execute("photo.label", &json!({"label": "red"})), + _ => a.session.undo_step().map(|_| serde_json::Value::Null), + }; + // (not every frame asks for every tally: sometimes several edits pass between two looks) + if roll(3) > 0 { + assert_eq!(shown(&mut a), fresh(&a), "after edit {step}"); + } + } +} + +#[test] +fn a_rating_or_a_slider_keeps_every_tally_and_a_keyword_keeps_the_folders_and_dates() { + let mut a = app(); + let id = a.session.visible_cloned()[0].0; + a.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + let look = |a: &mut LightcraftApp| { + let cat = &a.session.catalog; + (a.caches.folder_tree(cat), a.caches.keyword_tree(cat), a.caches.date_groups(cat), a.caches.person_names(cat)) + }; + let was = look(&mut a); + // a rating, then a slider: nothing is tallied again + a.session.execute("photo.rate", &json!({"rating": 2})).unwrap(); + a.session.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + let now = look(&mut a); + assert!(Arc::ptr_eq(&was.0, &now.0) && Arc::ptr_eq(&was.1, &now.1) && Arc::ptr_eq(&was.2, &now.2) && Arc::ptr_eq(&was.3, &now.3)); + // a keyword: the keyword tree is made again, the folders and the dates are not + a.session.execute("photo.setMeta", &json!({"addKeywords": ["travel"]})).unwrap(); + let now = look(&mut a); + assert!(Arc::ptr_eq(&was.0, &now.0) && Arc::ptr_eq(&was.2, &now.2), "folders and dates kept"); + assert!(!Arc::ptr_eq(&was.1, &now.1), "keywords tallied again"); + // a photo deleted: every tally is made again + a.session.execute("photo.delete", &json!({})).unwrap(); + let gone = look(&mut a); + assert!(!Arc::ptr_eq(&now.0, &gone.0) && !Arc::ptr_eq(&now.1, &gone.1) && !Arc::ptr_eq(&now.2, &gone.2)); + // an album made (more than a photo's own change): nothing is assumed + let before = look(&mut a); + a.session.execute("album.create", &json!({"name": "Trip"})).unwrap(); + let after = look(&mut a); + assert!(!Arc::ptr_eq(&before.0, &after.0)); +} diff --git a/crates/ui-egui/src/tests_view_cost.rs b/crates/ui-egui/src/tests_view_cost.rs new file mode 100644 index 000000000..064eba6fd --- /dev/null +++ b/crates/ui-egui/src/tests_view_cost.rs @@ -0,0 +1,155 @@ +//! What one edit costs in a large library: a measurement, not a test (run with `--ignored --nocapture`). + +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1000.0 +} + +/// A library of `n` photos, about a third of them keyworded from a pool of 500 keywords, showing a smart album of +/// the photos without keywords at random. +fn library(n: usize) -> lightcraft_engine::Session { + let mut s = lightcraft_engine::Session::new(); + let mut keyworded: Vec<Vec<u64>> = vec![Vec::new(); 500]; + for i in 0..n { + let id = s.catalog.alloc_photo_id(); + let name = format!("IMG_{i:06}.jpg"); + let day = 1 + (i / 400) % 28; + let month = 1 + (i / 11_200) % 12; + let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", i / 1000) }, &name, "JPEG", 6000, 4000, "2026-01-01T10:00:00"); + p.captured = Some(format!("2025-{month:02}-{day:02}T10:{:02}:{:02}", (i / 60) % 60, i % 60)); + s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap(); + if i % 3 == 0 { + keyworded[(i / 3) % 500].push(id.0); + } + } + for (k, ids) in keyworded.iter().enumerate() { + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("photo.setMeta", &json!({"addKeywords": [format!("keyword {k:03}")]})).unwrap(); + } + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}}); + let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + s +} + +#[test] +#[ignore = "a measurement: run with --ignored --nocapture"] +fn what_one_keyword_costs_in_a_library_of_67_000_photos() { + let t = Instant::now(); + let mut s = library(67_000); + eprintln!("built the library in {:.0} ms", ms(t.elapsed())); + // the engine alone + let t = Instant::now(); + let shown = s.visible().len(); + eprintln!("engine: first view of {shown} photos: {:.1} ms", ms(t.elapsed())); + for round in 0..3 { + let id: PhotoId = s.visible()[100 + round]; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let t = Instant::now(); + s.execute("photo.setMeta", &json!({"addKeywords": ["keyword 001"]})).unwrap(); + let command = t.elapsed(); + let t = Instant::now(); + let n = s.visible().len(); + let view = t.elapsed(); + let t = Instant::now(); + let _ = s.source_total(); + let total = t.elapsed(); + eprintln!("engine: keyword {round}: command {:.1} ms, view of {n} again {:.1} ms, source total {:.1} ms", ms(command), ms(view), ms(total)); + } + // its parts + let ids = s.visible_cloned(); + let t = Instant::now(); + let f = s.source.to_filter(&s.filter, &s.catalog); + let q = s.catalog.query(&f, &s.sort); + let query = t.elapsed(); + let t = Instant::now(); + let _ = s.catalog.arrange_stacks(&q); + let stacks = t.elapsed(); + let t = Instant::now(); + let _ = s.catalog.stack_index(); + let index = t.elapsed(); + eprintln!( + "engine parts: query and sort {:.1} ms, arrange stacks {:.1} ms, stack index {:.1} ms ({} ids)", + ms(query), + ms(stacks), + ms(index), + ids.len() + ); + // what the side panels tally from every photo after each change + let time = |what: &str, f: &dyn Fn()| { + let t = Instant::now(); + f(); + eprintln!("tally: {what} {:.1} ms", ms(t.elapsed())); + }; + time("folder tree", &|| drop(s.catalog.folder_tree())); + time("keyword tree", &|| drop(s.catalog.keyword_tree())); + time("date groups", &|| drop(s.catalog.date_groups())); + time("people", &|| drop(s.catalog.people())); + time("count of a smart album", &|| { + let _ = s.catalog.count(&f); + }); + time("count of all photos", &|| { + let _ = s.catalog.count(&lightcraft_catalog::Filter::default()); + }); + // the app: a frame after an edit against a frame with nothing changed + let app = LightcraftApp::new(s, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1600.0, 1000.0], 1.0); + h.app.ui.left_panel = true; + h.request("engine.execute", json!({"command": "view.squareGrid", "params": {}}), Duration::from_secs(60)); + h.request("engine.execute", json!({"command": "panel.keywords", "params": {}}), Duration::from_secs(60)); + h.settle(Duration::from_secs(120)); + let frame = |h: &mut Headless| { + let t = Instant::now(); + h.step(); + t.elapsed() + }; + let idle: Vec<f64> = (0..5).map(|_| ms(frame(&mut h))).collect(); + eprintln!("app: frames with nothing changed: {idle:.1?} ms"); + for round in 0..3 { + let id: PhotoId = h.app.session.visible()[300 + round]; + h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + h.step(); + h.step(); + let t = Instant::now(); + h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 002"]})).unwrap(); + let command = t.elapsed(); + let first = frame(&mut h); + let second = frame(&mut h); + eprintln!("app: keyword {round}: command {:.1} ms, the frame after it {:.1} ms, the next {:.1} ms", ms(command), ms(first), ms(second)); + } + // which part of the window the frame after an edit pays for + for (what, left, right) in [("grid alone", false, false), ("grid and left panel", true, false), ("grid and Keywords panel", false, true)] { + h.app.ui.left_panel = left; + h.request("engine.execute", json!({"command": if right { "panel.keywords" } else { "panel.close" }, "params": {}}), Duration::from_secs(60)); + if right && h.app.ui.right != crate::state::RightPanel::Keywords { + h.request("engine.execute", json!({"command": "panel.keywords", "params": {}}), Duration::from_secs(60)); + } + h.settle(Duration::from_secs(120)); + let id: PhotoId = h.app.session.visible()[500]; + h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + h.step(); + h.step(); + h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 004"]})).unwrap(); + let first = frame(&mut h); + eprintln!("app: {what} (right panel {:?}): the frame after a keyword {:.1} ms", h.app.ui.right, ms(first)); + } + h.app.ui.left_panel = true; + let view = "view.photoGrid"; + h.request("engine.execute", json!({"command": view, "params": {}}), Duration::from_secs(60)); + h.settle(Duration::from_secs(120)); + let id: PhotoId = h.app.session.visible()[400]; + h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + h.step(); + h.step(); + h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 003"]})).unwrap(); + let first = frame(&mut h); + eprintln!("app: {view}: the frame after a keyword {:.1} ms", ms(first)); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index a46a67a72..a0699f77a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -8,6 +8,20 @@ ## 2026-10-13 +### Edits keep up in a large library +- A small edit (a keyword, a rating, a flag, a slider) no longer has the grid's list worked out again from every + photo: only the photos the edit touched are asked about again and put at their place in the order. In a library of + 67,000 photos that step went from 37 ms to under 1 ms. Still worked out whole: an edit to more than 256 photos, a + change to an album, a stack or the keyword list, an album shown in its own order, Recently Added, Missing Photos, + and a view that asks about "the last N days". +- With no filter set, the count of the source is taken from the list instead of from a second pass over the library. +- The side panels keep a tally an edit cannot have changed: a rating or a slider changes none of the folders, + keywords, dates, people and counts; a keyword leaves the folders, the dates and the counts as they are. With both + side panels open, the frame after a keyword went from about 156 ms to about 54 ms in that library (2 ms with the + panels closed, from 71 ms). +- Still tallied from every photo on each edit: the count beside each album and smart album in the left panel (about + 25 ms for a smart album in that library), and the filter bar's lists of cameras, lenses and keywords. + ### A view can hold its photos until you refresh it - View ▸ Hold View Until Refreshed (off unless you turn it on; kept with the library). With it on, an edit that makes a photo stop matching the view keeps it in the view: a keyword given in a smart album of photos without keywords, From 43ba500c6e245cd04ad3278b909ba8c62e7cb490 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Tue, 13 Oct 2026 21:20:55 -0400 Subject: [PATCH 144/315] PERF-EDIT: a smart album's count follows an edit, asking only the photos it touched The left panel shows a count beside every album. Each catalog revision had every album counted again, and a smart album's count asks every photo of the library whether its rules hold: one pass over the library for each smart album after each keyword, rating or slider tick (about 25 ms an album in a library of 67,000 photos). `AlbumTally` (`crates/catalog/src/tally.rs`) keeps the counts and, for each smart album, which photos it holds. After a small edit each smart album asks only the photos the edit touched (`Catalog::touched_since`); an album of chosen photos is counted again only when a photo came, went or was deleted. Counted from every photo as before: new rules, a change to an album, a stack or the keyword list, an edit to more than 256 photos, another library, and each new minute while an album asks about "the last N days". What an album remembers is in proportion to the photos it holds: a bit a photo id while ids are close together, a set when they are far apart. The frame after a keyword in that library, both side panels open: about 54 ms -> 27 ms (156 ms before #1660). Tests: 900 random edits against eight smart albums (keywords, flags, ratings, labels, sliders, capture dates, file names, an album of chosen photos) and an ordinary one, the followed counts compared with `album_count` after each; a delete followed, three hundred photos counted whole; the members' bits at the edges and for ids far apart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/catalog/src/lib.rs | 1 + crates/catalog/src/tally.rs | 234 +++++++++++++++++++++++++ crates/engine/src/tests_view_follow.rs | 74 ++++++++ crates/ui-egui/src/lib.rs | 57 ++++-- docs/whats-new.md | 8 +- 5 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 crates/catalog/src/tally.rs diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index aa2f5745b..79b02f7dc 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -25,6 +25,7 @@ pub mod rules; pub mod safe_file; pub mod stacks; pub mod store; +pub mod tally; use std::collections::BTreeMap; use std::sync::Arc; diff --git a/crates/catalog/src/tally.rs b/crates/catalog/src/tally.rs new file mode 100644 index 000000000..8f15e5ec4 --- /dev/null +++ b/crates/catalog/src/tally.rs @@ -0,0 +1,234 @@ +//! How many photos each album shows, kept up to date from what the latest ops touched. +//! +//! A smart album's count asks every photo whether its rules hold. Asked again after every edit, that is a pass over +//! the library for each smart album each time a keyword is given. [`AlbumTally`] remembers which photos each smart +//! album holds, and after a small edit asks only the photos the edit touched ([`Catalog::touched_since`]). An op +//! that changes more than photos of its own (an album's rules or photos, a stack, the keyword list) has it counted +//! whole again, as before. + +use std::collections::{HashMap, HashSet}; + +use crate::{AlbumId, Aspects, Catalog, PhotoId}; + +/// The most photos an edit may touch for the counts to follow it photo by photo. +const MAX_FOLLOWED: usize = 256; +/// Bits are kept while they cost no more than this many words a photo held, and these to start with. Past that +/// (photo ids far apart: a library whose ids don't count up from 1) a set is kept instead, so what an album +/// remembers is always in proportion to the photos it holds, never to the largest id in the library. +const WORDS_A_MEMBER: usize = 4; +const WORDS_TO_START: usize = 1024; + +/// The photos a smart album holds. +#[derive(Clone, Debug)] +enum Members { + /// One bit a photo id, and how many are set. + Bits { + words: Vec<u64>, + len: usize, + }, + Set(HashSet<PhotoId>), +} + +impl Members { + fn none() -> Members { + Members::Bits { words: Vec::new(), len: 0 } + } + + fn len(&self) -> usize { + match self { + Members::Bits { len, .. } => *len, + Members::Set(set) => set.len(), + } + } + + #[cfg(test)] + fn holds(&self, id: PhotoId) -> bool { + match self { + Members::Bits { words, .. } => { + usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0) + } + Members::Set(set) => set.contains(&id), + } + } + + /// Put `id` in or take it out. + fn set(&mut self, id: PhotoId, held: bool) { + if let Members::Bits { words, len } = self { + let bit = 1u64 << (id.0 % 64); + match usize::try_from(id.0 / 64) { + Ok(at) if at < words.len() => { + if let Some(word) = words.get_mut(at) { + match (*word & bit != 0, held) { + (false, true) => { + *word |= bit; + *len = len.saturating_add(1); + } + (true, false) => { + *word &= !bit; + *len = len.saturating_sub(1); + } + _ => {} + } + } + return; + } + // (past the end: not held, so there is nothing to take out) + _ if !held => return, + // room for it, as long as the bits stay in proportion to the photos held + Ok(at) if at < len.saturating_mul(WORDS_A_MEMBER).saturating_add(WORDS_TO_START) => { + words.resize(at + 1, 0); + if let Some(word) = words.get_mut(at) { + *word |= bit; + *len = len.saturating_add(1); + } + return; + } + // ids too far apart for bits: a set from here on + _ => { + let ids = words + .iter() + .enumerate() + .flat_map(|(at, word)| (0..64u64).filter(move |b| word & (1u64 << b) != 0).map(move |b| PhotoId(at as u64 * 64 + b))); + *self = Members::Set(ids.collect()); + } + } + } + if let Members::Set(set) = self { + if held { + set.insert(id); + } else { + set.remove(&id); + } + } + } +} + +/// [`Catalog::album_count`] of every album, as of one revision of the catalog. +#[derive(Clone, Debug)] +pub struct AlbumTally { + revision: u64, + counts: HashMap<AlbumId, usize>, + /// The smart albums' photos. + members: HashMap<AlbumId, Members>, +} + +impl AlbumTally { + /// Counted from every photo. + pub fn of(cat: &Catalog) -> AlbumTally { + let mut counts = HashMap::new(); + let mut members = HashMap::new(); + for album in cat.albums() { + if album.is_smart() { + let mut held = Members::none(); + for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) { + held.set(p.id, true); + } + counts.insert(album.id, held.len()); + members.insert(album.id, held); + } else { + counts.insert(album.id, cat.album_count(album.id)); + } + } + AlbumTally { revision: cat.revision, counts, members } + } + + /// The count of each album. + pub fn counts(&self) -> &HashMap<AlbumId, usize> { + &self.counts + } + + /// The catalog's revision the counts are of. + pub fn revision(&self) -> u64 { + self.revision + } + + /// Bring the counts up to `cat` as it is now. `true` when that was done from the photos the ops since touched + /// (or nothing had changed), `false` when every photo was asked again. + /// + /// `cat` has to be the catalog the counts were made of, further along. Another catalog is told apart only by + /// its revisions: a library that is loaded takes a range of its own (and then everything is counted again), but + /// two catalogs both counting from 0 look like one. + pub fn update(&mut self, cat: &Catalog) -> bool { + if self.revision == cat.revision { + return true; + } + let followed = match (cat.touched_since(self.revision), cat.aspects_since(self.revision)) { + (Some(touched), Some(changed)) if touched.len() <= MAX_FOLLOWED => { + self.follow(cat, &touched, changed); + true + } + _ => false, + }; + if !followed { + *self = AlbumTally::of(cat); + } + followed + } + + /// The counts after ops that touched only `touched`: each smart album asks those photos again; an album of + /// chosen photos counts again only when a photo came, went or was deleted (who is in it did not change: that + /// is an op on the album, and not followed). + fn follow(&mut self, cat: &Catalog, touched: &[PhotoId], changed: Aspects) { + for (album, held) in &mut self.members { + for id in touched { + held.set(*id, cat.photo(*id).is_some_and(|p| cat.album_contains(*album, p))); + } + self.counts.insert(*album, held.len()); + } + if changed.any_of(Aspects::PRESENCE) { + for album in cat.albums().filter(|a| !a.is_smart()) { + self.counts.insert(album.id, cat.album_count(album.id)); + } + } + self.revision = cat.revision; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(m: &Members, of: &[u64]) -> Vec<u64> { + of.iter().copied().filter(|id| m.holds(PhotoId(*id))).collect() + } + + #[test] + fn members_are_kept_as_bits_at_the_edges_of_a_word_and_past_the_end() { + let mut m = Members::none(); + let probe = [0, 1, 63, 64, 65, 127, 128, 5000]; + assert!(ids(&m, &probe).is_empty() && m.len() == 0); + for id in [0, 63, 64, 5000] { + m.set(PhotoId(id), true); + } + assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 64, 5000], 4)); + // put in twice, taken out twice, taken out where nothing is, far past the end: counted once each way + m.set(PhotoId(63), true); + m.set(PhotoId(64), false); + m.set(PhotoId(64), false); + m.set(PhotoId(1), false); + m.set(PhotoId(900_000), false); + assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 5000], 3)); + assert!(matches!(m, Members::Bits { .. })); + } + + #[test] + fn photo_ids_far_apart_are_kept_as_a_set_in_proportion_to_the_photos_held() { + // one photo with an id in the thousands of millions: no bit for every id up to it + let mut m = Members::none(); + for id in [3, 70, 9_000_000_000] { + m.set(PhotoId(id), true); + } + assert!(matches!(m, Members::Set(_)), "a set once the ids are too far apart for bits"); + assert_eq!((ids(&m, &[3, 70, 71, 9_000_000_000, u64::MAX]), m.len()), (vec![3, 70, 9_000_000_000], 3)); + m.set(PhotoId(u64::MAX), true); + m.set(PhotoId(3), false); + assert_eq!((ids(&m, &[3, 70, 9_000_000_000, u64::MAX]), m.len()), (vec![70, 9_000_000_000, u64::MAX], 3)); + // an album of many photos with ids counting up stays bits, however many + let mut dense = Members::none(); + for id in 1..=200_000u64 { + dense.set(PhotoId(id), true); + } + assert!(matches!(&dense, Members::Bits { words, .. } if words.len() <= 200_000 / 64 + 1)); + assert_eq!(dense.len(), 200_000); + } +} diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests_view_follow.rs index bf0656f86..391e38b3b 100644 --- a/crates/engine/src/tests_view_follow.rs +++ b/crates/engine/src/tests_view_follow.rs @@ -219,3 +219,77 @@ fn an_edit_to_many_photos_or_to_more_than_photos_is_worked_out_whole() { let _ = s.visible_cloned(); assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2)); } + +#[test] +fn the_albums_counts_followed_through_edits_are_the_counts_made_from_every_photo() { + use lightcraft_catalog::tally::AlbumTally; + let (mut s, mut ids, album, smart) = library(240); + // more smart albums: picks, well rated or keyworded ones, one that looks into an album of chosen photos + for rules in [ + json!({"ruleSet": {"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}]}}), + json!({"ruleSet": {"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 4}, {"field": "keywords", "op": "contains", "value": "pets"}]}}), + json!({"album": album, "rating": 3}), + // what the edits below change besides: a label, a slider, when a photo was taken, what its file is called + json!({"label": "red"}), + json!({"edited": true}), + json!({"dateFrom": "2025-06-01"}), + json!({"text": "REN"}), + ] { + let made = s.execute("album.createSmart", &json!({"name": "More", "rules": rules})); + assert!(made.is_ok(), "{rules}: {made:?}"); + } + assert!(s.catalog.album(lightcraft_catalog::AlbumId(smart)).is_some_and(|a| a.is_smart())); + let fresh = |s: &Session| -> Vec<(u64, usize)> { + let mut v: Vec<(u64, usize)> = s.catalog.albums().map(|a| (a.id.0, s.catalog.album_count(a.id))).collect(); + v.sort_unstable(); + v + }; + let shown = |tally: &AlbumTally| -> Vec<(u64, usize)> { + let mut v: Vec<(u64, usize)> = tally.counts().iter().map(|(id, n)| (id.0, *n)).collect(); + v.sort_unstable(); + v + }; + let mut tally = AlbumTally::of(&s.catalog); + assert_eq!(shown(&tally), fresh(&s)); + let mut dice = Dice(23); + let (mut followed, mut whole) = (0, 0); + for step in 0..900 { + edit(&mut s, &mut dice, &mut ids, album); + // (not every frame draws the albums: sometimes several edits pass between two looks) + if dice.roll(4) == 0 { + continue; + } + let moved = tally.revision() != s.catalog.revision; + match (moved, tally.update(&s.catalog)) { + (true, true) => followed += 1, + (true, false) => whole += 1, + // (an edit that was refused changed nothing: nothing to follow) + (false, _) => {} + } + assert_eq!(shown(&tally), fresh(&s), "after edit {step}"); + } + assert!(followed > 300 && whole > 20, "followed {followed} times, counted whole {whole} times"); + // a photo deleted: the album of chosen photos it is in counts one less, without every photo being asked + let in_album = s + .catalog + .album(lightcraft_catalog::AlbumId(album)) + .and_then(|a| a.photos.iter().copied().find(|id| s.catalog.photo(*id).is_some_and(|p| !p.deleted))); + if let Some(id) = in_album { + let before = tally.counts()[&lightcraft_catalog::AlbumId(album)]; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(tally.update(&s.catalog), "followed"); + assert_eq!(tally.counts()[&lightcraft_catalog::AlbumId(album)], before - 1); + } + // three hundred photos rated at once: counted from every photo, and right + let many: Vec<u64> = s.visible_cloned().iter().take(300).map(|id| id.0).collect(); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + s.execute("library.filter", &json!({"rating": 0, "text": ""})).unwrap(); + let many: Vec<u64> = if many.len() >= 300 { many } else { s.visible_cloned().iter().take(300).map(|id| id.0).collect() }; + if many.len() > 256 { + s.execute("library.select", &json!({"ids": many})).unwrap(); + s.execute("photo.rate", &json!({"rating": 5})).unwrap(); + assert!(!tally.update(&s.catalog), "more photos than are followed one by one"); + assert_eq!(shown(&tally), fresh(&s)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b2fc4975e..f4ac68a00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1525,8 +1525,12 @@ pub struct Caches { counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>, filter_values: Option<(u64, std::sync::Arc<FilterValues>)>, - album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>, - /// How often the album counts were recomputed (tests check that unchanged frames don't). + /// The albums' counts: the minute they are of (while a smart album asks about "the last N days"), the tally + /// that follows edits, and the counts as the panels take them. + album_counts: + Option<(String, lightcraft_catalog::tally::AlbumTally, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>, + /// How often the album counts were made from every photo (tests check that unchanged frames, and edits to a + /// photo or a few, don't). pub album_count_scans: usize, /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). rules_view: Option<(u64, std::sync::Arc<RulesView>)>, @@ -1829,21 +1833,37 @@ impl Caches { ) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>> { let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - let k = key_of((cat.revision, minute)); - match &self.album_counts { - Some((h, v)) if *h == k => v.clone(), - _ => { - if relative { - // "in the last N days" counts back from the session clock, as in the grid - lightcraft_catalog::rules::set_now(Some(now.to_string())); - } - let v: std::sync::Arc<std::collections::HashMap<_, _>> = - std::sync::Arc::new(cat.albums().map(|a| (a.id, cat.album_count(a.id))).collect()); - self.album_count_scans += 1; - self.album_counts = Some((k, v.clone())); - v - } + // the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart + // album asking only those photos again; anything more, or a new minute, is counted from every photo + let kept = self.album_counts.take().filter(|(of, _, _)| of == minute); + if let Some((_, tally, shown)) = &kept + && tally.revision() == cat.revision + { + let shown = shown.clone(); + self.album_counts = kept; + return shown; } + if relative { + // "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks + // only the photos it touched, at this second; the others were asked earlier in the minute. That is as + // far out as the counts already were between edits, and the next minute counts them all again.) + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let tally = match kept { + Some((_, mut tally, _)) => { + if !tally.update(cat) { + self.album_count_scans += 1; + } + tally + } + None => { + self.album_count_scans += 1; + lightcraft_catalog::tally::AlbumTally::of(cat) + } + }; + let shown = std::sync::Arc::new(tally.counts().clone()); + self.album_counts = Some((minute.to_string(), tally, shown.clone())); + shown } } @@ -1884,16 +1904,17 @@ mod cache_tests { c.album_counts(&cat, "2026-09-30T12:00:40"); } assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); - // metadata change + // a photo rated: followed from that photo, not counted from every one cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); + assert_eq!(c.album_count_scans, 1); // rule change let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); - assert_eq!(c.album_count_scans, 4); + assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not"); lightcraft_catalog::rules::set_now(None); } diff --git a/docs/whats-new.md b/docs/whats-new.md index a0699f77a..654b285ad 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -19,8 +19,12 @@ keywords, dates, people and counts; a keyword leaves the folders, the dates and the counts as they are. With both side panels open, the frame after a keyword went from about 156 ms to about 54 ms in that library (2 ms with the panels closed, from 71 ms). -- Still tallied from every photo on each edit: the count beside each album and smart album in the left panel (about - 25 ms for a smart album in that library), and the filter bar's lists of cameras, lenses and keywords. +- The count beside each smart album in the left panel follows an edit the same way: the album asks only the photos + the edit touched, where it asked every photo of the library after each edit (about 25 ms an album in that library). + With that, the frame after a keyword is at about 29 ms there. New rules, a change to an album or the keyword list, + an edit to more than 256 photos, and each new minute for an album that asks about "the last N days" are still + counted from every photo. +- Still tallied from every photo on each edit: the filter bar's lists of cameras, lenses and keywords. ### A view can hold its photos until you refresh it - View ▸ Hold View Until Refreshed (off unless you turn it on; kept with the library). With it on, an edit that makes From 366e6891a06e5f50706f3649711ac949b072d5ac Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Sun, 11 Oct 2026 20:18:04 -0400 Subject: [PATCH 145/315] LRC-LIB-KEYWORDS: the Keywords panel folds its sections and can group the selection's keywords The titles of Keywords and of the Keyword List fold their sections shut (kept in the UI state as foldedSections), so the list of every keyword is reachable however many keywords the selection has. Settings > Interface > Keywords of the selection > Grouped by parent (off by default): keywords inside the same keyword share a line naming it, their chips say only what is below it and sit side by side, a list of more than ten lines stops after eight with "+N more", and the suggestions say the keyword's own name. A selection without keywords says "No keywords yet", as the other two views of the box do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 20 ++ crates/ui-egui/src/panels/keyword_list.rs | 8 +- crates/ui-egui/src/panels/keywording.rs | 394 ++++++++++++++++++++-- crates/ui-egui/src/panels/right.rs | 64 +++- crates/ui-egui/src/panels/settings.rs | 10 + crates/ui-egui/src/state.rs | 36 ++ crates/ui-egui/src/tests_keywording.rs | 146 ++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 10 + 9 files changed, 645 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..4a26e880b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1513,6 +1513,9 @@ pub struct Caches { keyword_names: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>, /// The Keywording box's chips, by library revision and selection. keyword_chips: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>, + /// The Keywording box's chips grouped by the keyword they are inside, by library revision and + /// selection. + keyword_groups: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Group>>)>, /// The Keyword List's tick boxes, by library revision and selection. keyword_ticks: Option<(u64, u64, std::sync::Arc<panels::keyword_list::Ticks>)>, folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>, @@ -1653,6 +1656,23 @@ impl Caches { } } } + /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). + pub(crate) fn keyword_groups( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc<Vec<panels::keywording::Group>> { + let key = key_of(selection); + match &self.keyword_groups { + Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(), + _ => { + let chips = self.keyword_chips(cat, selection); + let g = std::sync::Arc::new(panels::keywording::grouped(&chips)); + self.keyword_groups = Some((cat.revision, key, g.clone())); + g + } + } + } /// How many selected photos have each keyword (the Keyword List's tick boxes). pub(crate) fn keyword_ticks( &mut self, diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..647eee777 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -21,7 +21,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); crate::widgets::divider(ui); // the title, where a dragged keyword goes back to the top level - let (title, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + let (title, title_resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); register(ui.ctx(), "keywordList:topLevel", title); let dragging = app.ui.dragging_keyword.clone(); let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); @@ -29,14 +29,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if dragging.is_some() && over_title { ui.painter().rect_stroke(title.shrink2(vec2(16.0, 6.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } + // (it folds the list shut; while a keyword is dragged it says where the drop goes) let heading = if dragging.is_some() { crate::i18n::tr("Drop here for the top level") } else { crate::i18n::tr("Keyword List") }; - ui.painter().text(pos2(title.left() + 24.0, title.center().y + 2.0), Align2::LEFT_CENTER, heading, t.semibold(15.0), t.text); + let open = super::right::fold_title(app, ui, "keywordList", title, &title_resp, heading); if released && over_title && let Some(k) = dragging.clone() { drop_keyword(app, ui.ctx(), &k, None); } + if !open { + return; + } let fid = egui::Id::new("keyword-list-filter"); let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default(); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 857498599..7913c0ff3 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -40,7 +40,7 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let prefix = if export { "keywordExport" } else { "keywordContaining" }; ui.horizontal_wrapped(|ui| { for n in names.iter() { - let (body, _) = chip(ui, &n.path, !n.on_all(), false); + let (body, _) = chip(ui, &n.path, &n.path, !n.on_all(), false, ui.max_rect().width()); register(ui.ctx(), format!("{prefix}:{}", n.path), body.rect); if n.on_all() { body.on_hover_text(&n.path); @@ -52,21 +52,33 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// One chip, measured before it is placed so that a wrapping row moves it whole to the next line: -/// the name (cut short to fit the row) and, with `cross`, a × at its end. A `partial` one (only -/// some of the selected photos) is dimmed and marked with an asterisk. Returns the name's response -/// and the ×'s. -fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Response, Option<egui::Response>) { +/// Chips on a line of the grouped box are this far apart. +const GAP: f32 = 6.0; +/// The room a chip's × takes at its end. +const CROSS_W: f32 = 18.0; + +/// What a chip shows of `name` in a row `row_w` wide (cut short to fit it), and the chip's width. +fn chip_size(ui: &egui::Ui, name: &str, partial: bool, cross: bool, row_w: f32) -> (String, f32) { + let font = Tokens::get(ui.ctx()).font(13.0); + let cross_w = if cross { CROSS_W } else { 0.0 }; + let room = (row_w - 16.0 - cross_w).max(24.0); + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x; + let shown = chip_label(name, partial, room, measure); + let w = measure(&shown) + 16.0 + cross_w; + (shown, w) +} + +/// One chip of the keyword `path`, measured before it is placed so that a wrapping row moves it +/// whole to the next line: `name` (the path, or what the grouped box shows of it; cut short to fit +/// a row `row_w` wide) and, with `cross`, a × at its end. A `partial` one (only some of the +/// selected photos) is dimmed and marked with an asterisk. Returns the name's response and the ×'s. +fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, row_w: f32) -> (egui::Response, Option<egui::Response>) { use egui::{Align2, Rect, Sense, pos2, vec2}; let t = Tokens::get(ui.ctx()); let font = t.font(13.0); let color = if partial { t.text_dim } else { t.text_label }; - let cross_w = if cross { 18.0 } else { 0.0 }; - // at most the row's width - let room = (ui.max_rect().width() - 16.0 - cross_w).max(24.0); - let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; - let shown = chip_label(path, partial, room, measure); - let w = measure(&shown) + 16.0 + cross_w; + let cross_w = if cross { CROSS_W } else { 0.0 }; + let (shown, w) = chip_size(ui, name, partial, cross, row_w); // the row places it; its name and × answer under ids of their own keyword, so an open menu // stays with its keyword when the chips change let (rect, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); @@ -118,35 +130,141 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { } /// The selection's keywords as chips: the name (right-click for its menu), and × to take it off -/// every selected photo. One only some of them have is marked with an asterisk. +/// every selected photo. One only some of them have is marked with an asterisk. Whole names, or +/// grouped by the keyword they are inside (Settings ▸ Interface). pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let selection = app.session.targets(&serde_json::Value::Null); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); + if chips.is_empty() { + let t = Tokens::get(ui.ctx()); + let r = ui.label(egui::RichText::new(crate::i18n::tr("No keywords yet")).color(t.text_dim)); + register(ui.ctx(), "keywordChipsEmpty", r.rect); + return; + } + if app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped { + let groups = app.caches.keyword_groups(&app.session.catalog, &selection); + chip_groups(app, ui, &groups); + return; + } ui.horizontal_wrapped(|ui| { for c in chips.iter() { - let (body, x) = chip(ui, &c.path, !c.on_all(), true); - register(ui.ctx(), format!("keywordChip:{}", c.path), body.rect); - if let Some(x) = x { - register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); - if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); - } - } - // one tooltip: the whole name (it may be cut short), and how many have it - let full = c.path.replace('|', " › "); - let body = if c.on_all() { - body.on_hover_text(full) - } else { - register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); - let n = crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of); - body.on_hover_text(format!("{full}\n{n}")) - }; - let c = c.clone(); - body.context_menu(|ui| menu(app, ui, &c)); + let (body, x) = chip(ui, &c.path, &c.path, !c.on_all(), true, ui.max_rect().width()); + chip_acts(app, ui, c, body, x); } }); } +/// A drawn chip does its work: its × takes the keyword off the selected photos, its name has the +/// tooltip and the menu. +fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Response, x: Option<egui::Response>) { + register(ui.ctx(), format!("keywordChip:{}", c.path), body.rect); + if let Some(x) = x { + register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); + if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); + } + } + // one tooltip: the whole name (it may be cut short), and how many have it + let full = c.path.replace('|', " › "); + let body = if c.on_all() { + body.on_hover_text(full) + } else { + register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); + let n = crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of); + body.on_hover_text(format!("{full}\n{n}")) + }; + let c = c.clone(); + body.context_menu(|ui| menu(app, ui, &c)); +} + +/// When a list is shortened, only this many chips are measured for it: more than fit the lines +/// shown, however narrow they are. +const MEASURED: usize = 128; + +/// The grouped box: each group's parent on a line of its own, its chips under it side by side, +/// and "+N more" where a long list stops. +fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group]) { + use egui::{Align2, Sense, vec2}; + let t = Tokens::get(ui.ctx()); + let avail = ui.available_width(); + let all = app.ui.keyword_chips_all; + // the widths the lines are planned with (past what a shortened list can show: a line each) + let mut measured = 0_usize; + let sizes: Vec<(bool, Vec<f32>)> = groups + .iter() + .map(|g| { + let widths = g + .chips + .iter() + .map(|(name, c)| { + measured = measured.saturating_add(1); + if all || measured <= MEASURED { chip_size(ui, name, !c.on_all(), true, avail).1 } else { avail } + }) + .collect(); + (!g.parent.is_empty(), widths) + }) + .collect(); + let small = t.font(11.5); + for (i, line) in lines(&sizes, avail, GAP, all).into_iter().enumerate() { + match line { + Line::Heading(g) => { + let Some(group) = groups.get(g) else { continue }; + ui.add_space(if i == 0 { 0.0 } else { 8.0 }); + let (r, resp) = ui.allocate_exact_size(vec2(avail, 18.0), Sense::hover()); + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), small.clone(), t.text_label).size().x; + let shown = chip_label(&group.parent, false, avail, measure); + let full = group.parent.replace('|', " › "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &full)); + ui.painter().text(r.left_center(), Align2::LEFT_CENTER, shown, small.clone(), t.text_label); + register(ui.ctx(), format!("keywordGroup:{}", group.parent), r); + } + Line::Chips(g, row) => { + let Some(group) = groups.get(g) else { continue }; + ui.add_space(if i == 0 { 0.0 } else { 4.0 }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = GAP; + for (name, c) in group.chips.get(row).into_iter().flatten() { + let (body, x) = chip(ui, &c.path, name, !c.on_all(), true, avail); + chip_acts(app, ui, c, body, x); + } + }); + } + Line::More(n) => { + ui.add_space(6.0); + let label = crate::i18n::tr_format!("+{n} more", n = n); + let r = ui.add(egui::Button::new(egui::RichText::new(label).color(t.text_label)).corner_radius(10.0)); + register(ui.ctx(), "keywordChipsMore", r.rect); + if r.on_hover_text(crate::i18n::tr("Show every keyword of the selected photos")).clicked() { + app.ui.keyword_chips_all = true; + } + } + Line::Fewer => { + ui.add_space(6.0); + let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Show fewer")).color(t.text_label)).corner_radius(10.0)); + register(ui.ctx(), "keywordChipsFewer", r.rect); + if r.clicked() { + app.ui.keyword_chips_all = false; + } + } + } + } +} + +/// What the grouped box's suggestions say of the keywords `paths`: each one's own name, or its +/// whole path (" › " between its levels) when another of them has the same name. +pub(crate) fn suggestion_names(paths: &[String]) -> Vec<String> { + use lightcraft_catalog::keywords::SEP; + let leaf = |p: &str| p.rsplit(SEP).next().unwrap_or(p).to_lowercase(); + let mut named: std::collections::HashMap<String, usize> = Default::default(); + for p in paths { + *named.entry(leaf(p)).or_default() += 1; + } + paths + .iter() + .map(|p| if named.get(&leaf(p)).is_some_and(|n| *n > 1) { p.replace(SEP, " › ") } else { p.rsplit(SEP).next().unwrap_or(p).to_string() }) + .collect() +} + /// A chip's label within `room` (as `measure` measures text): the keyword's path with " › " /// between its levels, and an asterisk when it is `partial`. Too long, it is cut from the front so /// that the keyword's own name and the asterisk stay ("…Lisbon › Belém tower *"). @@ -171,6 +289,115 @@ pub(crate) fn chip_label(path: &str, partial: bool, room: f32, measure: impl Fn( best } +/// Keywords of the selection that are inside the same keyword, as the grouped box shows them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Group { + /// The keyword they are inside, spelled as the first of them spells it; empty for the keywords + /// that share none with another. + pub parent: String, + /// Each keyword's name below `parent`, and its chip. + pub chips: Vec<(String, Chip)>, +} + +/// The chips grouped by the keyword they are inside: the deepest one a chip shares with another +/// chip ("Places|Italy" for "Places|Italy|Rome" next to "Places|Italy|Venice"; "Transport" for +/// "Transport|Rail|Night Train" next to "Transport|Ferry"). Those that share none come first, in a group +/// without a parent; then the groups by name, each in the chips' order. +pub(crate) fn grouped(chips: &[Chip]) -> Vec<Group> { + use lightcraft_catalog::keywords::SEP; + let sep = SEP.to_string(); + // a path's first `depth` levels, and the rest + let head = |levels: &[&str], depth: usize| levels.get(..depth).unwrap_or_default().join(&sep); + let tail = |levels: &[&str], depth: usize| levels.get(depth..).unwrap_or_default().join(&sep); + // how many chips are inside each keyword (by its lower-case path) + let mut inside: std::collections::HashMap<String, usize> = Default::default(); + for c in chips { + let levels: Vec<&str> = c.path.split(SEP).collect(); + for depth in 1..levels.len() { + *inside.entry(head(&levels, depth).to_lowercase()).or_default() += 1; + } + } + let mut groups: std::collections::BTreeMap<String, Group> = Default::default(); + for c in chips { + let levels: Vec<&str> = c.path.split(SEP).collect(); + let shared = |depth: &usize| inside.get(&head(&levels, *depth).to_lowercase()).is_some_and(|n| *n > 1); + let depth = (1..levels.len()).rev().find(shared).unwrap_or(0); + let parent = head(&levels, depth); + groups.entry(parent.to_lowercase()).or_insert_with(|| Group { parent, chips: Vec::new() }).chips.push((tail(&levels, depth), c.clone())); + } + groups.into_values().collect() +} + +/// Items `widths` wide laid out left to right in rows `avail` wide, `gap` apart: the items of each +/// row. An item wider than a row has a row to itself. +pub(crate) fn rows_of(widths: &[f32], avail: f32, gap: f32) -> Vec<std::ops::Range<usize>> { + let mut rows = Vec::new(); + let (mut start, mut used) = (0, 0.0_f32); + for (i, w) in widths.iter().enumerate() { + let w = if w.is_finite() { w.max(0.0) } else { 0.0 }; + if i > start && used + gap + w > avail { + rows.push(start..i); + (start, used) = (i, w); + } else { + used = if i == start { w } else { used + gap + w }; + } + } + if start < widths.len() { + rows.push(start..widths.len()); + } + rows +} + +/// A line of the grouped box. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Line { + /// The name of a group's parent. + Heading(usize), + /// Chips of a group (by their place in it). + Chips(usize, std::ops::Range<usize>), + /// "+N more": this many chips are not shown. + More(usize), + /// "Show fewer", under a list shown whole that would otherwise be shortened. + Fewer, +} + +/// The grouped box shows this many lines of a long list (then "+N more")… +pub(crate) const LINES: usize = 8; +/// …but a list only this many lines longer is shown whole: "+1 more" saves nothing. +pub(crate) const SLACK: usize = 2; + +/// The lines of the grouped box: for each group whether it has a heading and how wide its chips +/// are, in rows `avail` wide with chips `gap` apart. A list longer than [`LINES`] + [`SLACK`] +/// lines stops after [`LINES`] and says how many chips are left, unless `all` of it is asked for. +pub(crate) fn lines(groups: &[(bool, Vec<f32>)], avail: f32, gap: f32, all: bool) -> Vec<Line> { + let mut out = Vec::new(); + for (g, (heading, widths)) in groups.iter().enumerate() { + if widths.is_empty() { + continue; + } + if *heading { + out.push(Line::Heading(g)); + } + out.extend(rows_of(widths, avail, gap).into_iter().map(|row| Line::Chips(g, row))); + } + if out.len() <= LINES + SLACK { + return out; + } + if all { + out.push(Line::Fewer); + return out; + } + out.truncate(LINES); + if matches!(out.last(), Some(Line::Heading(_))) { + out.pop(); + } + let count = |lines: &[Line]| lines.iter().map(|l| if let Line::Chips(_, row) = l { row.len() } else { 0 }).sum::<usize>(); + let total: usize = groups.iter().map(|(_, widths)| widths.len()).sum(); + let left = total.saturating_sub(count(&out)); + out.push(Line::More(left)); + out +} + /// A keyword of the selection, as a chip shows it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Chip { @@ -358,7 +585,8 @@ mod tests { for _ in 0..3 { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { ui.set_max_width(120.0); - let _ = chip(ui, "Places|Portugal|Lisbon|Belém tower", true, true); + let path = "Places|Portugal|Lisbon|Belém tower"; + let _ = chip(ui, path, "Belém tower", true, true, 120.0); }); out.textures_delta.clear(); if let Some(update) = out.platform_output.accesskit_update.take() { @@ -370,6 +598,106 @@ mod tests { assert!(has("Button", "Remove “Places › Portugal › Lisbon › Belém tower”"), "{found:?}"); } + fn chip_of(path: &str) -> Chip { + Chip { path: path.into(), have: 1, of: 1 } + } + + fn groups_of(paths: &[&str]) -> Vec<(String, Vec<String>)> { + let chips: Vec<Chip> = paths.iter().map(|p| chip_of(p)).collect(); + grouped(&chips).into_iter().map(|g| (g.parent, g.chips.into_iter().map(|(name, _)| name).collect())).collect() + } + + /// Given keywords inside the same keyword, when the box groups them, then they share a line + /// naming that keyword and each says only what is below it; a keyword alone below its parent + /// joins the nearest keyword it shares with another, and one that shares none comes first + /// under no name. + #[test] + fn keywords_group_under_the_keyword_they_share() { + let got = groups_of(&[ + "beach", + "Places|Italy|Florence", + "Places|Italy|Rome", + "Places|Portugal|Lisbon", + "Places|Portugal|Porto", + "Transport|Ferry", + "Transport|Rail|Night Train", + ]); + let want: Vec<(String, Vec<String>)> = [ + ("", vec!["beach"]), + ("Places|Italy", vec!["Florence", "Rome"]), + ("Places|Portugal", vec!["Lisbon", "Porto"]), + ("Transport", vec!["Ferry", "Rail|Night Train"]), + ] + .into_iter() + .map(|(p, c)| (p.to_string(), c.into_iter().map(str::to_string).collect())) + .collect(); + assert_eq!(got, want); + } + + /// A keyword and one inside it are both inside their shared parent; a hierarchy with one + /// keyword stays whole, under no name; spelling differences of the parent don't split a group. + #[test] + fn grouping_holds_at_the_edges() { + assert_eq!(groups_of(&["A|B", "A|B|C"]), [("A".to_string(), vec!["B".to_string(), "B|C".to_string()])]); + assert_eq!(groups_of(&["Places|Portugal|Lisbon"]), [(String::new(), vec!["Places|Portugal|Lisbon".to_string()])]); + assert_eq!(groups_of(&["Places|Rome", "places|Athens"]), [("Places".to_string(), vec!["Rome".to_string(), "Athens".to_string()])]); + assert_eq!(groups_of(&["İstanbul|Köprü", "i̇stanbul|Kule"]).len(), 1, "a parent whose lower case is longer"); + assert!(groups_of(&[]).is_empty()); + // every chip is in exactly one group + let paths = ["a", "a|b", "a|b|c", "a|d", "e|f", "g"]; + let n: usize = groups_of(&paths).iter().map(|(_, c)| c.len()).sum(); + assert_eq!(n, paths.len()); + } + + /// Chips fill a row until the next would not fit; one wider than the row has a row to itself, + /// and widths that are not numbers don't break the layout. + #[test] + fn chips_fill_rows() { + assert_eq!(rows_of(&[40.0, 40.0, 40.0], 100.0, 6.0), [0..2, 2..3]); + assert_eq!(rows_of(&[40.0, 54.0], 100.0, 6.0).len(), 1, "exactly fits"); + assert_eq!(rows_of(&[300.0, 20.0], 100.0, 6.0), [0..1, 1..2]); + assert_eq!(rows_of(&[20.0, 300.0, 20.0], 100.0, 6.0), [0..1, 1..2, 2..3]); + assert!(rows_of(&[], 100.0, 6.0).is_empty()); + assert_eq!(rows_of(&[f32::NAN, f32::INFINITY, 10.0], 100.0, 6.0).iter().map(|r| r.len()).sum::<usize>(), 3); + assert_eq!(rows_of(&[10.0, 10.0], 0.0, 6.0), [0..1, 1..2], "no room at all"); + } + + /// Given a list of more than LINES + SLACK lines, when it isn't asked for whole, then LINES + /// lines show and the rest is counted; a heading is never the last line shown. Asked for + /// whole, it ends in "Show fewer". A shorter list is shown as it is. + #[test] + fn a_long_list_is_shortened_and_says_how_much_is_left() { + // a heading and 3 chips, a heading and 9 (one chip a row): 14 lines + let groups = vec![(true, vec![90.0; 3]), (true, vec![90.0; 9])]; + let short = lines(&groups, 100.0, 6.0, false); + assert_eq!(short.len(), LINES + 1, "{short:?}"); + assert_eq!(short.last(), Some(&Line::More(6)), "3 + 3 chips shown: {short:?}"); + let whole = lines(&groups, 100.0, 6.0, true); + assert_eq!(whole.len(), 15); + assert_eq!(whole.last(), Some(&Line::Fewer)); + // the eighth line would be the second group's heading: it goes too + let cut = lines(&[(false, vec![90.0; 7]), (true, vec![90.0; 6])], 100.0, 6.0, false); + assert_eq!(cut.len(), LINES, "{cut:?}"); + assert_eq!(cut.last(), Some(&Line::More(6))); + assert!(cut.iter().all(|l| !matches!(l, Line::Heading(_))), "{cut:?}"); + // within the slack: whole, with neither button + let fits = lines(&[(true, vec![90.0; 9])], 100.0, 6.0, false); + assert_eq!(fits.len(), 10); + assert!(fits.iter().all(|l| matches!(l, Line::Heading(_) | Line::Chips(..)))); + assert_eq!(lines(&[(true, vec![90.0; 9])], 100.0, 6.0, true), fits, "nothing to show fewer of"); + assert!(lines(&[], 100.0, 6.0, false).is_empty()); + assert!(lines(&[(true, vec![])], 100.0, 6.0, false).is_empty(), "a group without chips has no heading"); + } + + /// The grouped box's suggestions say a keyword's own name; two with the same name say their + /// whole paths, so they can be told apart. + #[test] + fn suggestions_are_named_apart() { + let paths: Vec<String> = ["Italy|Rome|Market", "Spain|Madrid|market", "Places|Lisbon", "sea"].iter().map(|s| s.to_string()).collect(); + assert_eq!(suggestion_names(&paths), ["Italy › Rome › Market", "Spain › Madrid › market", "Lisbon", "sea"]); + assert!(suggestion_names(&[]).is_empty()); + } + /// "Keywords & Containing Keywords": every level of the selection's keywords, flat, each /// counted once per photo. #[test] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..cdbbf6132 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -20,10 +20,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.ui.right == RightPanel::Keywords { egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - header(ui, "Keywords"); - padded(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Select photos to give them keywords.")).color(t.text_dim)); - }); + if fold_header(app, ui, "keywording", "Keywords") { + padded(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Select photos to give them keywords.")).color(t.text_dim)); + }); + } super::keyword_list::show(app, ui); }); return; @@ -57,6 +58,36 @@ pub fn header(ui: &mut egui::Ui, title: &str) { ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text); } +/// A panel title that folds its section shut and open again (`section:{id}`; kept in +/// [`crate::state::UiState::folded_sections`]). Says whether the section is open. +pub fn fold_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); + let title = crate::i18n::tr(title); + fold_title(app, ui, id, r, &resp, title) +} + +/// [`fold_header`] for a title whose room is already taken (`r`, answering as `resp`). +pub fn fold_title(app: &mut LightcraftApp, ui: &egui::Ui, id: &str, r: Rect, resp: &egui::Response, title: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let open = !app.ui.section_folded(id); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); + register(ui.ctx(), format!("section:{id}"), r); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); + } + if resp.has_focus() { + p.rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + p.text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text); + let chevron = Rect::from_center_size(pos2(r.right() - 30.0, r.center().y + 2.0), vec2(14.0, 14.0)); + paint(p, chevron, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); + if resp.clicked() { + app.ui.toggle_folded(id); + } + open +} + pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); @@ -818,7 +849,15 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.catalog.photo(id).is_none() { return; } - header(ui, "Keywords"); + if fold_header(app, ui, "keywording", "Keywords") { + keywording(app, ui); + } + super::keyword_list::show(app, ui); +} + +/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set, +/// the painter and the suggestions. +fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); padded(ui, |ui| { let kid = egui::Id::new("kw-input"); @@ -882,10 +921,18 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); if !suggestions.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); + // grouped: the keyword's own name, its whole path in the tooltip + let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped; + let names = if grouped { super::keywording::suggestion_names(&suggestions) } else { Vec::new() }; ui.horizontal_wrapped(|ui| { - for k in suggestions { - let r = ui - .add(egui::Button::new(egui::RichText::new(format!("+ {}", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0)); + if grouped { + ui.spacing_mut().item_spacing = vec2(6.0, 4.0); + } + for (i, k) in suggestions.into_iter().enumerate() { + let whole = k.replace('|', " › "); + let name = names.get(i).unwrap_or(&whole); + let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0)); + let r = if grouped { r.on_hover_text(&whole) } else { r }; register(ui.ctx(), format!("kwSuggest:{k}"), r.rect); if r.clicked() { let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); @@ -897,7 +944,6 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } }); - super::keyword_list::show(app, ui); } /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a0717ccfd..ca5e408fc 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -652,6 +652,16 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ); }); check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); + heading(ui, t, crate::i18n::tr("Keywords")); + row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| { + use crate::state::KeywordChips as K; + choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips); + }); + hint( + ui, + t, + crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), + ); heading(ui, t, crate::i18n::tr("Detail")); check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); row(ui, t, crate::i18n::tr("Info overlay"), |ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..f3b40a95e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -283,6 +283,8 @@ pub struct AppSettings { /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, + /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. + pub keyword_chips: KeywordChips, } impl Default for AppSettings { @@ -308,6 +310,7 @@ impl Default for AppSettings { light_theme: LightTheme::Silver, favorite_themes: Vec::new(), display_profile: String::new(), + keyword_chips: KeywordChips::default(), } } } @@ -490,6 +493,18 @@ pub enum KeywordingView { WillExport, } +/// How the Keywording box lays out the selection's keywords. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordChips { + /// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon"). + #[default] + Paths, + /// Keywords inside the same keyword share a line naming it, and their chips say only what is + /// below it; long lists and the suggestions are shortened. + Grouped, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -711,6 +726,13 @@ pub struct UiState { pub keyword_painter: Option<String>, /// What the Keywording box shows (Lightroom Classic's Keyword Tags views). pub keywording_view: KeywordingView, + /// The Keywording box, grouped: every keyword is shown, however many lines that takes ("+N + /// more" was clicked). + #[serde(skip)] + pub keyword_chips_all: bool, + /// Sections of the right panel folded shut by a click on their title (`keywording`, + /// `keywordList`); the others are open. + pub folded_sections: Vec<String>, /// The Keyword List's open levels (lower-case paths). pub keyword_list_open: Vec<String>, /// The keyword picked in the Keyword List (− deletes it, Edit edits it). @@ -1141,6 +1163,8 @@ impl Default for UiState { second_window: false, keyword_painter: None, keywording_view: KeywordingView::default(), + keyword_chips_all: false, + folded_sections: Vec::new(), keyword_list_open: Vec::new(), keyword_list_selected: None, dragging_keyword: None, @@ -1182,6 +1206,18 @@ impl UiState { self.grading_hidden = h.iter().any(|x| *x).then_some((photo, h)); } + /// A right-panel section that folds by its title is folded shut. + pub fn section_folded(&self, id: &str) -> bool { + self.folded_sections.iter().any(|s| s == id) + } + pub fn toggle_folded(&mut self, id: &str) { + if self.section_folded(id) { + self.folded_sections.retain(|s| s != id); + } else { + self.folded_sections.push(id.to_string()); + } + } + pub fn section_open(&self, id: &str) -> bool { self.open_sections.iter().any(|s| s == id) } diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..ed9ecbd51 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -227,3 +227,149 @@ fn containing_keywords_view_lists_parents_too() { } assert!(!has(&h, "keywordChip:beach"), "the chips give way"); } + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn count(h: &Headless, prefix: &str) -> usize { + h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).count() +} + +/// Give both selected photos `keywords`, replacing what they had. +fn give(h: &mut Headless, ids: &[u64], keywords: &[String]) { + ask(h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "keywords": keywords}})); +} + +/// Sixteen keywords inside "Subjects", each long enough to take a line of the panel. +fn sixteen() -> Vec<String> { + (1..=16).map(|i| format!("Subjects|a keyword with quite a long name {i:02}")).collect() +} + +// Given keywords inside the same keyword and the box set to group them, +// when the panel is drawn, +// then they share a line naming that keyword and their chips say only what is below it, side by +// side where they fit; each chip still acts on its whole keyword. +#[test] +fn grouped_keywords_share_a_line_naming_their_parent() { + let (mut h, ids) = two_selected(); + let keywords: Vec<String> = ["Places|Italy|Rome", "Places|Italy|Venice", "Transport|Ferry", "sea"].iter().map(|s| s.to_string()).collect(); + give(&mut h, &ids, &keywords); + assert_eq!( + count(&h, "keywordGroup:"), + 0, + "whole names unless asked: {:?}", + h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordGroup")).collect::<Vec<_>>() + ); + let whole = rect(&h, "keywordChip:Places|Italy|Rome").width(); + + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + let heading = rect(&h, "keywordGroup:Places|Italy"); + let (rome, venice) = (rect(&h, "keywordChip:Places|Italy|Rome"), rect(&h, "keywordChip:Places|Italy|Venice")); + assert!(rome.top() >= heading.bottom(), "under its heading: {rome:?} {heading:?}"); + assert_eq!(rome.top(), venice.top(), "side by side"); + assert!(rome.width() < whole, "the chip says only “Rome”: {} < {whole}", rome.width()); + // a keyword that shares nothing has no heading and comes first + assert!(!has(&h, "keywordGroup:Transport") && !has(&h, "keywordGroup:"), "one keyword is no group"); + assert!(rect(&h, "keywordChip:sea").top() < heading.top()); + // the × still takes the whole keyword off both + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:Places|Italy|Rome"})); + assert!(ids.iter().all(|id| !keywords_of(&h, *id).iter().any(|k| k == "Places|Italy|Rome"))); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k == "Places|Italy|Venice"))); +} + +// Given sixteen keywords and the grouped box, +// when the panel is drawn, +// then eight lines show with "+N more" for the rest, which shows them all; "Show fewer" brings the +// short list back. The Keyword List is nearer the top for it. +#[test] +fn a_long_grouped_list_is_shortened_until_asked_for() { + let (mut h, ids) = two_selected(); + give(&mut h, &ids, &sixteen()); + assert_eq!(count(&h, "keywordChip:"), 16, "whole names: every keyword, as before"); + assert!(!has(&h, "keywordChipsMore")); + let list_before = rect(&h, "field:keywordFilter").top(); + + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + assert_eq!(count(&h, "keywordChip:"), 7, "a heading and seven chips"); + assert!(has(&h, "keywordChipsMore") && !has(&h, "keywordChipsFewer")); + assert!(rect(&h, "field:keywordFilter").top() < list_before - 80.0, "{} from {list_before}", rect(&h, "field:keywordFilter").top()); + + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipsMore"})); + assert_eq!(count(&h, "keywordChip:"), 16); + assert!(has(&h, "keywordChipsFewer") && !has(&h, "keywordChipsMore")); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipsFewer"})); + assert_eq!(count(&h, "keywordChip:"), 7); +} + +// Given the grouped box, chips stay inside the panel and never overlap, whatever their length. +#[test] +fn grouped_chips_stay_inside_the_panel() { + let (mut h, ids) = two_selected(); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + let long = "Places|Portugal|Lisbon|Belém tower and the old town square on the river by the bridge"; + let keywords: Vec<String> = + ["Events|Weddings", "Events|Birthdays", "sunset", "Places|Portugal|Porto", long].iter().map(|s| s.to_string()).collect(); + give(&mut h, &ids, &keywords); + let panel = rect(&h, "panel:right_panel"); + let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); + assert_eq!(chips.len(), 5, "{chips:?}"); + for (i, (id, r)) in chips.iter().enumerate() { + assert!(r.left() >= panel.left() && r.right() <= panel.right(), "{id} inside the panel: {r:?} in {panel:?}"); + for (other, o) in chips.iter().skip(i + 1) { + assert!(!r.intersects(o.shrink(0.5)), "{id} overlaps {other}"); + } + } +} + +// Given a selection without keywords, the box says so (as its other two views do). +#[test] +fn a_selection_without_keywords_says_so() { + let (mut h, ids) = two_selected(); + give(&mut h, &ids, &[]); + assert!(has(&h, "keywordChipsEmpty")); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + assert!(has(&h, "keywordChipsEmpty")); +} + +// Given the Keywords panel, +// when the title of Keywording or of the Keyword List is clicked, +// then that section folds shut and the other stays; a second click opens it, and what is folded is +// kept with the interface's settings. +#[test] +fn the_sections_fold_by_their_titles() { + let (mut h, _) = two_selected(); + assert!(has(&h, "field:keyword") && has(&h, "field:keywordFilter")); + let list_before = rect(&h, "field:keywordFilter").top(); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywording"})); + assert!(!has(&h, "field:keyword") && !has(&h, "keywordChip:beach") && !has(&h, "kwSet:1"), "Keywording is shut"); + assert!(has(&h, "field:keywordFilter"), "the Keyword List stays"); + assert!(rect(&h, "field:keywordFilter").top() < list_before - 100.0); + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::<crate::UiState>(&saved).unwrap().sanitized(); + assert!(back.section_folded("keywording") && !back.section_folded("keywordList")); + assert!(!serde_json::from_str::<crate::UiState>("{}").unwrap().section_folded("keywording"), "settings from before: open"); + + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywordList"})); + assert!(!has(&h, "field:keywordFilter") && !has(&h, "keywordCheck:beach"), "the Keyword List is shut"); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywording"})); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywordList"})); + assert!(has(&h, "field:keyword") && has(&h, "field:keywordFilter") && has(&h, "keywordChip:beach")); +} + +// Given the grouped box, the suggestions say only the keyword's own name (its whole path when two +// of them have the same name) and still give the whole keyword. +#[test] +fn grouped_suggestions_name_the_keyword_alone() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "addKeywords": ["Places|Portugal|Lisbon"]}})); + let whole = rect(&h, "kwSuggest:Places|Portugal|Lisbon").width(); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + assert!(rect(&h, "kwSuggest:Places|Portugal|Lisbon").width() < whole); + ask(&mut h, "ui.clickWidget", json!({"id": "kwSuggest:Places|Portugal|Lisbon"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k == "Places|Portugal|Lisbon"))); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..290732c53 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -704,7 +704,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..91363d1e7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -200,6 +200,16 @@ ## 2026-10-11 +### Keywords panel +- The titles **Keywords** and **Keyword List** in the Keywords panel fold their sections shut and open again, so + the list of every keyword is one click away however many keywords the selected photos have. What is folded is + remembered. +- Settings ▸ Interface ▸ Keywords of the selection ▸ **Grouped by parent** lays the selected photos' keywords out by + the keyword they are inside: "Places › Italy" once, on a line of its own, with Rome, Venice and the others side by + side under it. A long list stops after eight lines with "+N more", and the suggestions say only the keyword's own + name. Whole names, one chip per keyword, stays the default. +- A selection without keywords now says "No keywords yet" where its chips would be. + ### HDR and panorama merges of CR2 keep their colour - Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come From 5ca5e0547cc8a503b247ce3ffae14cb9bdfea5b6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Sun, 11 Oct 2026 20:23:33 -0400 Subject: [PATCH 146/315] LRC-LIB-KEYWORDS: the Add keyword box offers the library's keywords under itself What matches the text being typed (after the last comma) is offered right under the box instead of in the Suggestions block further down: Down / Up move through the offers, Return or a click gives the highlighted one (with the keywords typed before it) and leaves the cursor in the box. With none highlighted, Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside, before Return creates it. The offers have a cache of their own: they and the suggestions are both asked for on every frame of typing, and each looks through the library's keywords. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de-formats.json | 4 + crates/ui-egui/locales/es-formats.json | 4 + crates/ui-egui/locales/fr-formats.json | 4 + crates/ui-egui/locales/ja-formats.json | 4 + crates/ui-egui/locales/pt-br-formats.json | 4 + crates/ui-egui/locales/ru-formats.json | 4 + crates/ui-egui/locales/uk-formats.json | 4 + crates/ui-egui/locales/zh-hans-formats.json | 4 + crates/ui-egui/locales/zh-hant-formats.json | 4 + crates/ui-egui/src/lib.rs | 22 +++ crates/ui-egui/src/panels/keywording.rs | 163 ++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 79 ++++++++-- crates/ui-egui/src/tests_keywording.rs | 132 ++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 4 + 15 files changed, 423 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..286434462 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} von {total} MB", "{pct} %": "{pct} %", "+{n} more": "+{n} weitere", + "Create the keyword “{name}”": "Stichwort „{name}“ erstellen", + "new, inside {parent}": "neu, in {parent}", + "Keywords of {n} selected photos": "Stichwörter von {n} ausgewählten Fotos", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "„{name}“ von {n} Fotos entfernt. Bearbeiten → Rückgängig stellt es wieder her.", "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..86e8cf358 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct} %", "+{n} more": "+{n} más", + "Create the keyword “{name}”": "Crear la palabra clave “{name}”", + "new, inside {parent}": "nueva, dentro de {parent}", + "Keywords of {n} selected photos": "Palabras clave de {n} fotos seleccionadas", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” quitada de {n} fotos. Editar → Deshacer la devuelve.", "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", "{what} merge cancelled": "Combinación {what} cancelada", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..b6b0ed5f0 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} sur {total} Mo", "{pct} %": "{pct} %", "+{n} more": "+{n} autres", + "Create the keyword “{name}”": "Créer le mot-clé « {name} »", + "new, inside {parent}": "nouveau, dans {parent}", + "Keywords of {n} selected photos": "Mots-clés de {n} photos sélectionnées", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "« {name} » retiré de {n} photos. Modifier → Annuler le rétablit.", "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", "{what} merge cancelled": "Fusion {what} annulée", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..646ef0392 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} / {total} MB", "{pct} %": "{pct}%", "+{n} more": "ほか{n}件", + "Create the keyword “{name}”": "キーワード「{name}」を作成", + "new, inside {parent}": "新規、{parent} の中", + "Keywords of {n} selected photos": "選択した写真{n}枚のキーワード", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "「{name}」を写真{n}枚から削除しました。「編集」→「取り消す」で元に戻せます。", "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", "{what} merge cancelled": "{what}合成をキャンセルしました", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..4270e283e 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct}%", "+{n} more": "+{n} mais", + "Create the keyword “{name}”": "Criar a palavra-chave “{name}”", + "new, inside {parent}": "nova, dentro de {parent}", + "Keywords of {n} selected photos": "Palavras-chave de {n} fotos selecionadas", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” removida de {n} fotos. Editar → Desfazer a traz de volta.", "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", "{what} merge cancelled": "{what} mesclagem cancelada", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..0d1b1021d 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} из {total} МБ", "{pct} %": "{pct} %", "+{n} more": "ещё {n}", + "Create the keyword “{name}”": "Создать ключевое слово «{name}»", + "new, inside {parent}": "новое, внутри {parent}", + "Keywords of {n} selected photos": "Ключевые слова выбранных фото: {n}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» убрано с фото: {n}. Правка → Отменить вернёт его.", "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", "{what} merge cancelled": "{what}: слияние отменено", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..bcf55a06c 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done} з {total} МБ", "{pct} %": "{pct} %", "+{n} more": "+{n} ще", + "Create the keyword “{name}”": "Створити ключове слово «{name}»", + "new, inside {parent}": "нове, всередині {parent}", + "Keywords of {n} selected photos": "Ключові слова вибраних фото: {n}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» вилучено з фото: {n}. Редагування → Скасувати поверне його.", "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", "{what} merge cancelled": "Об’єднання {what} скасовано", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..e8e58f891 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", "+{n} more": "还有 {n} 项", + "Create the keyword “{name}”": "创建关键字“{name}”", + "new, inside {parent}": "新关键字,位于 {parent} 内", + "Keywords of {n} selected photos": "所选 {n} 张照片的关键字", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已从 {n} 张照片中移除“{name}”。编辑 → 撤销可恢复。", "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", "{what} merge cancelled": "已取消 {what} 合并", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..394bc9ea9 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -235,6 +235,10 @@ "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", "+{n} more": "還有 {n} 項", + "Create the keyword “{name}”": "建立關鍵字「{name}」", + "new, inside {parent}": "新關鍵字,位於 {parent} 內", + "Keywords of {n} selected photos": "選取的 {n} 張照片的關鍵字", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已從 {n} 張照片移除「{name}」。編輯 → 復原可恢復。", "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", "{what} merge cancelled": "已取消{what}合併", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4a26e880b..386cf021e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1521,6 +1521,9 @@ pub struct Caches { folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>, people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>, suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>, + /// What the Add keyword box offers for the text typed in it, by library revision, the + /// selection's keywords and that text. + completions: Option<(u64, std::sync::Arc<panels::keywording::Offers>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>, filter_values: Option<(u64, std::sync::Arc<FilterValues>)>, @@ -1771,6 +1774,25 @@ impl Caches { } } } + /// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart + /// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks + /// through the library's keywords. + pub(crate) fn completions( + &mut self, + cat: &lightcraft_catalog::Catalog, + current: &[String], + typed: &str, + ) -> std::sync::Arc<panels::keywording::Offers> { + let k = key_of((cat.revision, current, typed)); + match &self.completions { + Some((h, v)) if *h == k => v.clone(), + _ => { + let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed)); + self.completions = Some((k, v.clone())); + v + } + } + } /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> { let k = key_of((cat.revision, current, prefix, n)); diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 7913c0ff3..7cc810bb8 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -289,6 +289,135 @@ pub(crate) fn chip_label(path: &str, partial: bool, room: f32, measure: impl Fn( best } +/// The Add keyword box offers at most this many of the library's keywords. +pub(crate) const OFFERS: usize = 8; + +/// What the Add keyword box offers under itself for the text typed in it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Offers { + /// The library's keywords matching the text (those starting with it first, then the most + /// used), without the ones every selected photo has. + pub keywords: Vec<String>, + /// The keyword Return would create, when the library has none of that name: where it would go + /// (inside the default parent, if there is one). + pub create: Option<String>, +} + +/// The text being typed in the Add keyword box: what follows its last comma. +pub(crate) fn typing(text: &str) -> &str { + text.rsplit(',').next().unwrap_or("").trim() +} + +/// The keywords typed before the last comma, each where the library would put it. +pub(crate) fn typed_before(catalog: &Catalog, text: &str) -> Vec<String> { + let Some((before, _)) = text.rsplit_once(',') else { return Vec::new() }; + before.split(',').map(|s| catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() +} + +/// What the box offers for `typed`, for photos that all have the `current` keywords. +pub(crate) fn offers(catalog: &Catalog, current: &[String], typed: &str) -> Offers { + let name = lightcraft_catalog::keywords::clean(typed); + if name.is_empty() { + return Offers::default(); + } + let keywords = catalog.keyword_suggestions(current, typed, OFFERS); + let create = (!catalog.has_keyword(&name)).then(|| catalog.typed_keyword(typed)); + Offers { keywords, create } +} + +/// What was chosen from the offers under the Add keyword box. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Picked { + /// One of the library's keywords. + Keyword(String), + /// The new keyword, as typed. + Typed, +} + +/// The offers under the Add keyword box (`below` is the box): the library's keywords matching +/// what is typed, each by its own name with the keyword it is inside after it, the one at `at` +/// highlighted (↑ / ↓); then, for a name the library doesn't have, the keyword Return creates. +/// Returns the offer clicked and where the offers were drawn. +pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at: Option<usize>) -> (Option<Picked>, egui::Rect) { + use egui::{Align2, Sense, pos2, vec2}; + use lightcraft_catalog::keywords::SEP; + let t = Tokens::get(ui.ctx()); + let mut picked = None; + let width = (below.width() - 10.0).max(60.0); + let area = egui::Area::new(egui::Id::new("keyword-offers")) + .order(egui::Order::Foreground) + .fixed_pos(below.left_bottom() + vec2(0.0, 2.0)) + .constrain(true); + let shown = area.show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 0.0; + // a line: the keyword's own name, then (dimmer, cut from the front) where it is + let line = |ui: &mut egui::Ui, id: egui::Id, name: &str, place: &str, said: &str, lit: bool| { + let (r, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); + let resp = ui.interact(r, id, Sense::click()).union(resp); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, lit, said)); + if lit || resp.hovered() { + ui.painter().rect_filled(r, 4.0, if lit { t.pressed } else { t.hover }); + } + let font = t.font(13.0); + let measure = |s: &str, f: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), f.clone(), t.text).size().x; + let name = chip_label(name, false, width - 16.0, |s| measure(s, &font)); + let end = r.left() + 8.0 + measure(&name, &font); + ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &name, font, t.text); + let small = t.font(11.5); + let room = r.right() - 8.0 - (end + 10.0); + if !place.is_empty() && room >= 30.0 { + let place = chip_label(place, false, room, |s| measure(s, &small)); + ui.painter().text(pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, place, small, t.text_label); + } + resp + }; + for (i, k) in offers.keywords.iter().enumerate() { + let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k)); + let whole = k.replace(SEP, " › "); + let lit = at == Some(i); + let r = line(ui, egui::Id::new(("keyword-offer", k.to_lowercase())), name, place, &whole, lit); + register(ui.ctx(), format!("kwComplete:{k}"), r.rect); + if lit { + register(ui.ctx(), format!("kwCompleteAt:{k}"), r.rect); + } + if r.clicked() { + picked = Some(Picked::Keyword(k.clone())); + } + } + if let Some(new) = &offers.create { + if !offers.keywords.is_empty() { + ui.add_space(3.0); + let (r, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.field_border); + ui.add_space(3.0); + } + let (place, name) = new.rsplit_once(SEP).unwrap_or(("", new)); + let whole = new.replace(SEP, " › "); + let (place, said) = if place.is_empty() { + (crate::i18n::tr("new keyword").to_string(), crate::i18n::tr_format!("Create the keyword “{name}”", name = whole)) + } else { + let inside = place.replace(SEP, " › "); + ( + crate::i18n::tr_format!("new, inside {parent}", parent = inside), + crate::i18n::tr_format!("Create the keyword “{name}”", name = whole), + ) + }; + // (its place is said in words: not a path to cut from the front) + let r = line(ui, egui::Id::new("keyword-offer-create"), name, "", &said, false); + let small = t.font(11.5); + ui.painter().text(pos2(r.rect.right() - 8.0, r.rect.center().y), Align2::RIGHT_CENTER, place, small, t.text_label); + register(ui.ctx(), "kwCompleteCreate", r.rect); + if r.on_hover_text(crate::i18n::tr("Return creates this keyword and gives it to the selected photos")).clicked() { + picked = Some(Picked::Typed); + } + } + }); + }); + (picked, shown.response.rect) +} + /// Keywords of the selection that are inside the same keyword, as the grouped box shows them. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Group { @@ -689,6 +818,40 @@ mod tests { assert!(lines(&[(true, vec![])], 100.0, 6.0, false).is_empty(), "a group without chips has no heading"); } + /// Given a library with Places › Lisbon, the box offers it for "lis" (not for photos that all + /// have it), says a name the library lacks would be created (inside the default parent when + /// there is one), and offers nothing for nothing. + #[test] + fn the_box_offers_what_matches_and_says_what_is_new() { + use lightcraft_catalog::keywords::KeywordInfo; + let (mut c, _) = library(&[&["Places|Lisbon", "sea"], &["Places|Porto"]]); + let got = offers(&c, &[], "lis"); + assert_eq!(got.keywords, ["Places|Lisbon"]); + assert_eq!(got.create.as_deref(), Some("lis"), "“lis” itself is no keyword"); + assert!(offers(&c, &["Places|Lisbon".into()], "lis").keywords.is_empty(), "they all have it"); + assert_eq!(offers(&c, &[], " SEA ").create, None, "the library has sea"); + assert_eq!(offers(&c, &[], "places").create, None, "and Places, which holds keywords"); + assert_eq!(offers(&c, &[], " "), Offers::default()); + assert_eq!(offers(&c, &[], " | "), Offers::default()); + c.apply(Op::SetKeyword { path: "Inbox".into(), info: Some(KeywordInfo { new_keywords_inside: true, ..KeywordInfo::default() }) }).unwrap(); + assert_eq!(offers(&c, &[], "harbour").create.as_deref(), Some("Inbox|harbour")); + assert_eq!(offers(&c, &[], "a|b").create.as_deref(), Some("a|b"), "a path typed whole goes where it says"); + assert!(offers(&c, &[], "p").keywords.len() <= OFFERS); + } + + /// What is typed is what follows the last comma; what came before it goes along when an offer + /// is taken. + #[test] + fn typing_is_what_follows_the_last_comma() { + let (c, _) = library(&[&["sea"]]); + assert_eq!(typing("sea, lis "), "lis"); + assert_eq!(typing("lis"), "lis"); + assert_eq!(typing("sea,"), ""); + assert_eq!(typing(""), ""); + assert_eq!(typed_before(&c, "sea, , beach ,lis"), ["sea", "beach"]); + assert!(typed_before(&c, "lis").is_empty()); + } + /// The grouped box's suggestions say a keyword's own name; two with the same name say their /// whole paths, so they can be told apart. #[test] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index cdbbf6132..c55385c72 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -862,15 +862,75 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { padded(ui, |ui| { let kid = egui::Id::new("kw-input"); let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default()); + // the keywords every selected photo has: those only some have are still offered and suggested + let selection = app.session.targets(&serde_json::Value::Null); + let have: Vec<String> = + app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); + // while the box has the cursor it offers the library's keywords for what is being typed, + // right under itself; it stays for a click on one of them, which takes the cursor away + let field = crate::text_field::id("field:keyword"); + let oid = egui::Id::new("kw-offers"); + let pass = ui.ctx().cumulative_pass_nr(); + let (typed_then, at_then, drawn, drawn_pass): (String, Option<usize>, Rect, u64) = + ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0)); + let clicking = drawn_pass.checked_add(1) == Some(pass) + && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); + let typed = super::keywording::typing(&text).to_string(); + let offers = (ui.memory(|m| m.has_focus(field)) || clicking) + .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) + .filter(|o| !o.keywords.is_empty() || o.create.is_some()); + // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes + // back to none: Return then gives what was typed, as without offers); Return gives the + // highlighted one + let mut at = at_then.filter(|_| typed_then == typed).filter(|a| offers.as_ref().is_some_and(|o| *a < o.keywords.len())); + let mut picked = None; + if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { + let pressed = |ui: &mut egui::Ui, key| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key)); + if pressed(ui, egui::Key::ArrowDown) { + at = Some(at.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1))); + } + if pressed(ui, egui::Key::ArrowUp) { + at = at.and_then(|a| a.checked_sub(1)); + } + if let Some(k) = at.and_then(|a| o.keywords.get(a)) + && pressed(ui, egui::Key::Enter) + { + picked = Some(super::keywording::Picked::Keyword(k.clone())); + } + } let r = crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); + let mut drawn = Rect::NOTHING; + if let Some(o) = &offers { + let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at); + drawn = rect; + picked = picked.or(clicked); + } + ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass))); // Return gives the keywords to the selected photos; Esc gives back what was there before // this edit (the shared field does) - if r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty() { + let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); + let adding: Option<Vec<String>> = match picked { + // an offer: it, and the keywords typed before the last comma + Some(super::keywording::Picked::Keyword(k)) => { + let mut kws = super::keywording::typed_before(&app.session.catalog, &text); + kws.push(k); + Some(kws) + } // a new name goes inside the default parent (Put New Keywords Inside This Keyword) - let kws: Vec<String> = text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect(); + Some(super::keywording::Picked::Typed) => { + Some(text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()) + } + None if returned => Some(text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()), + None => None, + }; + if let Some(kws) = adding { let _ = app.run("photo.setMeta", json!({"addKeywords": kws})); text.clear(); + // an offer taken leaves the cursor in the box, for the next keyword + if !returned { + ui.memory_mut(|m| m.request_focus(field)); + } } ui.data_mut(|d| d.insert_temp(kid, text)); ui.add_space(8.0); @@ -910,15 +970,9 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); ui.add_space(10.0); - // suggestions: completions of the typed text, else keywords used together with this - // photo's keywords, else the most used ones - let typed = ui.data(|d| d.get_temp::<String>(kid).unwrap_or_default()); - let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); - // the keywords every selected photo has: those only some have are still suggested - let selection = app.session.targets(&serde_json::Value::Null); - let have: Vec<String> = - app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); - let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); + // suggestions: keywords used together with the selection's keywords, else the most used + // ones (what matches the text being typed is offered under the box itself) + let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone(); if !suggestions.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); // grouped: the keyword's own name, its whole path in the tooltip @@ -936,9 +990,6 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { register(ui.ctx(), format!("kwSuggest:{k}"), r.rect); if r.clicked() { let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); - if !last.is_empty() { - ui.data_mut(|d| d.insert_temp(kid, String::new())); - } } } }); diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index ed9ecbd51..1ce27c746 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -373,3 +373,135 @@ fn grouped_suggestions_name_the_keyword_alone() { ask(&mut h, "ui.clickWidget", json!({"id": "kwSuggest:Places|Portugal|Lisbon"})); assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k == "Places|Portugal|Lisbon"))); } + +/// Two photos selected (beach + Weddings, beach), and a third photo of the library, not selected, +/// with Places|Portugal|Lisbon and Places|Portugal|Porto. +fn with_places() -> (Headless, Vec<u64>) { + let (mut h, ids) = two_selected(); + let third = h.app.session.visible_cloned().get(2).map(|p| p.0).expect("a third photo"); + ask( + &mut h, + "engine.execute", + json!({"command": "photo.setMeta", "params": {"ids": [third], "keywords": ["Places|Portugal|Lisbon", "Places|Portugal|Porto"]}}), + ); + (h, ids) +} + +fn typed(h: &mut Headless, text: &str) { + ask(h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(h, "ui.text", json!({"text": text})); +} + +fn all_have(h: &Headless, ids: &[u64], keyword: &str) -> bool { + ids.iter().all(|id| keywords_of(h, *id).iter().any(|k| k == keyword)) +} + +fn none_has(h: &Headless, ids: &[u64], keyword: &str) -> bool { + ids.iter().all(|id| !keywords_of(h, *id).iter().any(|k| k == keyword)) +} + +// Given a library with Places › Portugal › Lisbon, +// when "lis" is typed in the Add keyword box, +// then the keyword is offered right under the box, and the suggestions further down stay what +// they were (they used to turn into the completions, a long way from the box). +#[test] +fn typing_offers_the_librarys_keywords_under_the_box() { + let (mut h, _) = with_places(); + assert!(has(&h, "kwSuggest:Weddings")); + typed(&mut h, "lis"); + let (field, offer) = (rect(&h, "field:keyword"), rect(&h, "kwComplete:Places|Portugal|Lisbon")); + assert!(offer.top() >= field.bottom() && offer.top() - field.bottom() < 12.0, "right under the box: {offer:?} {field:?}"); + assert!(offer.left() >= field.left() - 1.0 && offer.right() <= field.right() + 1.0, "as wide as the box: {offer:?} {field:?}"); + assert!(!has(&h, "kwComplete:Places|Portugal|Porto"), "only what matches"); + assert!(has(&h, "kwSuggest:Weddings") && !has(&h, "kwSuggest:Places|Portugal|Lisbon"), "the suggestions stay"); + // nothing typed, nothing offered + ask(&mut h, "ui.key", json!({"key": "Escape"})); + assert_eq!(count(&h, "kwComplete"), 0); +} + +// Given the offers under the box, +// when ↓ then Return are pressed, +// then the highlighted keyword goes to every selected photo (not the letters typed), and the box +// is empty and still has the cursor for the next keyword. +#[test] +fn down_and_return_give_the_offered_keyword() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + assert!(has(&h, "kwCompleteAt:Places|Portugal|Lisbon"), "highlighted"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon")); + assert!(none_has(&h, &ids, "lis"), "the letters typed are not a keyword"); + assert_eq!(count(&h, "kwComplete"), 0, "the box is empty again"); + // still in the box: the next keyword is typed straight away + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "harbour")); +} + +// Given the offers under the box, Return alone still gives what was typed, as it always did; ↑ +// from the first offer goes back to that. +#[test] +fn return_alone_gives_what_was_typed() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "lis") && none_has(&h, &ids, "Places|Portugal|Lisbon")); + + typed(&mut h, "por"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + ask(&mut h, "ui.key", json!({"key": "ArrowUp"})); + assert_eq!(count(&h, "kwCompleteAt:"), 0, "none highlighted"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "por") && none_has(&h, &ids, "Places|Portugal|Porto")); +} + +// Given a name the library doesn't have, +// when it is typed, +// then the box says Return will create it (and inside which keyword, with a default parent); a +// keyword the library has is not announced as new. +#[test] +fn a_new_name_is_announced_as_new() { + let (mut h, _) = with_places(); + typed(&mut h, "Lisbn"); + assert!(has(&h, "kwCompleteCreate"), "a misspelling is a new keyword: say so"); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + typed(&mut h, "weddings"); + assert!(!has(&h, "kwCompleteCreate"), "the library has Weddings"); + assert!(has(&h, "kwComplete:Weddings"), "one of the two lacks it"); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + typed(&mut h, "Places|Portugal"); + assert!(!has(&h, "kwCompleteCreate"), "a keyword with keywords inside it is there too"); +} + +// Given the offers under the box, a click on one gives that keyword to the selected photos; a +// click on the "create" line gives what was typed. Keywords typed before a comma go along. +#[test] +fn a_click_on_an_offer_gives_it() { + let (mut h, ids) = with_places(); + typed(&mut h, "sea, lis"); + assert!(has(&h, "kwComplete:Places|Portugal|Lisbon"), "the offers are for what follows the comma"); + ask(&mut h, "ui.clickWidget", json!({"id": "kwComplete:Places|Portugal|Lisbon"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon") && all_have(&h, &ids, "sea") && none_has(&h, &ids, "lis")); + + typed(&mut h, "harbour"); + ask(&mut h, "ui.clickWidget", json!({"id": "kwCompleteCreate"})); + assert!(all_have(&h, &ids, "harbour")); +} + +// Given more offers than there is room for, ↓ stops at the last and never runs off the list. +#[test] +fn the_highlight_stays_on_the_list() { + let (mut h, ids) = with_places(); + typed(&mut h, "por"); + for _ in 0..40 { + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + } + let lit: Vec<String> = h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("kwCompleteAt:").map(str::to_string)).collect(); + assert_eq!(lit.len(), 1, "{lit:?}"); + let offered = count(&h, "kwComplete:"); + assert!(offered >= 2, "Portugal and Porto both match"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, &lit[0]), "the last offer, {}, was given", lit[0]); + assert!(none_has(&h, &ids, "por")); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 290732c53..6af41a7ca 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -704,7 +704,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:<keyword>`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 91363d1e7..08b9c8fe0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -209,6 +209,10 @@ side under it. A long list stops after eight lines with "+N more", and the suggestions say only the keyword's own name. Whole names, one chip per keyword, stays the default. - A selection without keywords now says "No keywords yet" where its chips would be. +- Typing in **Add keyword** offers the library's matching keywords right under the box. ↓ and Return (or a click) + give the offered keyword and keep the cursor in the box for the next one; Return alone still adds what you typed. + A name the library doesn't have is marked as a new keyword before you press Return, so a misspelling no longer + becomes a keyword unnoticed. The suggestions further down stay put while you type. ### HDR and panorama merges of CR2 keep their colour - Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and From 4ce0aad79cb06ceededdfa7034668152bbaf73fd Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Sun, 11 Oct 2026 20:26:54 -0400 Subject: [PATCH 147/315] LRC-LIB-KEYWORDS: the Keywording box says what a removal touched With several photos selected the box says how many its keywords are of. A chip's x is a target as tall as the chip and 22 pt wide (it was 14 pt square) that lights up on its own, and the chip is 4 pt narrower for it. Taking a keyword off several photos, by the x or the chip's menu, says which keyword left how many photos and that Edit > Undo puts it back; a refused removal says why. Chips and their x show the keyboard focus, and the Add keyword box says in its tooltip that commas separate keywords. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 15 +++++ crates/ui-egui/src/panels/keywording.rs | 81 ++++++++++++++++++++----- crates/ui-egui/src/panels/right.rs | 5 ++ crates/ui-egui/src/tests_keywording.rs | 78 ++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 3 + 6 files changed, 169 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 386cf021e..9915ac03f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1513,6 +1513,8 @@ pub struct Caches { keyword_names: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>, /// The Keywording box's chips, by library revision and selection. keyword_chips: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>, + /// How many photos of the library the selection is, by library revision and selection. + selected_photos: Option<(u64, u64, usize)>, /// The Keywording box's chips grouped by the keyword they are inside, by library revision and /// selection. keyword_groups: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Group>>)>, @@ -1659,6 +1661,19 @@ impl Caches { } } } + /// How many photos the selection is: each once, those the library has (what the Keywording + /// box's "N of M" counts). + pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { + let key = key_of(selection); + match self.selected_photos { + Some((r, k, n)) if r == cat.revision && k == key => n, + _ => { + let n = panels::keywording::distinct(cat, selection).len(); + self.selected_photos = Some((cat.revision, key, n)); + n + } + } + } /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). pub(crate) fn keyword_groups( &mut self, diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 7cc810bb8..f9b05e238 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -54,17 +54,22 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// Chips on a line of the grouped box are this far apart. const GAP: f32 = 6.0; -/// The room a chip's × takes at its end. -const CROSS_W: f32 = 18.0; +/// The × at a chip's end is a target this wide, and as tall as the chip. +const CROSS_W: f32 = 22.0; + +/// The room a chip needs besides its name: the space before it and, with a ×, the ×'s target (the +/// × sits in the middle of it, which leaves the name its space), else the space after it. +fn chip_edges(cross: bool) -> f32 { + 8.0 + if cross { CROSS_W } else { 8.0 } +} /// What a chip shows of `name` in a row `row_w` wide (cut short to fit it), and the chip's width. fn chip_size(ui: &egui::Ui, name: &str, partial: bool, cross: bool, row_w: f32) -> (String, f32) { let font = Tokens::get(ui.ctx()).font(13.0); - let cross_w = if cross { CROSS_W } else { 0.0 }; - let room = (row_w - 16.0 - cross_w).max(24.0); + let room = (row_w - chip_edges(cross)).max(24.0); let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x; let shown = chip_label(name, partial, room, measure); - let w = measure(&shown) + 16.0 + cross_w; + let w = measure(&shown) + chip_edges(cross); (shown, w) } @@ -73,7 +78,7 @@ fn chip_size(ui: &egui::Ui, name: &str, partial: bool, cross: bool, row_w: f32) /// a row `row_w` wide) and, with `cross`, a × at its end. A `partial` one (only some of the /// selected photos) is dimmed and marked with an asterisk. Returns the name's response and the ×'s. fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, row_w: f32) -> (egui::Response, Option<egui::Response>) { - use egui::{Align2, Rect, Sense, pos2, vec2}; + use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; let t = Tokens::get(ui.ctx()); let font = t.font(13.0); let color = if partial { t.text_dim } else { t.text_label }; @@ -87,24 +92,57 @@ fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, r let full = path.replace('|', " › "); let resp = ui.interact(body, id, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &full)); + // the ×: the whole end of the chip, so that it is hard to miss and easy to tell from the name let x = cross.then(|| { - let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); + let xr = Rect::from_min_max(pos2(rect.right() - cross_w, rect.top()), rect.max); let xresp = ui.interact(xr, id.with("remove"), Sense::click()); let said = crate::i18n::tr_format!("Remove “{name}”", name = full); xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &said)); xresp }); - ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - if resp.hovered() || x.as_ref().is_some_and(egui::Response::hovered) { - ui.painter().rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); + let p = ui.painter(); + p.rect_stroke(rect, 10.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if resp.hovered() { + p.rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); } - ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); + p.text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font, color); if let Some(x) = &x { - ui.painter().text(x.rect.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if x.hovered() { t.text } else { t.text_dim }); + // under the pointer the × lights up on its own: this click removes, the name's doesn't + if x.hovered() { + p.rect_filled(x.rect.shrink(1.0), CornerRadius { nw: 0, sw: 0, ne: 10, se: 10 }, t.hover); + } + let (c, arm) = (x.rect.center(), 3.5); + let stroke = Stroke::new(1.5, if x.hovered() || x.has_focus() { t.text } else { t.text_dim }); + p.line_segment([c + vec2(-arm, -arm), c + vec2(arm, arm)], stroke); + p.line_segment([c + vec2(-arm, arm), c + vec2(arm, -arm)], stroke); + if x.has_focus() { + p.rect_stroke(x.rect, CornerRadius { nw: 4, sw: 4, ne: 10, se: 10 }, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + } + // reached with Tab, a chip shows where the keyboard is + if resp.has_focus() { + p.rect_stroke(rect, 10.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } (resp, x) } +/// Take the chip's keyword off the selected photos. Several photos losing it at once is said, with +/// the way back (one photo's chip going is enough); a refusal is said too. +fn remove(app: &mut LightcraftApp, ctx: &egui::Context, chip: &Chip) { + match app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})) { + Ok(_) if chip.have > 1 => { + let name = chip.path.replace('|', " › "); + app.toast_for( + ctx, + crate::i18n::tr_format!("Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", name = name, n = chip.have), + 6.0, + ); + } + Ok(_) => {} + Err(e) => app.toast_error(ctx, e), + } +} + /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the /// Keyword List's). fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { @@ -117,7 +155,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { let _ = app.run("photo.setMeta", json!({"addKeywords": [chip.path]})); } if item(ui, "remove", crate::i18n::tr("Remove from Selected Photos")) { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})); + remove(app, ui.ctx(), chip); } if item(ui, "show", crate::i18n::tr("Show Photos with Keyword")) { super::left::browse_all_photos(app, true); @@ -129,6 +167,21 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { } } +/// With several photos selected, how many the box's keywords are of (`keywordScope`): what a × +/// or a keyword typed here acts on. +pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let selection = app.session.targets(&serde_json::Value::Null); + let photos = app.caches.selected_photos(&app.session.catalog, &selection); + if photos < 2 { + return; + } + let t = Tokens::get(ui.ctx()); + let said = crate::i18n::tr_format!("Keywords of {n} selected photos", n = photos); + let r = ui.label(egui::RichText::new(said).size(11.5).color(t.text_label)); + register(ui.ctx(), "keywordScope", r.rect); + ui.add_space(4.0); +} + /// The selection's keywords as chips: the name (right-click for its menu), and × to take it off /// every selected photo. One only some of them have is marked with an asterisk. Whole names, or /// grouped by the keyword they are inside (Settings ▸ Interface). @@ -161,7 +214,7 @@ fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Respo if let Some(x) = x { register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); + remove(app, ui.ctx(), c); } } // one tooltip: the whole name (it may be cut short), and how many have it diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index c55385c72..ecebd010f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -901,6 +901,10 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let r = crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); let mut drawn = Rect::NOTHING; + if offers.is_none() { + // (not over the offers) + r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); + } if let Some(o) = &offers { let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at); drawn = rect; @@ -936,6 +940,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(8.0); super::keywording::view_switch(app, ui); ui.add_space(4.0); + super::keywording::scope(app, ui); if app.ui.keywording_view == crate::state::KeywordingView::Keywords { super::keywording::chip_row(app, ui); } else { diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 1ce27c746..839760af3 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -505,3 +505,81 @@ fn the_highlight_stays_on_the_list() { assert!(all_have(&h, &ids, &lit[0]), "the last offer, {}, was given", lit[0]); assert!(none_has(&h, &ids, "por")); } + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +// Given several photos selected, the box says how many its keywords are of; with one photo there +// is nothing to say. +#[test] +fn the_box_says_how_many_photos_it_is_for() { + let (mut h, ids) = two_selected(); + assert!(has(&h, "keywordScope"), "two photos are selected"); + assert!(rect(&h, "keywordScope").bottom() <= rect(&h, "keywordChip:beach").top(), "above the chips it is about"); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})); + assert!(!has(&h, "keywordScope"), "one photo"); +} + +// The × is a target as tall as its chip and as wide as that, apart from the name next to it. +#[test] +fn the_cross_is_as_tall_as_its_chip() { + let (h, _) = two_selected(); + let (name, cross) = (rect(&h, "keywordChip:beach"), rect(&h, "keywordChipRemove:beach")); + assert!(cross.height() >= 22.0 && cross.width() >= 22.0, "{cross:?}"); + assert_eq!(cross.height(), name.height()); + assert!(cross.left() >= name.right() - 0.5, "beside the name, not over it: {cross:?} {name:?}"); +} + +// Given a keyword on several selected photos, +// when its × takes it off them, +// then a message names the keyword and how many photos lost it and says how to get it back, and +// Undo does. Taking a keyword off one photo says nothing: the chip going is enough. +#[test] +fn taking_a_keyword_off_several_photos_says_so_and_undo_puts_it_back() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:Weddings"})); + assert_eq!(toast(&h), "", "only one of the two had Weddings"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:beach"})); + assert!(none_has(&h, &ids, "beach")); + let said = toast(&h); + assert!(said.contains("beach") && said.contains('2') && said.contains("Undo"), "{said}"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo", "params": {}})); + assert!(all_have(&h, &ids, "beach"), "one undo step puts it back on both"); + + // the menu's Remove from Selected Photos says the same + h.app.ui.toast = None; + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:beach", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipMenu:remove"})); + assert!(none_has(&h, &ids, "beach") && toast(&h).contains("beach"), "{}", toast(&h)); +} + +// Names of any length and script stay inside the panel in both layouts, and nothing breaks: a +// name of 400 letters, one of emoji and right-to-left text, levels of one letter. +#[test] +fn any_name_fits_the_panel() { + let (mut h, ids) = two_selected(); + let long = "x".repeat(400); + let keywords: Vec<String> = + vec![long.clone(), format!("a|{long}"), "🙂|العربية|שלום".into(), "🙂|العربية|مرحبا".into(), "a|b|c|d|e|f|g|h".into(), "İ|ı".into()]; + give(&mut h, &ids, &keywords); + for layout in [crate::state::KeywordChips::Paths, crate::state::KeywordChips::Grouped] { + h.app.ui.settings.keyword_chips = layout; + h.settle(SETTLE); + let panel = rect(&h, "panel:right_panel"); + let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); + assert_eq!(chips.len(), keywords.len(), "{layout:?}"); + for (id, r) in &chips { + assert!( + r.left() >= panel.left() && r.right() <= panel.right(), + "{layout:?}: {} inside the panel: {r:?}", + id.chars().take(40).collect::<String>() + ); + assert!(r.width() > 0.0 && r.height() < 30.0); + } + } + // and typing them offers them without trouble + typed(&mut h, &"x".repeat(300)); + typed(&mut h, "🙂"); + assert!(count(&h, "kwComplete") >= 1); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6af41a7ca..11a4170ea 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -704,7 +704,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:<keyword>`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:<keyword>`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 08b9c8fe0..5f4f55dd9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -213,6 +213,9 @@ give the offered keyword and keep the cursor in the box for the next one; Return alone still adds what you typed. A name the library doesn't have is marked as a new keyword before you press Return, so a misspelling no longer becomes a keyword unnoticed. The suggestions further down stay put while you type. +- With several photos selected, the Keywords panel says how many photos its keywords belong to. The × on a keyword + is a larger target that lights up on its own, and taking a keyword off several photos at once now says which + keyword left how many photos, and that Edit ▸ Undo puts it back. ### HDR and panorama merges of CR2 keep their colour - Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and From c2c85f5d978907395819889ea2801bb5a1900869 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Sun, 11 Oct 2026 20:43:45 -0400 Subject: [PATCH 148/315] LRC-LIB-KEYWORDS: what two independent reviews found in the Keywords panel - "+N more" was for the whole session: it is for the selection it was asked of, and another selection's long list is short again. - A keyword two photos spell differently was offered twice under one widget id, and a click on it gave nothing: it is offered once. - The offers followed the box out of the panel when it scrolled away and covered it near the bottom of the window: they are not drawn while the box is out of sight, and go above it where there is no room below. - The highlight was a line number, so a change of the library moved it to another keyword, which Return then gave: it stays with its keyword. - Esc after an offer was taken brought back keywords just added; Shift or Alt with the arrow keys moved the highlight instead of the text cursor; a refused add lost the typed text without a word. - A name the library lacks was looked up in the library twice per change of the text: once. - A wrong value under keywordChips or foldedSections discarded the whole settings file: it is read as the default. - Groups follow the tree ("Places > Italy" right after "Places"); a heading cut short says its whole name on hover; the number of chips measured for a shortened list follows the box's width; the selection is asked for once a frame. - The nine new plain messages are translated in every language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de.json | 9 ++ crates/ui-egui/locales/es.json | 9 ++ crates/ui-egui/locales/fr.json | 9 ++ crates/ui-egui/locales/ja.json | 9 ++ crates/ui-egui/locales/pt-br.json | 9 ++ crates/ui-egui/locales/ru.json | 9 ++ crates/ui-egui/locales/uk.json | 9 ++ crates/ui-egui/locales/zh-hans.json | 9 ++ crates/ui-egui/locales/zh-hant.json | 9 ++ crates/ui-egui/src/panels/keywording.rs | 134 +++++++++++++++++----- crates/ui-egui/src/panels/right.rs | 86 ++++++++++----- crates/ui-egui/src/state.rs | 23 +++- crates/ui-egui/src/tests_keywording.rs | 141 +++++++++++++++++++++++- docs/parity-checklist.md | 2 +- 14 files changed, 401 insertions(+), 66 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..63b377da0 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1975,6 +1975,15 @@ "Keyword List": "Stichwortliste", "Filter Keywords": "Stichwörter filtern", "No keywords yet": "Noch keine Stichwörter", + "Show fewer": "Weniger anzeigen", + "Whole names": "Ganze Namen", + "Grouped by parent": "Nach übergeordnetem Stichwort gruppiert", + "Keywords of the selection": "Stichwörter der Auswahl", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", + "Show every keyword of the selected photos": "Alle Stichwörter der ausgewählten Fotos anzeigen", + "new keyword": "neues Stichwort", + "Return creates this keyword and gives it to the selected photos": "Die Eingabetaste erstellt dieses Stichwort und weist es den ausgewählten Fotos zu", + "Separate several keywords with commas. Return gives them to the selected photos.": "Mehrere Stichwörter durch Kommas trennen. Die Eingabetaste weist sie den ausgewählten Fotos zu.", "No keywords match": "Keine passenden Stichwörter", "Keyword Name": "Stichwortname", "Synonyms": "Synonyme", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..b3104fd85 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1855,6 +1855,15 @@ "Keyword List": "Lista de palabras clave", "Filter Keywords": "Filtrar palabras clave", "No keywords yet": "Aún no hay palabras clave", + "Show fewer": "Mostrar menos", + "Whole names": "Nombres completos", + "Grouped by parent": "Agrupadas por palabra clave superior", + "Keywords of the selection": "Palabras clave de la selección", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Agrupadas: las palabras clave dentro de la misma palabra clave comparten una línea con su nombre; las listas largas y las sugerencias se acortan.", + "Show every keyword of the selected photos": "Mostrar todas las palabras clave de las fotos seleccionadas", + "new keyword": "palabra clave nueva", + "Return creates this keyword and gives it to the selected photos": "Intro crea esta palabra clave y la asigna a las fotos seleccionadas", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separa varias palabras clave con comas. Intro las asigna a las fotos seleccionadas.", "No keywords match": "Ninguna palabra clave coincide", "Keyword Name": "Nombre de la palabra clave", "Synonyms": "Sinónimos", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..cc0a47cb3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1809,6 +1809,15 @@ "Keyword List": "Liste des mots-clés", "Filter Keywords": "Filtrer les mots-clés", "No keywords yet": "Aucun mot-clé pour l'instant", + "Show fewer": "Afficher moins", + "Whole names": "Noms complets", + "Grouped by parent": "Groupés par mot-clé parent", + "Keywords of the selection": "Mots-clés de la sélection", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Groupés : les mots-clés contenus dans un même mot-clé partagent une ligne qui le nomme ; les longues listes et les suggestions sont raccourcies.", + "Show every keyword of the selected photos": "Afficher tous les mots-clés des photos sélectionnées", + "new keyword": "nouveau mot-clé", + "Return creates this keyword and gives it to the selected photos": "Entrée crée ce mot-clé et l'attribue aux photos sélectionnées", + "Separate several keywords with commas. Return gives them to the selected photos.": "Séparez plusieurs mots-clés par des virgules. Entrée les attribue aux photos sélectionnées.", "No keywords match": "Aucun mot-clé ne correspond", "Keyword Name": "Nom du mot-clé", "Synonyms": "Synonymes", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..2b71d047e 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1821,6 +1821,15 @@ "Keyword List": "キーワードリスト", "Filter Keywords": "キーワードを絞り込む", "No keywords yet": "キーワードはまだありません", + "Show fewer": "表示を減らす", + "Whole names": "完全な名前", + "Grouped by parent": "親キーワードでグループ化", + "Keywords of the selection": "選択した写真のキーワード", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "グループ化:同じキーワードの中にあるキーワードは、その名前を示す行にまとめられ、長いリストと候補は短く表示されます。", + "Show every keyword of the selected photos": "選択した写真のキーワードをすべて表示", + "new keyword": "新しいキーワード", + "Return creates this keyword and gives it to the selected photos": "Return キーでこのキーワードを作成し、選択した写真に付けます", + "Separate several keywords with commas. Return gives them to the selected photos.": "複数のキーワードはカンマで区切ります。Return キーで選択した写真に付けます。", "No keywords match": "一致するキーワードはありません", "Keyword Name": "キーワード名", "Synonyms": "同義語", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..9f84de5c8 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1819,6 +1819,15 @@ "Keyword List": "Lista de palavras-chave", "Filter Keywords": "Filtrar palavras-chave", "No keywords yet": "Ainda não há palavras-chave", + "Show fewer": "Mostrar menos", + "Whole names": "Nomes completos", + "Grouped by parent": "Agrupadas pela palavra-chave superior", + "Keywords of the selection": "Palavras-chave da seleção", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Agrupadas: as palavras-chave dentro da mesma palavra-chave compartilham uma linha com o nome dela; listas longas e as sugestões são encurtadas.", + "Show every keyword of the selected photos": "Mostrar todas as palavras-chave das fotos selecionadas", + "new keyword": "nova palavra-chave", + "Return creates this keyword and gives it to the selected photos": "Enter cria esta palavra-chave e a atribui às fotos selecionadas", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separe várias palavras-chave com vírgulas. Enter as atribui às fotos selecionadas.", "No keywords match": "Nenhuma palavra-chave corresponde", "Keyword Name": "Nome da palavra-chave", "Synonyms": "Sinônimos", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..a3cd09849 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1528,6 +1528,15 @@ "Keyword List": "Список ключевых слов", "Filter Keywords": "Фильтровать ключевые слова", "No keywords yet": "Ключевых слов пока нет", + "Show fewer": "Показать меньше", + "Whole names": "Полные имена", + "Grouped by parent": "Сгруппированы по родительскому слову", + "Keywords of the selection": "Ключевые слова выбранных фото", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Сгруппированы: ключевые слова внутри одного ключевого слова стоят под строкой с его именем; длинные списки и подсказки сокращаются.", + "Show every keyword of the selected photos": "Показать все ключевые слова выбранных фото", + "new keyword": "новое ключевое слово", + "Return creates this keyword and gives it to the selected photos": "Клавиша Enter создаст это ключевое слово и добавит его выбранным фото", + "Separate several keywords with commas. Return gives them to the selected photos.": "Несколько ключевых слов разделяйте запятыми. Клавиша Enter добавит их выбранным фото.", "No keywords match": "Подходящих ключевых слов нет", "Keyword Name": "Название ключевого слова", "Synonyms": "Синонимы", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..1407c8259 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2003,6 +2003,15 @@ "Keyword List": "Список ключових слів", "Filter Keywords": "Фільтрувати ключові слова", "No keywords yet": "Ключових слів ще немає", + "Show fewer": "Показати менше", + "Whole names": "Повні назви", + "Grouped by parent": "Згруповано за батьківським словом", + "Keywords of the selection": "Ключові слова вибраних фото", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Згруповано: ключові слова всередині одного ключового слова стоять під рядком із його назвою; довгі списки та підказки скорочуються.", + "Show every keyword of the selected photos": "Показати всі ключові слова вибраних фото", + "new keyword": "нове ключове слово", + "Return creates this keyword and gives it to the selected photos": "Клавіша Enter створить це ключове слово й додасть його вибраним фото", + "Separate several keywords with commas. Return gives them to the selected photos.": "Кілька ключових слів розділяйте комами. Клавіша Enter додасть їх вибраним фото.", "No keywords match": "Немає відповідних ключових слів", "Keyword Name": "Назва ключового слова", "Synonyms": "Синоніми", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..2ffe4fd05 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1819,6 +1819,15 @@ "Keyword List": "关键字列表", "Filter Keywords": "过滤关键字", "No keywords yet": "还没有关键字", + "Show fewer": "显示较少", + "Whole names": "完整名称", + "Grouped by parent": "按上级关键字分组", + "Keywords of the selection": "所选照片的关键字", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "分组:同一关键字内的关键字共用一行并标出该关键字的名称;较长的列表和建议会被缩短。", + "Show every keyword of the selected photos": "显示所选照片的全部关键字", + "new keyword": "新关键字", + "Return creates this keyword and gives it to the selected photos": "按回车键创建此关键字并添加到所选照片", + "Separate several keywords with commas. Return gives them to the selected photos.": "用逗号分隔多个关键字。按回车键将它们添加到所选照片。", "No keywords match": "没有匹配的关键字", "Keyword Name": "关键字名称", "Synonyms": "同义词", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..0d4f6d076 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1819,6 +1819,15 @@ "Keyword List": "關鍵字清單", "Filter Keywords": "篩選關鍵字", "No keywords yet": "尚無關鍵字", + "Show fewer": "顯示較少", + "Whole names": "完整名稱", + "Grouped by parent": "依上層關鍵字分組", + "Keywords of the selection": "選取照片的關鍵字", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "分組:同一關鍵字內的關鍵字共用一行並標出該關鍵字的名稱;較長的清單和建議會縮短。", + "Show every keyword of the selected photos": "顯示選取照片的全部關鍵字", + "new keyword": "新關鍵字", + "Return creates this keyword and gives it to the selected photos": "按 Return 鍵建立此關鍵字並加到選取的照片", + "Separate several keywords with commas. Return gives them to the selected photos.": "以逗號分隔多個關鍵字。按 Return 鍵將它們加到選取的照片。", "No keywords match": "沒有符合的關鍵字", "Keyword Name": "關鍵字名稱", "Synonyms": "同義詞", diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index f9b05e238..6e66aafbf 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -169,9 +169,8 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { /// With several photos selected, how many the box's keywords are of (`keywordScope`): what a × /// or a keyword typed here acts on. -pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let selection = app.session.targets(&serde_json::Value::Null); - let photos = app.caches.selected_photos(&app.session.catalog, &selection); +pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId]) { + let photos = app.caches.selected_photos(&app.session.catalog, selection); if photos < 2 { return; } @@ -185,9 +184,8 @@ pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// The selection's keywords as chips: the name (right-click for its menu), and × to take it off /// every selected photo. One only some of them have is marked with an asterisk. Whole names, or /// grouped by the keyword they are inside (Settings ▸ Interface). -pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let selection = app.session.targets(&serde_json::Value::Null); - let chips = app.caches.keyword_chips(&app.session.catalog, &selection); +pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId]) { + let chips = app.caches.keyword_chips(&app.session.catalog, selection); if chips.is_empty() { let t = Tokens::get(ui.ctx()); let r = ui.label(egui::RichText::new(crate::i18n::tr("No keywords yet")).color(t.text_dim)); @@ -195,8 +193,8 @@ pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; } if app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped { - let groups = app.caches.keyword_groups(&app.session.catalog, &selection); - chip_groups(app, ui, &groups); + let groups = app.caches.keyword_groups(&app.session.catalog, selection); + chip_groups(app, ui, &groups, crate::key_of(selection)); return; } ui.horizontal_wrapped(|ui| { @@ -230,17 +228,25 @@ fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Respo body.context_menu(|ui| menu(app, ui, &c)); } -/// When a list is shortened, only this many chips are measured for it: more than fit the lines -/// shown, however narrow they are. -const MEASURED: usize = 128; +/// When a list is shortened, only this many chips are measured for it: more than the lines it +/// can show hold in rows `avail` wide, however short their names are. The others are planned a +/// line each, past the lines shown. +pub(crate) fn measured(avail: f32) -> usize { + // the narrowest chip: its edges and a letter + let narrowest = chip_edges(true) + 4.0; + let row = if avail.is_finite() && avail > narrowest { ((avail / narrowest) as usize).min(4096) } else { 1 }; + row.saturating_add(1).saturating_mul(LINES + SLACK + 1) +} /// The grouped box: each group's parent on a line of its own, its chips under it side by side, /// and "+N more" where a long list stops. -fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group]) { +fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group], selection: u64) { use egui::{Align2, Sense, vec2}; let t = Tokens::get(ui.ctx()); let avail = ui.available_width(); - let all = app.ui.keyword_chips_all; + // the whole list only for the selection it was asked of + let all = app.ui.keyword_chips_all == Some(selection); + let limit = measured(avail); // the widths the lines are planned with (past what a shortened list can show: a line each) let mut measured = 0_usize; let sizes: Vec<(bool, Vec<f32>)> = groups @@ -251,7 +257,7 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group]) { .iter() .map(|(name, c)| { measured = measured.saturating_add(1); - if all || measured <= MEASURED { chip_size(ui, name, !c.on_all(), true, avail).1 } else { avail } + if all || measured <= limit { chip_size(ui, name, !c.on_all(), true, avail).1 } else { avail } }) .collect(); (!g.parent.is_empty(), widths) @@ -268,7 +274,11 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group]) { let shown = chip_label(&group.parent, false, avail, measure); let full = group.parent.replace('|', " › "); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &full)); - ui.painter().text(r.left_center(), Align2::LEFT_CENTER, shown, small.clone(), t.text_label); + ui.painter().text(r.left_center(), Align2::LEFT_CENTER, &shown, small.clone(), t.text_label); + // (cut short, it says its whole name on hover, as a chip does) + if shown != full { + resp.on_hover_text(&full); + } register(ui.ctx(), format!("keywordGroup:{}", group.parent), r); } Line::Chips(g, row) => { @@ -288,7 +298,7 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group]) { let r = ui.add(egui::Button::new(egui::RichText::new(label).color(t.text_label)).corner_radius(10.0)); register(ui.ctx(), "keywordChipsMore", r.rect); if r.on_hover_text(crate::i18n::tr("Show every keyword of the selected photos")).clicked() { - app.ui.keyword_chips_all = true; + app.ui.keyword_chips_all = Some(selection); } } Line::Fewer => { @@ -296,7 +306,7 @@ fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group]) { let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Show fewer")).color(t.text_label)).corner_radius(10.0)); register(ui.ctx(), "keywordChipsFewer", r.rect); if r.clicked() { - app.ui.keyword_chips_all = false; + app.ui.keyword_chips_all = None; } } } @@ -369,12 +379,21 @@ pub(crate) fn typed_before(catalog: &Catalog, text: &str) -> Vec<String> { /// What the box offers for `typed`, for photos that all have the `current` keywords. pub(crate) fn offers(catalog: &Catalog, current: &[String], typed: &str) -> Offers { - let name = lightcraft_catalog::keywords::clean(typed); + use lightcraft_catalog::keywords::{SEP, clean}; + let name = clean(typed); if name.is_empty() { return Offers::default(); } - let keywords = catalog.keyword_suggestions(current, typed, OFFERS); - let create = (!catalog.has_keyword(&name)).then(|| catalog.typed_keyword(typed)); + // (a keyword two photos spell differently is one offer, the first spelling) + let mut seen = std::collections::HashSet::new(); + let mut keywords = catalog.keyword_suggestions(current, typed, OFFERS); + keywords.retain(|k| seen.insert(k.to_lowercase())); + // where Return would put a name the library lacks, as `Catalog::typed_keyword` does, without + // looking through the library a second time + let create = (!catalog.has_keyword(&name)).then(|| match catalog.default_keyword_parent() { + Some(parent) if !name.contains(SEP) => format!("{parent}{SEP}{name}"), + _ => name, + }); Offers { keywords, create } } @@ -390,17 +409,25 @@ pub(crate) enum Picked { /// The offers under the Add keyword box (`below` is the box): the library's keywords matching /// what is typed, each by its own name with the keyword it is inside after it, the one at `at` /// highlighted (↑ / ↓); then, for a name the library doesn't have, the keyword Return creates. -/// Returns the offer clicked and where the offers were drawn. -pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at: Option<usize>) -> (Option<Picked>, egui::Rect) { +/// They go above the box where the window has no room for them under it. Returns the offer +/// clicked and where the offers were drawn. +pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at: Option<&str>) -> (Option<Picked>, egui::Rect) { use egui::{Align2, Sense, pos2, vec2}; use lightcraft_catalog::keywords::SEP; let t = Tokens::get(ui.ctx()); let mut picked = None; let width = (below.width() - 10.0).max(60.0); - let area = egui::Area::new(egui::Id::new("keyword-offers")) - .order(egui::Order::Foreground) - .fixed_pos(below.left_bottom() + vec2(0.0, 2.0)) - .constrain(true); + // as tall as its lines: under the box, or over it where that is the only side with room + let lines = offers.keywords.len().saturating_add(usize::from(offers.create.is_some())); + let height = lines as f32 * 24.0 + if offers.create.is_some() && !offers.keywords.is_empty() { 17.0 } else { 10.0 }; + let window = ui.ctx().content_rect(); + let over = below.bottom() + 2.0 + height > window.bottom() && below.top() - window.top() > window.bottom() - below.bottom(); + let area = egui::Area::new(egui::Id::new("keyword-offers")).order(egui::Order::Foreground).constrain(true); + let area = if over { + area.pivot(Align2::LEFT_BOTTOM).fixed_pos(below.left_top() - vec2(0.0, 2.0)) + } else { + area.fixed_pos(below.left_bottom() + vec2(0.0, 2.0)) + }; let shown = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { ui.set_width(width); @@ -426,10 +453,10 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at } resp }; - for (i, k) in offers.keywords.iter().enumerate() { + for k in &offers.keywords { let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k)); let whole = k.replace(SEP, " › "); - let lit = at == Some(i); + let lit = at == Some(k.as_str()); let r = line(ui, egui::Id::new(("keyword-offer", k.to_lowercase())), name, place, &whole, lit); register(ui.ctx(), format!("kwComplete:{k}"), r.rect); if lit { @@ -462,7 +489,13 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let small = t.font(11.5); ui.painter().text(pos2(r.rect.right() - 8.0, r.rect.center().y), Align2::RIGHT_CENTER, place, small, t.text_label); register(ui.ctx(), "kwCompleteCreate", r.rect); - if r.on_hover_text(crate::i18n::tr("Return creates this keyword and gives it to the selected photos")).clicked() { + // (Return gives the highlighted offer instead, while there is one) + let r = if at.is_none() { + r.on_hover_text(crate::i18n::tr("Return creates this keyword and gives it to the selected photos")) + } else { + r + }; + if r.clicked() { picked = Some(Picked::Typed); } } @@ -505,7 +538,9 @@ pub(crate) fn grouped(chips: &[Chip]) -> Vec<Group> { let shared = |depth: &usize| inside.get(&head(&levels, *depth).to_lowercase()).is_some_and(|n| *n > 1); let depth = (1..levels.len()).rev().find(shared).unwrap_or(0); let parent = head(&levels, depth); - groups.entry(parent.to_lowercase()).or_insert_with(|| Group { parent, chips: Vec::new() }).chips.push((tail(&levels, depth), c.clone())); + // (ordered level by level: "Port › Docks" follows "Port", before "Portugal") + let key = parent.to_lowercase().replace(SEP, "\u{1}"); + groups.entry(key).or_insert_with(|| Group { parent, chips: Vec::new() }).chips.push((tail(&levels, depth), c.clone())); } groups.into_values().collect() } @@ -892,6 +927,45 @@ mod tests { assert!(offers(&c, &[], "p").keywords.len() <= OFFERS); } + /// A keyword two photos spell differently is offered once. + #[test] + fn a_keyword_spelled_two_ways_is_one_offer() { + let (c, _) = library(&[&["Lisbon"], &["lisbon"], &["LISBON", "Lisboa"]]); + let got = offers(&c, &[], "lis").keywords; + assert_eq!(got.len(), 2, "{got:?}"); + assert!(got.iter().any(|k| k.eq_ignore_ascii_case("lisbon")) && got.contains(&"Lisboa".to_string())); + } + + /// Groups follow the tree: a keyword's groups come right after it, before a keyword whose name + /// merely starts the same. + #[test] + fn groups_are_in_the_trees_order() { + let parents: Vec<String> = + groups_of(&["Portugal|a", "Portugal|b", "Port|Docks|j", "Port|Docks|k", "Port|x", "Port|y"]).into_iter().map(|(p, _)| p).collect(); + assert_eq!(parents, ["Port", "Port|Docks", "Portugal"]); + } + + /// However wide the box, more chips are measured than a shortened list can show: the lines + /// shown are planned with their real widths. + #[test] + fn enough_chips_are_measured_for_the_lines_shown() { + for avail in [0.0, 60.0, 224.0, 474.0, 1500.0, 6000.0, f32::NAN, f32::INFINITY, -5.0] { + let limit = measured(avail); + assert!(limit > LINES + SLACK, "{avail}: {limit}"); + if avail.is_finite() && avail > 0.0 { + // the narrowest chips there can be, a gap apart: those of the lines shown + let narrowest = chip_edges(true) + 4.0; + let fit = (LINES + SLACK + 1) * ((avail / (narrowest + GAP)) as usize + 1); + assert!(limit >= fit, "{avail}: {limit} < {fit}"); + } + } + // a wide box: the lines shown are full rows, not one chip each past the 128th + let widths = vec![40.0; 300]; + let limit = measured(1500.0); + let planned: Vec<f32> = widths.iter().enumerate().map(|(i, w)| if i < limit { *w } else { 1500.0 }).collect(); + assert_eq!(lines(&[(false, planned)], 1500.0, GAP, false), lines(&[(false, widths)], 1500.0, GAP, false)); + } + /// What is typed is what follows the last comma; what came before it goes along when an offer /// is taken. #[test] diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ecebd010f..23dadfb9e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -871,49 +871,75 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let field = crate::text_field::id("field:keyword"); let oid = egui::Id::new("kw-offers"); let pass = ui.ctx().cumulative_pass_nr(); - let (typed_then, at_then, drawn, drawn_pass): (String, Option<usize>, Rect, u64) = - ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0)); - let clicking = drawn_pass.checked_add(1) == Some(pass) + // (remembered from the last frame: the text the highlight was for and the keyword it was + // on, where the offers were drawn, and whether the box was in sight) + let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option<String>, Rect, u64, bool) = + ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false)); + let last_frame = drawn_pass.checked_add(1) == Some(pass); + let clicking = last_frame && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); let typed = super::keywording::typing(&text).to_string(); - let offers = (ui.memory(|m| m.has_focus(field)) || clicking) + // (none while the box is scrolled out of the panel: they would float over what is there) + let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden)) .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) .filter(|o| !o.keywords.is_empty() || o.create.is_some()); // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes // back to none: Return then gives what was typed, as without offers); Return gives the - // highlighted one - let mut at = at_then.filter(|_| typed_then == typed).filter(|a| offers.as_ref().is_some_and(|o| *a < o.keywords.len())); + // highlighted one. The highlight is on a keyword, not a line: it stays with it when the + // library changes under it, and goes when the keyword does. With Shift or Alt the keys + // are the text's own. + let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k))); let mut picked = None; if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { - let pressed = |ui: &mut egui::Ui, key| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, key)); + let pressed = |ui: &mut egui::Ui, key: egui::Key| { + ui.input_mut(|i| { + let plain = + |e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none()); + let found = i.events.iter().any(plain); + i.events.retain(|e| !plain(e)); + found + }) + }; + let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k)); if pressed(ui, egui::Key::ArrowDown) { - at = Some(at.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1))); + let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)); + at = o.keywords.get(next).cloned(); + } else if pressed(ui, egui::Key::ArrowUp) { + at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned()); } - if pressed(ui, egui::Key::ArrowUp) { - at = at.and_then(|a| a.checked_sub(1)); - } - if let Some(k) = at.and_then(|a| o.keywords.get(a)) + if let Some(k) = &at && pressed(ui, egui::Key::Enter) { picked = Some(super::keywording::Picked::Keyword(k.clone())); } } - let r = - crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); + // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) + let bid = egui::Id::new("kw-begin"); + let begin = ui.data_mut(|d| d.remove_temp::<bool>(bid)).unwrap_or(false); + let r = crate::text_field::TextField::singleline("field:keyword", &mut text) + .hint(crate::i18n::tr("Add keyword")) + .width(f32::INFINITY) + .begin_edit(begin) + .show(ui); + let in_sight = ui.clip_rect().contains(r.response.rect.center()); let mut drawn = Rect::NOTHING; if offers.is_none() { // (not over the offers) r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); } - if let Some(o) = &offers { - let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at); + if let Some(o) = offers.as_ref().filter(|_| in_sight) { + let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at.as_deref()); drawn = rect; picked = picked.or(clicked); } - ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass))); + ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight))); // Return gives the keywords to the selected photos; Esc gives back what was there before // this edit (the shared field does) let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); + // a new name goes inside the default parent (Put New Keywords Inside This Keyword) + let as_typed = |app: &LightcraftApp, text: &str| -> Vec<String> { + text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() + }; let adding: Option<Vec<String>> = match picked { // an offer: it, and the keywords typed before the last comma Some(super::keywording::Picked::Keyword(k)) => { @@ -921,28 +947,30 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { kws.push(k); Some(kws) } - // a new name goes inside the default parent (Put New Keywords Inside This Keyword) - Some(super::keywording::Picked::Typed) => { - Some(text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()) - } - None if returned => Some(text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()), + Some(super::keywording::Picked::Typed) => Some(as_typed(app, &text)), + None if returned => Some(as_typed(app, &text)), None => None, }; if let Some(kws) = adding { - let _ = app.run("photo.setMeta", json!({"addKeywords": kws})); - text.clear(); - // an offer taken leaves the cursor in the box, for the next keyword - if !returned { - ui.memory_mut(|m| m.request_focus(field)); + // (refused, the text stays to be put right, and the refusal is said) + match app.run("photo.setMeta", json!({"addKeywords": kws})) { + Ok(_) => { + text.clear(); + // an offer taken leaves the cursor in the box, for the next keyword + if !returned { + ui.data_mut(|d| d.insert_temp(bid, true)); + } + } + Err(e) => app.toast_error(ui.ctx(), e), } } ui.data_mut(|d| d.insert_temp(kid, text)); ui.add_space(8.0); super::keywording::view_switch(app, ui); ui.add_space(4.0); - super::keywording::scope(app, ui); + super::keywording::scope(app, ui, &selection); if app.ui.keywording_view == crate::state::KeywordingView::Keywords { - super::keywording::chip_row(app, ui); + super::keywording::chip_row(app, ui, &selection); } else { super::keywording::names_row(app, ui); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f3b40a95e..0112ac4d9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -284,6 +284,7 @@ pub struct AppSettings { /// the display is treated as sRGB). pub display_profile: String, /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. + #[serde(deserialize_with = "lenient")] pub keyword_chips: KeywordChips, } @@ -726,12 +727,13 @@ pub struct UiState { pub keyword_painter: Option<String>, /// What the Keywording box shows (Lightroom Classic's Keyword Tags views). pub keywording_view: KeywordingView, - /// The Keywording box, grouped: every keyword is shown, however many lines that takes ("+N - /// more" was clicked). + /// The Keywording box, grouped: every keyword of this selection (its hash) is shown, however + /// many lines that takes ("+N more" was clicked). Another selection's list is short again. #[serde(skip)] - pub keyword_chips_all: bool, + pub keyword_chips_all: Option<u64>, /// Sections of the right panel folded shut by a click on their title (`keywording`, /// `keywordList`); the others are open. + #[serde(deserialize_with = "lenient_ids")] pub folded_sections: Vec<String>, /// The Keyword List's open levels (lower-case paths). pub keyword_list_open: Vec<String>, @@ -1163,7 +1165,7 @@ impl Default for UiState { second_window: false, keyword_painter: None, keywording_view: KeywordingView::default(), - keyword_chips_all: false, + keyword_chips_all: None, folded_sections: Vec::new(), keyword_list_open: Vec::new(), keyword_list_selected: None, @@ -1266,6 +1268,19 @@ impl UiState { } } +/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer +/// build's choice) is the default, and the rest of the settings file is still read. +fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result<T, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +/// A list of ids read leniently: what isn't a list is none, and what isn't text in it is left out. +fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()) +} + fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> { let values = Vec::<String>::deserialize(d)?; let mut seen = std::collections::BTreeSet::new(); diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 839760af3..ece0ffc6c 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -578,8 +578,145 @@ fn any_name_fits_the_panel() { assert!(r.width() > 0.0 && r.height() < 30.0); } } - // and typing them offers them without trouble + // and typing them offers them without trouble: the photos here all have them, so another + // photo's emoji keyword is what is offered + let next = others(&mut h); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [next[0]], "keywords": ["🙂|العربية|سلام"]}})); typed(&mut h, &"x".repeat(300)); + ask(&mut h, "ui.key", json!({"key": "Escape"})); typed(&mut h, "🙂"); - assert!(count(&h, "kwComplete") >= 1); + assert!(has(&h, "kwComplete:🙂|العربية|سلام"), "{:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete")).collect::<Vec<_>>()); +} + +fn lit(h: &Headless) -> Vec<String> { + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("kwCompleteAt:").map(str::to_string)).collect() +} + +/// Ids of the library's photos after the two selected ones. +fn others(h: &mut Headless) -> Vec<u64> { + h.app.session.visible_cloned().iter().skip(2).take(4).map(|p| p.0).collect() +} + +// Given a long grouped list shown whole after "+N more", +// when other photos with a long list are selected, +// then theirs is short again: asking for more was for the photos it was asked of. +#[test] +fn asking_for_more_is_for_the_selection_it_was_asked_of() { + let (mut h, ids) = two_selected(); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + give(&mut h, &ids, &sixteen()); + let next = others(&mut h); + give(&mut h, &next, &sixteen()); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipsMore"})); + assert_eq!(count(&h, "keywordChip:"), 16); + // taking a keyword off keeps the list whole: it is still theirs + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "removeKeywords": [sixteen()[15]]}})); + assert_eq!(count(&h, "keywordChip:"), 15); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": next}})); + assert_eq!(count(&h, "keywordChip:"), 7, "short again"); + assert!(has(&h, "keywordChipsMore") && !has(&h, "keywordChipsFewer")); +} + +// Given a keyword two photos spell differently (Lisbon, lisbon), +// when it is typed, +// then it is offered once, and a click gives it. +#[test] +fn a_keyword_spelled_two_ways_is_offered_once() { + let (mut h, ids) = two_selected(); + let next = others(&mut h); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [next[0]], "keywords": ["Lisbon"]}})); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [next[1]], "keywords": ["lisbon"]}})); + typed(&mut h, "lis"); + let offered: Vec<String> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete:")).map(|(w, _)| w.clone()).collect(); + assert_eq!(offered.len(), 1, "{offered:?}"); + ask(&mut h, "ui.clickWidget", json!({"id": offered[0]})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k.eq_ignore_ascii_case("lisbon")))); +} + +// Given a highlighted offer, +// when the library changes under it and the offers come in another order, +// then the highlight is still on that keyword (it used to stay on the line, and Return gave +// whatever keyword had moved there). +#[test] +fn the_highlight_stays_with_its_keyword() { + let (mut h, ids) = with_places(); + typed(&mut h, "por"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let before = lit(&h); + assert_eq!(before.len(), 1); + let next = others(&mut h); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": next, "addKeywords": ["Portraits"]}})); + assert!(has(&h, "kwComplete:Portraits"), "the offers changed"); + assert_eq!(lit(&h), before, "the highlight did not move to another keyword"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, &before[0]) && none_has(&h, &ids, "Portraits")); +} + +// Given an offer taken with ↓ and Return, Esc afterwards does not bring back the keywords that +// were just added: the box stays empty. +#[test] +fn escape_after_an_offer_keeps_the_box_empty() { + let (mut h, ids) = with_places(); + typed(&mut h, "sea"); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": ", lis"})); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "sea") && all_have(&h, &ids, "Places|Portugal|Lisbon")); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "x"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "x") && none_has(&h, &ids, "seax"), "{:?}", keywords_of(&h, ids[0])); +} + +// ↓ / ↑ / Return with Shift or Alt held are the text box's own (select to the start of the text): +// they leave the offers alone. +#[test] +fn modified_arrows_are_the_text_boxs() { + let (mut h, ids) = with_places(); + typed(&mut h, "por"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown", "shift": true})); + assert!(lit(&h).is_empty(), "Shift+↓ highlights nothing: {:?}", lit(&h)); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let before = lit(&h); + ask(&mut h, "ui.key", json!({"key": "ArrowUp", "shift": true})); + assert_eq!(lit(&h), before); + let _ = ids; +} + +// Given the box scrolled out of the panel with the cursor still in it, its offers are not drawn +// (they used to float over the top bar, where Return still took one). +#[test] +fn offers_go_when_the_box_scrolls_out_of_sight() { + let (mut h, ids) = with_places(); + give(&mut h, &ids, &sixteen()); + typed(&mut h, "lis"); + assert!(has(&h, "kwComplete:Places|Portugal|Lisbon")); + let panel = rect(&h, "panel:right_panel"); + ask(&mut h, "ui.move", json!({"x": panel.center().x, "y": panel.center().y})); + for _ in 0..8 { + ask(&mut h, "ui.scroll", json!({"dy": -400.0})); + } + assert!(rect(&h, "field:keyword").bottom() < panel.top(), "scrolled out: {:?}", rect(&h, "field:keyword")); + assert_eq!(count(&h, "kwComplete"), 0); +} + +// Settings files with something else under the two new keys keep every other setting (and these +// two at their defaults): a wrong value used to discard the whole file. +#[test] +fn wrong_values_for_the_new_settings_keep_the_rest() { + let ui: crate::UiState = serde_json::from_str(r#"{"foldedSections": "oops", "rightWidth": 300.0}"#).unwrap(); + assert!(!ui.section_folded("keywording") && ui.right_width == 300.0); + let ui: crate::UiState = + serde_json::from_str(r#"{"foldedSections": [1, "keywordList", null], "settings": {"keywordChips": "bogus", "filmNames": false}}"#).unwrap(); + assert!(ui.section_folded("keywordList") && !ui.section_folded("keywording")); + assert_eq!(ui.settings.keyword_chips, crate::state::KeywordChips::Paths); + assert!(!ui.settings.film_names, "the other settings are read"); + let ui: crate::UiState = serde_json::from_str(r#"{"settings": {"keywordChips": "grouped"}}"#).unwrap(); + assert_eq!(ui.settings.keyword_chips, crate::state::KeywordChips::Grouped); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 11a4170ea..639894aed 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -704,7 +704,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:<keyword>`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:<keyword>`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people From 6bb52f09f6b76751e36d67567f37f558d406709a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Sun, 11 Oct 2026 20:57:14 -0400 Subject: [PATCH 149/315] LRC-LIB-KEYWORDS: what the check of the review fixes found - An offer taken asked the box to start a new edit on the next frame; a box not drawn then (its panel switched away at once) took the cursor whenever it was next drawn. The request holds for one frame. - Return on the frame the box scrolled out of sight still took the highlighted offer, which was no longer drawn: it gives nothing. - In a window with room for the offers on neither side of the box they covered it: they go to the side with more room and show as many lines as fit there, the highlighted one among them. - Ukrainian says "suggestions" as the panel does; four clearer wordings (uk, ru, de, ja). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/src/panels/keywording.rs | 24 ++++++--- crates/ui-egui/src/panels/right.rs | 10 +++- crates/ui-egui/src/tests_keywording.rs | 72 +++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 63b377da0..a98ed75c2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1976,7 +1976,7 @@ "Filter Keywords": "Stichwörter filtern", "No keywords yet": "Noch keine Stichwörter", "Show fewer": "Weniger anzeigen", - "Whole names": "Ganze Namen", + "Whole names": "Vollständige Namen", "Grouped by parent": "Nach übergeordnetem Stichwort gruppiert", "Keywords of the selection": "Stichwörter der Auswahl", "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2b71d047e..824abe24f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1822,7 +1822,7 @@ "Filter Keywords": "キーワードを絞り込む", "No keywords yet": "キーワードはまだありません", "Show fewer": "表示を減らす", - "Whole names": "完全な名前", + "Whole names": "パス全体", "Grouped by parent": "親キーワードでグループ化", "Keywords of the selection": "選択した写真のキーワード", "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "グループ化:同じキーワードの中にあるキーワードは、その名前を示す行にまとめられ、長いリストと候補は短く表示されます。", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index a3cd09849..575b63cd0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1530,7 +1530,7 @@ "No keywords yet": "Ключевых слов пока нет", "Show fewer": "Показать меньше", "Whole names": "Полные имена", - "Grouped by parent": "Сгруппированы по родительскому слову", + "Grouped by parent": "По родительскому ключевому слову", "Keywords of the selection": "Ключевые слова выбранных фото", "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Сгруппированы: ключевые слова внутри одного ключевого слова стоят под строкой с его именем; длинные списки и подсказки сокращаются.", "Show every keyword of the selected photos": "Показать все ключевые слова выбранных фото", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 1407c8259..50bcd4a88 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2005,9 +2005,9 @@ "No keywords yet": "Ключових слів ще немає", "Show fewer": "Показати менше", "Whole names": "Повні назви", - "Grouped by parent": "Згруповано за батьківським словом", + "Grouped by parent": "За батьківським ключовим словом", "Keywords of the selection": "Ключові слова вибраних фото", - "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Згруповано: ключові слова всередині одного ключового слова стоять під рядком із його назвою; довгі списки та підказки скорочуються.", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Згруповано: ключові слова всередині одного ключового слова стоять під рядком із його назвою; довгі списки та пропозиції скорочуються.", "Show every keyword of the selected photos": "Показати всі ключові слова вибраних фото", "new keyword": "нове ключове слово", "Return creates this keyword and gives it to the selected photos": "Клавіша Enter створить це ключове слово й додасть його вибраним фото", diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 6e66aafbf..b1f1e919e 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -417,11 +417,23 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let t = Tokens::get(ui.ctx()); let mut picked = None; let width = (below.width() - 10.0).max(60.0); - // as tall as its lines: under the box, or over it where that is the only side with room - let lines = offers.keywords.len().saturating_add(usize::from(offers.create.is_some())); - let height = lines as f32 * 24.0 + if offers.create.is_some() && !offers.keywords.is_empty() { 17.0 } else { 10.0 }; + // under the box, or over it where the window has more room there; as many lines as fit that + // side, so that the offers never cover the box (the highlighted one is always among them) let window = ui.ctx().content_rect(); - let over = below.bottom() + 2.0 + height > window.bottom() && below.top() - window.top() > window.bottom() - below.bottom(); + let create = usize::from(offers.create.is_some()); + let fits = |room: f32| { + let lines = (room - 2.0 - if create > 0 && !offers.keywords.is_empty() { 17.0 } else { 10.0 }) / 24.0; + if lines.is_finite() && lines >= 1.0 { lines as usize } else { 0 } + }; + let wanted = offers.keywords.len().saturating_add(create); + let (under, above) = (fits(window.bottom() - below.bottom()), fits(below.top() - window.top())); + let over = under < wanted && above > under; + let room = (if over { above } else { under }).clamp(1, wanted.max(1)); + // the keywords shown: those up to the highlighted one when not all fit + let count = room.saturating_sub(create).min(offers.keywords.len()); + let lit = at.and_then(|k| offers.keywords.iter().position(|x| x == k)); + let first = lit.map_or(0, |a| a.saturating_add(1).saturating_sub(count)).min(offers.keywords.len().saturating_sub(count)); + let listed = offers.keywords.iter().skip(first).take(count); let area = egui::Area::new(egui::Id::new("keyword-offers")).order(egui::Order::Foreground).constrain(true); let area = if over { area.pivot(Align2::LEFT_BOTTOM).fixed_pos(below.left_top() - vec2(0.0, 2.0)) @@ -453,7 +465,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at } resp }; - for k in &offers.keywords { + for k in listed { let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k)); let whole = k.replace(SEP, " › "); let lit = at == Some(k.as_str()); @@ -467,7 +479,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at } } if let Some(new) = &offers.create { - if !offers.keywords.is_empty() { + if count > 0 { ui.add_space(3.0); let (r, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, t.field_border); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 23dadfb9e..aae2e3f5b 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -914,8 +914,10 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) + // (asked for on the frame before this one only: a box that wasn't drawn then, its panel + // switched away, doesn't claim the cursor whenever it is next drawn) let bid = egui::Id::new("kw-begin"); - let begin = ui.data_mut(|d| d.remove_temp::<bool>(bid)).unwrap_or(false); + let begin = ui.data_mut(|d| d.remove_temp::<u64>(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); let r = crate::text_field::TextField::singleline("field:keyword", &mut text) .hint(crate::i18n::tr("Add keyword")) .width(f32::INFINITY) @@ -923,6 +925,10 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui); let in_sight = ui.clip_rect().contains(r.response.rect.center()); let mut drawn = Rect::NOTHING; + // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) + if !in_sight { + picked = None; + } if offers.is_none() { // (not over the offers) r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); @@ -958,7 +964,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { text.clear(); // an offer taken leaves the cursor in the box, for the next keyword if !returned { - ui.data_mut(|d| d.insert_temp(bid, true)); + ui.data_mut(|d| d.insert_temp(bid, pass)); } } Err(e) => app.toast_error(ui.ctx(), e), diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index ece0ffc6c..e47ba879a 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -720,3 +720,75 @@ fn wrong_values_for_the_new_settings_keep_the_rest() { let ui: crate::UiState = serde_json::from_str(r#"{"settings": {"keywordChips": "grouped"}}"#).unwrap(); assert_eq!(ui.settings.keyword_chips, crate::state::KeywordChips::Grouped); } + +// Given an offer taken with ↓ and Return, +// when the panel is switched away at once and comes back much later, +// then the box does not take the cursor unasked: keys typed then are not the box's. +#[test] +fn an_offer_taken_does_not_claim_the_cursor_later() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + // (Return and the switch on consecutive frames, as a script does them) + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + ask(&mut h, "ui.set", json!({"right": "info"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon")); + ask(&mut h, "ui.set", json!({"right": "keywords"})); + ask(&mut h, "ui.text", json!({"text": "zz"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(none_has(&h, &ids, "zz"), "typed with the cursor nowhere: {:?}", keywords_of(&h, ids[0])); +} + +// Given a highlighted offer and the box scrolled out of sight, Return gives nothing: what isn't +// drawn can't be taken, not even on the frame the box leaves. +#[test] +fn an_offer_out_of_sight_is_not_taken() { + let (mut h, ids) = with_places(); + give(&mut h, &ids, &sixteen()); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let panel = rect(&h, "panel:right_panel"); + ask(&mut h, "ui.move", json!({"x": panel.center().x, "y": panel.center().y})); + for _ in 0..8 { + // the wheel and Return together, frame after frame + let r = h.request("ui.scroll", json!({"dy": -400.0}), T); + assert_eq!(r["ok"], true, "{r}"); + if rect(&h, "field:keyword").bottom() < panel.top() { + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + } + } + h.settle(SETTLE); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(none_has(&h, &ids, "Places|Portugal|Lisbon"), "{:?}", keywords_of(&h, ids[0])); +} + +// Given a window too short for the offers under the box, they never cover the box being typed +// in: they go where there is more room and show as many lines as fit there. +#[test] +fn offers_never_cover_the_box() { + for height in [1000.0, 420.0, 300.0, 230.0, 180.0] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, height], 1.0); + let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(12).map(|p| p.0).collect(); + ask(&mut h, "ui.set", json!({"view": "photoGrid", "right": "keywords"})); + // nine keywords to offer for "pa", on photos that are not selected + let many: Vec<String> = (1..=9).map(|i| format!("Parks|park {i}")).collect(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": &ids[2..], "keywords": many}})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": &ids[..2]}})); + typed(&mut h, "pa"); + let field = rect(&h, "field:keyword"); + let offers: Vec<egui::Rect> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete")).map(|(_, r)| *r).collect(); + assert!(!offers.is_empty(), "{height}: something is offered"); + for r in &offers { + assert!(!r.intersects(field.shrink(0.5)), "{height}: an offer at {r:?} covers the box at {field:?}"); + assert!(r.top() >= -0.5 && r.bottom() <= height + 0.5, "{height}: an offer at {r:?} is off the window"); + } + // ↓ still reaches every offer drawn, and the highlighted one is among them + for _ in 0..12 { + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + assert_eq!(lit(&h).len(), 1, "{height}"); + } + } +} From afacec1747478bd2b75a8513333058490dcde2b7 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Tue, 13 Oct 2026 14:55:43 -0400 Subject: [PATCH 150/315] LR-BEHAV-WINDOW: the window opens at the size it was left at, zoomed if it was The desktop app opened at 1600 x 1000 every time: a resize, or a zoom by a double-click on the top bar, was forgotten at quit. The size the window rests at and whether it is zoomed (macOS) / maximized are kept in ui.json (`window: {size, zoomed}`) and the window is built from them. A zoomed window keeps the size it goes back to. A size counts once the window has rested at it for half a second, so the sizes a resize or the zoom animation passes through do not; one given just before quitting is taken at exit. Full screen and the window's place on the desktop are not remembered. Windows and Linux say whether a window is maximized. On macOS egui-winit only refreshes that at launch and when the app zooms the window itself, so there the window's size is gone by as well: zoomed when the system said so or the window was seen to come to fill its screen, and for as long as it keeps that size. Whatever is in doubt is a window of the size it has, which opens as large as it was left. A `window` in the settings that is not one reads as none and costs no other setting. Run on macOS with one display; the other platforms follow from reading winit and egui-winit, so the tracker row is partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/lightcraft/src/main.rs | 142 ++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/window_place.rs | 919 +++++++++++++++++++++++++++++ docs/parity-checklist.md | 10 +- docs/whats-new.md | 14 + 6 files changed, 1083 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/window_place.rs diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 1c70e19ea..a882daa59 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -80,6 +80,8 @@ impl eframe::App for App { self.0.ui(ui); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + // (a size the window was just given is remembered too) + self.0.ui.window = self.1.window_at_exit(self.0.ui.window); if let Err(e) = self.1.save(&self.0) { log::error!("{e}"); } @@ -183,6 +185,16 @@ fn config_dir() -> Option<std::path::PathBuf> { lightcraft_engine::config::config_dir() } +/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size +/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it +/// opens is the system's to say. +fn as_remembered(builder: egui::ViewportBuilder, window: Option<lightcraft_ui_egui::window_place::WindowPlace>) -> egui::ViewportBuilder { + let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); + // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen + // to a size of its own choosing) + if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } +} + /// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or /// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. fn prefs_path() -> Option<std::path::PathBuf> { @@ -301,6 +313,11 @@ struct PrefsWriter { failing: bool, /// `ui.json` couldn't be read at launch: never overwrite it this session. keep_file: bool, + /// Follows the window, to remember the size it rests at (`UiState::window`). + window: lightcraft_ui_egui::window_place::WindowWatch, + /// When the window was first looked at: its watch goes by the clock from then on, not by + /// the interface's time, which stands still while the window is hidden. + window_since: Option<std::time::Instant>, } impl PrefsWriter { @@ -331,8 +348,33 @@ impl PrefsWriter { Ok(()) } + /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). + /// + /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit + fn window_at_exit( + &mut self, + remembered: Option<lightcraft_ui_egui::window_place::WindowPlace>, + ) -> Option<lightcraft_ui_egui::window_place::WindowPlace> { + match self.window_since { + Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), + None => remembered, + } + } + fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + // (asked for before the input is read: the context is not asked from inside its own lock) + let zoom = ctx.zoom_factor(); + let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))); + // how the window is, to open like that next time (written with the settings below). What + // the settings hold is checked first: a script may have set it (`ui.set`) + let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); + let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); + app.ui.window = self.window.note(remembered, &seen, at); + if let Some(wait) = self.window.waits(at) { + // (a size is only remembered once the window has rested at it: look again then, though + // nothing else asks for a frame) + ctx.request_repaint_after(wait); + } let moved = app.ui.settings.library_path != self.library; if !moved && now - self.checked < 3.0 { return; @@ -817,12 +859,12 @@ fn main() -> eframe::Result { let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); win_files.arm(window_renderer); let win_files_app = win_files.clone(); + // how the window was when the app was last used (checked: the settings file is a file) + let opens_as = prefs.as_ref().and_then(|ui| ui.window).and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); let options = eframe::NativeOptions { renderer, - viewport: egui::ViewportBuilder::default() - .with_title("LightCraft") - .with_inner_size([1600.0, 1000.0]) - .with_min_inner_size([900.0, 560.0]) + viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as) + .with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE) .with_drag_and_drop(true) .with_fullsize_content_view(true) .with_titlebar_shown(false) @@ -1237,6 +1279,96 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Given what the settings remember of the window, when the app's window is built, then it + /// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it + /// opens at the usual size. + #[test] + fn the_window_opens_as_it_was() { + use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; + let fresh = as_remembered(egui::ViewportBuilder::default(), None); + assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); + // (not asked to un-zoom: macOS would then resize a window that fills its screen) + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); + } + + /// Given a window that rests at a size, when the settings are written and read again, then + /// they hold how it was. A window in the file that is not one (a hand edit, another version's) + /// reads as nothing remembered and costs no other setting; and a window resized just before + /// the app quits is remembered at that size. + #[test] + fn the_settings_keep_how_the_window_was() { + use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; + let d = dir("window"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.ui.settings.library_path = "/pictures/library".into(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save(&app).unwrap(); + assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); + let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; + app.ui.window = Some(window); + writer.save(&app).unwrap(); + let (restored, warning, _) = load_prefs_at(&path, false); + assert_eq!((restored.unwrap().window, warning), (Some(window), None)); + + // a window that is no window: the file is read all the same, with its other settings + let text = std::fs::read_to_string(&path).unwrap(); + for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { + let start = text.find("\"window\"").unwrap(); + let end = start + text[start..].find('}').unwrap() + 1; + std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, true); + let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); + assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); + } + + // resized, and the app quits a moment later: the new size is what is written + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); + writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); + writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); + assert!(writer.window_since.is_some()); + assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); + assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); + let _ = std::fs::remove_dir_all(&d); + } + + /// Given the app's window at a size, when the settings writer looks at it frame after frame, + /// then it asks to be woken while the size has still to rest, remembers the size once it has, + /// and asks for nothing more. A window in the settings that is not one (a script set it) is + /// put right before it is gone by. + #[test] + fn the_writer_follows_the_window_from_frame_to_frame() { + use lightcraft_ui_egui::window_place::WindowPlace; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut writer = PrefsWriter::default(); + let ctx = egui::Context::default(); + let pass = |look: &mut dyn FnMut(&egui::Context)| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) + }; + // (egui's own first frames ask for another: let them pass) + for _ in 0..3 { + pass(&mut |_| {}); + } + app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, None, "no window, and not yet at rest"); + assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); + // a second later + writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); + pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); + } + /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. /// The check runs in a child (this test binary again), since on macOS it ends the process. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..14fbf6f19 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,7 @@ pub mod text_field; pub mod theme; pub mod titlebar; pub mod widgets; +pub mod window_place; #[cfg(test)] mod tests_activity; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..8703de67a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -493,6 +493,11 @@ pub enum KeywordingView { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// How large the app's window was and whether it was zoomed, to open it like that again (the + /// desktop app; absent until a window has rested at a size). Read leniently: a window that + /// is not one reads as none, and never costs the other settings. + #[serde(skip_serializing_if = "Option::is_none", deserialize_with = "crate::window_place::WindowPlace::lenient")] + pub window: Option<crate::window_place::WindowPlace>, /// Shared panel arrangement; absent in legacy settings until their first layout. pub docking: Option<crate::docking::Workspace>, #[serde(default = "crate::i18n::default_language")] @@ -1043,6 +1048,7 @@ pub struct SyncCounts { impl Default for UiState { fn default() -> Self { UiState { + window: None, docking: None, language: crate::i18n::default_language(), preview_build_seen: None, diff --git a/crates/ui-egui/src/window_place.rs b/crates/ui-egui/src/window_place.rs new file mode 100644 index 000000000..e88cca9a1 --- /dev/null +++ b/crates/ui-egui/src/window_place.rs @@ -0,0 +1,919 @@ +//! How large the app's window was and whether it was zoomed (macOS) / maximized, so that it opens +//! like that again. Kept with the settings (`ui.json`). +//! +//! The size is the window's as it is when neither zoomed nor full screen: a zoomed window opens +//! zoomed, and goes back to that size when it is un-zoomed. Full screen is not remembered (the +//! app opens as a window), and neither is where on the desktop the window was: the system places +//! it. A place can be on a display that is gone, and only the window system knows which +//! displays there are. +//! +//! Windows and Linux say whether a window is maximized. macOS says so only when the window opens +//! and when the app zooms it itself, so there the window's size is gone by as well (`Unsaid`): a +//! window is zoomed when the system said so, or when it was seen to come to fill its screen, and +//! for as long as it then keeps its size (or is fitted to another screen that it fills). That +//! is a judgement, and it errs to one side on purpose: whatever is in doubt is a window of the +//! size it has, which opens as large as it was left, and only the size it would have gone back +//! to is not kept. So it is with a zoom from the Window menu beside a Dock at the side of the +//! screen, or of a window already as wide as its screen. The other way round there is one case: +//! a window dragged out by hand until it is as wide as its screen and nearly as tall is taken +//! for zoomed, and opens filling the screen. +//! +//! This module only decides (what to remember from what the window reports, what to open with); +//! the desktop app builds its window from it and feeds it each frame. + +use serde::{Deserialize, Serialize}; + +/// The size a window opens with when none is remembered, in points. +pub const DEFAULT_SIZE: [f32; 2] = [1600.0, 1000.0]; +/// The smallest the window gets. +pub const MIN_SIZE: [f32; 2] = [900.0, 560.0]; +/// No window is wider or taller than this: a larger number in the settings is not a size. +const MAX_SIDE: f32 = 16_384.0; +/// How long a size must stay as it is before it is remembered: a window that is being resized +/// or zoomed (which the system animates) passes through sizes it never rests at. +const SETTLED_SECONDS: f64 = 0.5; +/// A size that has stayed this long when the app quits is remembered too (an animation changes +/// it with every frame; a person who resizes and quits at once has let go of it by then). +const SETTLED_AT_EXIT_SECONDS: f64 = 0.15; +/// Two sizes this near each other, across and down, are one size (a size goes through the +/// screen's pixels and back). +const SAME_WITHIN: f32 = 1.5; +/// How much of its screen's height a zoomed window leaves to the system at most: the menu bar +/// (taller beside a camera housing) and a Dock along the bottom at its largest. +const SCREEN_KEPT_DOWN: f32 = 240.0; +/// A little longer than the wait, so that the frame asked for comes after it and not just before. +const LOOK_AGAIN_MARGIN_SECONDS: f64 = 0.05; +/// How long a window that has just left full screen is not looked at: the system animates the +/// way back, and not every way of leaving says that it is still under way. +const AFTER_FULL_SCREEN_SECONDS: f64 = 1.0; + +/// How the window was. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowPlace { + /// Its inner size while neither zoomed nor full screen, in points (of the system, not of + /// the interface's own scale). + pub size: [f32; 2], + /// Zoomed (macOS) / maximized. + #[serde(default)] + pub zoomed: bool, +} + +fn size_is_one(size: [f32; 2]) -> bool { + size.iter().all(|s| s.is_finite() && (1.0..=MAX_SIDE).contains(s)) +} + +/// `size`, or the smallest the window gets where it is smaller (a window manager that does not +/// keep to the minimum, a script): what is remembered is always a size the window opens at. +fn at_least_min(size: [f32; 2]) -> [f32; 2] { + [size[0].max(MIN_SIZE[0]), size[1].max(MIN_SIZE[1])] +} + +fn same(a: [f32; 2], b: [f32; 2]) -> bool { + (a[0] - b[0]).abs() <= SAME_WITHIN && (a[1] - b[1]).abs() <= SAME_WITHIN +} + +/// Whether a window of `size` fills `screen` as a zoomed one does where the Dock is hidden or +/// along the bottom: from side to side, and down to what the system keeps. +fn fills(size: [f32; 2], screen: [f32; 2]) -> bool { + size[0] >= screen[0] - SAME_WITHIN && size[1] >= screen[1] - SCREEN_KEPT_DOWN +} + +/// Whether a window of `size` covers `screen` altogether, as one in full screen does. +fn covers(size: [f32; 2], screen: [f32; 2]) -> bool { + size[0] >= screen[0] - SAME_WITHIN && size[1] >= screen[1] - SAME_WITHIN +} + +impl WindowPlace { + /// What the settings hold (which a person or another version may have written), as + /// something a window can be: a size within bounds. `None` when the size is no size at all. + pub fn checked(self) -> Option<WindowPlace> { + size_is_one(self.size).then(|| WindowPlace { size: at_least_min(self.size), zoomed: self.zoomed }) + } + + /// Reads the window from the settings without letting it cost the rest of them: anything that + /// is not a window as this version writes it (another shape, a hand edit) reads as nothing + /// remembered, where a strict reading would have the whole file taken for damaged. + pub fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowPlace>, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value::<Option<WindowPlace>>(value).ok().flatten().and_then(WindowPlace::checked)) + } +} + +/// What the window reports in one frame, in the system's points. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Seen { + /// The size of what it shows. + pub size: Option<[f32; 2]>, + /// The size of the screen it is on. + pub screen: Option<[f32; 2]>, + /// What the window system last said of its being zoomed / maximized. + pub zoomed: bool, + pub full_screen: bool, + pub minimized: bool, +} + +impl Seen { + /// From what egui holds of the window: its `info`, the rectangle it draws in (`content`: the + /// one size every platform reports, Wayland too) and the interface's own scale (`zoom`), by + /// which egui's points differ from the system's. + pub fn of(info: &egui::ViewportInfo, content: egui::Rect, zoom: f32) -> Seen { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let scaled = |v: egui::Vec2| [v.x * zoom, v.y * zoom]; + Seen { + size: Some(scaled(content.size())).filter(|s| size_is_one(*s)), + screen: info.monitor_size.map(scaled).filter(|s| size_is_one(*s)), + zoomed: info.maximized == Some(true), + full_screen: info.fullscreen == Some(true), + minimized: info.minimized == Some(true), + } + } +} + +/// Follows the window from frame to frame and says what to remember of it. +#[derive(Debug)] +pub struct WindowWatch { + /// A size the window has, and since when (seconds), until it has rested there long enough. + resting: Option<(WindowPlace, f64)>, + /// Whether the window system's word on being zoomed is kept up to date. On macOS it is only + /// what was true when the window opened or the app last zoomed it itself (the top bar + /// double-clicked): Window ▸ Zoom and a drag of the window's edge go unseen. + told_live: bool, + /// What the watch goes by where that word is not live. + unsaid: Unsaid, + /// The window was full screen when it was last seen. + full_screen: bool, + /// Until when (seconds) a window that has left full screen is not looked at. + back_from_full_screen: Option<f64>, +} + +/// Where the window system's word on being zoomed is not live: what the window is taken for. +/// It is zoomed on the system's word, or when it was seen to come to fill its screen, and for as +/// long as it then keeps the size it has zoomed. Anything else is a window: taking a window for +/// zoomed would open it larger than it was left, where taking a zoomed one for a window opens +/// it as large as it was. +#[derive(Debug, Default)] +struct Unsaid { + /// The system's word at the last frame. When it changes, the app has zoomed or un-zoomed the + /// window itself, and what it is taken for starts over from that. + said: bool, + taken: Taken, +} + +#[derive(Clone, Copy, Debug, Default)] +enum Taken { + /// Not seen yet (or let go of): the system's word says what it is. + #[default] + Unseen, + /// A window, of this size when it was last seen. + Window { size: [f32; 2] }, + /// Zoomed: at this size since then (seconds), on this screen; `rested` once it has stayed + /// that size for a moment. Until then it may be on its way there (the system animates a + /// zoom), and `size` is the largest it has been, across and down: on its way it only grows. + Zoomed { size: [f32; 2], since: f64, screen: Option<[f32; 2]>, rested: bool }, +} + +impl Default for WindowWatch { + fn default() -> Self { + WindowWatch::told_live(!cfg!(target_os = "macos")) + } +} + +impl WindowWatch { + /// A watch on a platform where the window system's word on being zoomed is live, or not. + pub fn told_live(told_live: bool) -> WindowWatch { + WindowWatch { resting: None, told_live, unsaid: Unsaid::default(), full_screen: false, back_from_full_screen: None } + } + + /// The window is `seen` at time `now` (seconds), and `remembered` is what the settings hold: + /// returns what they should hold from now on. Whether the window is zoomed is taken at + /// once; a size only once the window has rested at it for a moment. A minimized or + /// full-screen window changes nothing, nor does one that has only just left full screen. + pub fn note(&mut self, remembered: Option<WindowPlace>, seen: &Seen, now: f64) -> Option<WindowPlace> { + if seen.full_screen { + self.full_screen = true; + self.back_from_full_screen = None; + self.let_go(); + return remembered; + } + if std::mem::take(&mut self.full_screen) { + self.back_from_full_screen = Some(now + AFTER_FULL_SCREEN_SECONDS); + } + if seen.minimized { + self.back_from_full_screen = None; + self.let_go(); + return remembered; + } + if self.back_from_full_screen.is_some_and(|until| now < until) { + self.let_go(); + return remembered; + } + self.back_from_full_screen = None; + let zoomed = if self.told_live { seen.zoomed } else { self.taken_for_zoomed(seen, now) }; + if zoomed { + // (its size now is the screen's, not the window's own: the size it goes back to stays) + self.resting = None; + return Some(WindowPlace { size: remembered.map_or(DEFAULT_SIZE, |r| r.size), zoomed: true }); + } + let Some(size) = seen.size else { + self.resting = None; + return remembered; + }; + if seen.screen.is_some_and(|screen| covers(size, screen)) { + // (no window of its own size covers its screen: full screen, though nothing says so) + self.resting = None; + return remembered; + } + let place = WindowPlace { size: at_least_min(size), zoomed: false }; + if Some(place) == remembered { + self.resting = None; + return remembered; + } + match self.resting { + Some((resting, since)) if resting == place => { + if now - since >= SETTLED_SECONDS { + self.resting = None; + return Some(place); + } + } + _ => self.resting = Some((place, now)), + } + // (meanwhile only what is certain: the window is not zoomed) + remembered.map(|r| WindowPlace { zoomed: false, ..r }) + } + + /// Lets go of whatever has not rested yet: nothing waits (and wakes the app) while the + /// window is not one to look at. A window taken for zoomed stays that, at the size it was + /// last seen at. + fn let_go(&mut self) { + self.resting = None; + if let Taken::Zoomed { rested, .. } = &mut self.unsaid.taken { + *rested = true; + } + } + + /// Whether the window is zoomed, where the window system does not keep saying (`Unsaid`). + fn taken_for_zoomed(&mut self, seen: &Seen, now: f64) -> bool { + let Some(size) = seen.size else { + // (nothing to go by in this frame: as it was taken, or by the system's word if it + // was never seen) + self.let_go(); + return match self.unsaid.taken { + Taken::Unseen => seen.zoomed, + taken => matches!(taken, Taken::Zoomed { .. }), + }; + }; + let zoomed_now = Taken::Zoomed { size, since: now, screen: seen.screen, rested: false }; + let fills_its_screen = |size: [f32; 2]| seen.screen.is_some_and(|screen| fills(size, screen)); + self.unsaid.taken = if matches!(self.unsaid.taken, Taken::Unseen) || seen.zoomed != self.unsaid.said { + if seen.zoomed { zoomed_now } else { Taken::Window { size } } + } else { + match self.unsaid.taken { + // it comes to fill its screen: zoomed from the Window menu + Taken::Window { size: was } if fills_its_screen(size) && !fills_its_screen(was) => zoomed_now, + Taken::Window { .. } | Taken::Unseen => Taken::Window { size }, + Taken::Zoomed { size: at, since, screen, rested } => { + // (at one size for a moment: rested, whether or not a frame came to see it) + let rested = rested || now - since >= SETTLED_SECONDS; + let other_screen = seen.screen.is_some() && screen != seen.screen; + if same(at, size) && (!other_screen || fills_its_screen(size)) { + // as it was (not carried, as large as it is, to a screen it does not fill) + Taken::Zoomed { size: at, since, screen, rested } + } else if !rested && !other_screen && size[0] >= at[0] - SAME_WITHIN && size[1] >= at[1] - SAME_WITHIN { + // on its way there: larger than it has been, and never smaller again + Taken::Zoomed { size: [size[0].max(at[0]), size[1].max(at[1])], since: now, screen, rested: false } + } else if rested && other_screen && fills_its_screen(size) { + // its screen changed (a display unplugged, another resolution) and it fills the new one + zoomed_now + } else { + // resized by its edge, or zoomed back from the Window menu + Taken::Window { size } + } + } + } + }; + self.unsaid.said = seen.zoomed; + matches!(self.unsaid.taken, Taken::Zoomed { .. }) + } + + /// How long until a size the window has will have rested long enough, if one is waiting: the + /// app then looks again (nothing else may ask for a frame once the window is let go of). + pub fn waits(&self, now: f64) -> Option<std::time::Duration> { + let (since, wait) = match (self.back_from_full_screen, self.resting, self.unsaid.taken) { + (Some(until), _, _) => (until - AFTER_FULL_SCREEN_SECONDS, AFTER_FULL_SCREEN_SECONDS), + (None, Some((_, since)), _) | (None, None, Taken::Zoomed { since, rested: false, .. }) => (since, SETTLED_SECONDS), + _ => return None, + }; + let left = (wait - (now - since)).clamp(0.0, wait) + LOOK_AGAIN_MARGIN_SECONDS; + Some(std::time::Duration::try_from_secs_f64(left).unwrap_or(std::time::Duration::from_secs(1))) + } + + /// The app quits at time `now`: what the settings should hold. A size the window has rested + /// at, if only briefly, is remembered; one it is passing through is not. + pub fn at_exit(&mut self, remembered: Option<WindowPlace>, now: f64) -> Option<WindowPlace> { + match self.resting.take() { + Some((place, since)) if now - since >= SETTLED_AT_EXIT_SECONDS => Some(place), + _ => remembered, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCREEN: [f32; 2] = [2560.0, 1440.0]; + + fn window(size: [f32; 2]) -> Seen { + Seen { size: Some(size), screen: Some(SCREEN), ..Default::default() } + } + + fn place(size: [f32; 2], zoomed: bool) -> WindowPlace { + WindowPlace { size, zoomed } + } + + /// Given a window that is resized and comes to rest, when it is watched, then its new size + /// is remembered once it has rested for a moment, and none of the sizes it passed through. + #[test] + fn a_window_is_remembered_at_the_size_it_comes_to_rest_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let mut remembered = None; + for (i, width) in [1000.0, 1100.0, 1200.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), i as f64 * 0.1); + assert_eq!(remembered, None, "passing through {width}"); + assert!(watch.waits(i as f64 * 0.1).is_some(), "and the app looks again"); + } + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.5); + assert_eq!(remembered, None, "not yet at rest"); + // (it has been this size since 0.3: the app looks again just after 0.8) + let wait = watch.waits(0.5).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - 0.35).abs() < 1e-6, "{wait}"); + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.9); + assert_eq!(remembered, Some(place([1300.0, 800.0], false))); + assert!(watch.waits(0.9).is_none()); + // as it is: nothing to wait for + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 5.0), remembered); + assert!(watch.waits(5.0).is_none()); + } + } + + /// Given a window at rest that is zoomed (the top bar double-clicked), when it is watched + /// where the window system says so, then it is remembered as zoomed at once, with the size + /// it had before, though it grew through other sizes on the way; un-zoomed, it is no longer + /// zoomed at once and keeps that size. + #[test] + fn a_zoomed_window_keeps_the_size_it_goes_back_to() { + let mut watch = WindowWatch::told_live(true); + let rest = place([1300.0, 800.0], false); + let mut remembered = Some(rest); + for (i, width) in [1600.0, 2000.0, 2400.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 1300.0]), 10.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest)); + } + let zoomed = Seen { zoomed: true, ..window([2560.0, 1412.0]) }; + remembered = watch.note(remembered, &zoomed, 10.2); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &zoomed, 30.0), Some(place([1300.0, 800.0], true)), "however long it stays zoomed"); + assert!(watch.waits(30.0).is_none()); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 31.0); + assert_eq!(remembered, Some(rest), "no longer zoomed, and the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 31.2), Some(rest)); + // zoomed before anything was remembered: it opens zoomed, and goes back to the usual size + assert_eq!(WindowWatch::told_live(true).note(None, &zoomed, 0.0), Some(place(DEFAULT_SIZE, true))); + // the system's word is the answer there: a window as large as its screen is a window + assert_eq!(WindowWatch::told_live(true).note(Some(rest), &window([2560.0, 1335.0]), 0.0).map(|p| p.zoomed), Some(false)); + } + + /// Given macOS, where the window system says whether the window is zoomed only as it opens + /// and when the app zooms it itself: when a window that opened zoomed is resized by its edge, + /// then it is a window again, remembered at the size it was given, though the system's word + /// still says zoomed; zoomed once more, it is zoomed. + #[test] + fn a_zoomed_window_resized_by_its_edge_is_a_window_again_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1335.0]) }; + let mut remembered = watch.note(Some(rest), &zoomed, 0.0); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.0).is_some(), "the size it is zoomed at is still to be seen at rest"); + remembered = watch.note(remembered, &zoomed, 0.6); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.6).is_none()); + assert_eq!(watch.note(remembered, &zoomed, 60.0), Some(rest), "however long it stays zoomed"); + // (a size goes through the screen's pixels and back: a point off is the same size) + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([2559.0, 1336.0]) }, 60.5), Some(rest)); + // its right edge dragged in a little, far less than a Dock is wide; the word is unchanged + let dragged = Seen { zoomed: true, ..window([2500.0, 1335.0]) }; + remembered = watch.note(remembered, &dragged, 61.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], false)), "not zoomed any more"); + remembered = watch.note(remembered, &dragged, 61.6); + assert_eq!(remembered, Some(place([2500.0, 1335.0], false)), "and at the size it was given"); + assert!(watch.waits(61.6).is_none()); + // zoomed again from the Window menu: back at the size it is zoomed at + assert_eq!(watch.note(remembered, &zoomed, 70.0), Some(place([2500.0, 1335.0], true))); + } + + /// Given macOS, when the app zooms the window itself (the top bar double-clicked), then the + /// system says so at once and the window is zoomed through all the sizes it grows through, + /// with the size it had, wherever the Dock is; un-zoomed the same way, it is a window again + /// at that size. + #[test] + fn a_window_the_app_zooms_is_zoomed_at_once_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], false); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + // (a Dock at the left: zoomed, the window is narrower than its screen) + for (i, width) in [1300.0, 1700.0, 2100.0, 2410.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 1415.0]) }, 1.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1300.0, 800.0], true)), "growing through {width}"); + } + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2410.0, 1415.0]) }, 2.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert!(watch.waits(2.0).is_none()); + for (i, width) in [2100.0, 1700.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), 3.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest), "shrinking through {width}"); + } + assert!(watch.waits(3.1).is_none(), "back at the size it had: nothing to wait for"); + } + + /// Given macOS and a window zoomed from the Window menu (the system's word stays "not + /// zoomed"), when it is seen to come to fill its screen from side to side, then it is zoomed, + /// with the size it had, and zoomed back it is a window at that size again. A window that + /// only grows large is not zoomed, on any screen. + #[test] + fn a_window_that_comes_to_fill_its_screen_is_zoomed_where_the_system_does_not_say() { + let rest = place([1300.0, 800.0], false); + // a window of 1300 × 800 on `screen` that is next seen at `size` + let zoomed_on = |screen: [f32; 2], size: [f32; 2]| { + let mut watch = WindowWatch::told_live(false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + watch.note(Some(rest), &seen([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &seen(size), 0.2).map(|p| p.zoomed) + }; + // under the menu bar, with the Dock hidden, small, or as large as it gets + for (screen, size) in [ + ([2560.0, 1440.0], [2560.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1335.0]), + ([2560.0, 1440.0], [2559.0, 1335.0]), + ([1728.0, 1117.0], [1728.0, 1084.0]), + ([1512.0, 982.0], [1512.0, 794.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(true), "{size:?} on {screen:?}"); + } + // the usual size on a laptop's screen, windows sized by hand to nearly the screen, one + // that is a little narrower than it, and one as wide as it and far from as tall + for (screen, size) in [ + ([1728.0, 1117.0], DEFAULT_SIZE), + ([1710.0, 1107.0], DEFAULT_SIZE), + ([1512.0, 982.0], [1400.0, 820.0]), + ([1470.0, 956.0], [1340.0, 780.0]), + ([2560.0, 1440.0], [2430.0, 1270.0]), + ([2560.0, 1440.0], [2300.0, 1200.0]), + ([2560.0, 1440.0], [2550.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1190.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(false), "{size:?} on {screen:?}"); + } + let mut watch = WindowWatch::told_live(false); + let filling = window([2560.0, 1335.0]); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 0.9); + remembered = watch.note(remembered, &filling, 1.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &filling, 9.0), remembered, "and its own size is not written over"); + remembered = watch.note(remembered, &window([1900.0, 1000.0]), 10.0); + assert_eq!(remembered, Some(rest), "zoomed back: a window, at the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 10.2), Some(rest)); + assert!(watch.waits(10.2).is_none()); + // not told apart: zoomed from the Window menu beside a Dock at the side, it is remembered + // as a window of that size + let mut watch = WindowWatch::told_live(false); + let beside_a_dock = window([2410.0, 1415.0]); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &beside_a_dock, 0.3); + assert_eq!(watch.note(Some(rest), &beside_a_dock, 0.9), Some(place([2410.0, 1415.0], false))); + // without a screen's size to go by, the system's word is all there is + let no_screen = Seen { size: Some([2560.0, 1335.0]), screen: None, zoomed: true, ..Default::default() }; + assert_eq!(WindowWatch::told_live(false).note(Some(rest), &no_screen, 0.0), Some(place([1300.0, 800.0], true))); + } + + /// Given macOS and a window that was left as wide as its screen and a little shorter (its + /// lower edge dragged up after a zoom), when the app opens it at that size, then it is a + /// window of that size, launch after launch: filling the screen is no sign of a zoom unless + /// the window was seen to come to it. Dragged shorter still, it is remembered at that. + #[test] + fn a_window_that_opens_as_wide_as_its_screen_is_a_window_where_the_system_does_not_say() { + for (screen, size) in [([1728.0, 1117.0], [1728.0, 1054.0]), ([1512.0, 982.0], [1512.0, 760.0]), ([3440.0, 1440.0], [3440.0, 1200.0])] { + let left = place(size, false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + for frame in 0..5 { + assert_eq!(watch.note(Some(left), &seen(size), f64::from(frame) * 0.3), Some(left), "{size:?} on {screen:?}"); + } + let shorter = [size[0], size[1] - 20.0]; + watch.note(Some(left), &seen(shorter), 2.0); + assert_eq!(watch.note(Some(left), &seen(shorter), 2.6), Some(place(shorter, false))); + } + // the same after a double-click on the top bar that changed nothing (the system's word was + // stale, and is now "not zoomed"): still the window it is + let mut watch = WindowWatch::told_live(false); + let screen = [1728.0, 1117.0]; + let left = place([1728.0, 1054.0], false); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 0.0); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 1.0); + // (taken for zoomed on the system's word, until that word is taken back) + let unsaid = Seen { size: Some(left.size), screen: Some(screen), zoomed: false, ..Default::default() }; + assert_eq!(watch.note(Some(left), &unsaid, 2.0), Some(left)); + assert_eq!(watch.note(Some(left), &unsaid, 9.0), Some(left)); + } + + /// Given macOS and a zoomed window, when it is zoomed back from the Window menu (or resized by + /// its edge) before it was ever seen at rest, then it is a window at once, and the size it + /// comes to rest at is the window's, not taken for the size it is zoomed at. + #[test] + fn a_window_zoomed_back_at_once_is_a_window_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + let mut remembered = watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + assert_eq!(remembered, Some(rest)); + for (i, width) in [2200.0, 1700.0, 1240.0, 1240.0, 1240.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 820.0]) }, 0.3 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1240.0, 820.0], false)), "{width}"); + } + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([1240.0, 820.0]) }, 60.0), Some(place([1240.0, 820.0], false))); + assert!(watch.waits(60.0).is_none()); + } + + /// Given macOS and a zoomed window, when its screen changes (the display unplugged, another + /// resolution) and it is fitted to the new one, then it is still zoomed and keeps the size + /// it goes back to. Moved to a larger screen that it does not fill, it is a window. + #[test] + fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_say() { + for said in [true, false] { + let rest = place([1240.0, 820.0], false); + let on = |screen: [f32; 2], size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), zoomed: said, ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &on([2560.0, 1440.0], [1240.0, 820.0]), 0.0); + if said { + // (the system's word as the window opened: a window; the app zooms it later) + remembered = watch.note(remembered, &Seen { zoomed: false, ..on([2560.0, 1440.0], [1240.0, 820.0]) }, 0.0); + } + for now in [1.0, 2.0] { + remembered = watch.note(remembered, &on([2560.0, 1440.0], [2560.0, 1415.0]), now); + } + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "said: {said}"); + for now in [10.0, 10.6, 60.0] { + remembered = watch.note(remembered, &on([1728.0, 1117.0], [1728.0, 1084.0]), now); + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "on the smaller screen at {now}, said: {said}"); + } + assert!(watch.waits(60.0).is_none()); + // dragged over to a screen it does not fill + remembered = watch.note(remembered, &on([3440.0, 1440.0], [1728.0, 1084.0]), 70.0); + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "said: {said}"); + } + // seen zoomed in one frame only, and fitted to another screen long after: it had rested + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + let fitted = Seen { size: Some([1728.0, 1084.0]), screen: Some([1728.0, 1117.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &fitted, 5.0), Some(rest)); + } + + /// Given a size the window has not rested at yet, when the window goes full screen or is + /// minimized, or reports no size, then nothing waits (the app is not woken again and again + /// for as long as that lasts), and back as a window its size is seen afresh. + #[test] + fn nothing_waits_while_the_window_is_full_screen_or_minimized() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window(SCREEN) }, + ] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert!(watch.waits(1.0).is_some()); + assert_eq!(watch.note(Some(rest), &away, 1.1), Some(rest)); + assert!(watch.waits(1.1).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &away, 900.0), Some(rest)); + assert!(watch.waits(900.0).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &window([1500.0, 900.0]), 901.0), Some(rest)); + let mut remembered = Some(rest); + for now in [902.2, 902.8] { + remembered = watch.note(remembered, &window([1500.0, 900.0]), now); + } + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?}"); + assert!(watch.waits(902.8).is_none()); + } + } + // (macOS: the same of a zoomed window's size that was still to be seen at rest) + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, 0.0); + assert!(watch.waits(0.0).is_some()); + watch.note(Some(rest), &away, 0.1); + assert!(watch.waits(0.1).is_none(), "{away:?}"); + } + } + + /// Given a zoomed window that was full screen, when it leaves full screen, then for a moment + /// it is not looked at (the system animates the way back, at sizes that are neither the + /// window's nor the zoomed one), the app looks again once that is over, and the window is + /// zoomed with the size it goes back to, as before. + #[test] + fn a_window_that_has_just_left_full_screen_is_not_looked_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for now in [0.0, 1.0] { + assert_eq!(watch.note(Some(rest), &zoomed, now), Some(rest)); + } + assert_eq!(watch.note(Some(rest), &Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, 5.0), Some(rest)); + // on the way back the system no longer says "full screen", nor "zoomed" where its word is live + for now in [9.0, 9.3, 9.6, 9.9] { + assert_eq!(watch.note(Some(rest), &Seen { zoomed: !live, ..window(SCREEN) }, now), Some(rest), "at {now}"); + let wait = watch.waits(now).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - (10.05 - now)).abs() < 1e-6, "at {now}: looks again after the moment, {wait}"); + } + assert_eq!(watch.note(Some(rest), &zoomed, 10.1), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 11.0), Some(rest)); + assert!(watch.waits(11.0).is_none()); + } + } + + /// Given macOS and a window dragged by its corner, when it reaches the screen's width and is + /// then dragged on, taller and little by little narrower again, and left narrower than the + /// screen, then it is a window at the size it was left at: a zoom only grows on its way, so + /// what gets narrower again is somebody's hand. + #[test] + fn a_window_dragged_wide_and_narrower_again_is_a_window_where_the_system_does_not_say() { + for per_frame in [1.0, 1.4] { + let screen = [1728.0, 1117.0]; + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let rest = place([1600.0, 950.0], false); + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &seen(rest.size), 0.0); + let mut size = [1728.0, 950.0]; + let mut now = 1.0; + for _ in 0..40 { + remembered = watch.note(remembered, &seen(size), now); + size = [size[0] - per_frame, size[1] + 3.0]; + now += 1.0 / 60.0; + } + for _ in 0..3 { + remembered = watch.note(remembered, &seen(size), now); + now += 0.3; + } + assert_eq!(remembered, Some(place(size, false)), "{per_frame} pt narrower a frame"); + } + // a zoomed window whose edge is dragged in slowly: a window once it is no longer the size it was zoomed at + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let mut remembered = Some(rest); + for frame in 1..=10 { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2560.0 - frame as f32, 1335.0]) }, 2.0 + f64::from(frame) / 60.0); + } + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "ten points narrower, a point at a time"); + // and one whose lower edge is dragged up a little: it still fills its screen, and is a window + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let shorter = Seen { zoomed: true, ..window([2560.0, 1385.0]) }; + watch.note(Some(rest), &shorter, 2.0); + assert_eq!(watch.note(Some(rest), &shorter, 2.6), Some(place([2560.0, 1385.0], false))); + } + + /// Given macOS and a window that opened zoomed, when its edge is dragged in before it was + /// seen at rest (one side only gets smaller), or it is carried to a larger screen in that + /// moment, then it is a window; and a frame without the screen's size costs a zoomed window + /// nothing. + #[test] + fn a_zoomed_window_changed_before_it_rested_is_a_window_where_the_system_does_not_say() { + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for at in [0.1, 0.3] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let dragged = Seen { zoomed: true, ..window([2000.0, 1415.0]) }; + assert_eq!(watch.note(Some(rest), &dragged, at), Some(place([1240.0, 820.0], false)), "dragged at {at}"); + assert_eq!(watch.note(Some(rest), &dragged, at + 0.6), Some(place([2000.0, 1415.0], false))); + } + // carried to a larger screen, as large as it is, a moment after it opened: no frame came in between + for at in [0.2, 0.52, 0.9] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let carried = Seen { size: Some([2560.0, 1415.0]), screen: Some([5120.0, 2880.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &carried, at).map(|p| p.zoomed), Some(false), "carried at {at}"); + } + // the screen's size missing for a frame + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &zoomed, now); + } + assert_eq!(watch.note(Some(rest), &Seen { screen: None, ..zoomed }, 2.0), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 2.1), Some(rest)); + } + + /// Given macOS and a window that opened zoomed and was then resized by hand (the system's + /// word still says zoomed), when it goes full screen and back, or reports no size for a + /// frame, then it is still the window it was: what was made out of it is not let go of for + /// the system's old word. + #[test] + fn a_window_made_out_is_not_taken_for_zoomed_again_on_the_systems_old_word() { + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let dragged = Seen { zoomed: true, ..window([1500.0, 900.0]) }; + let mut remembered = watch.note(Some(rest), &dragged, 2.0); + remembered = watch.note(remembered, &dragged, 2.6); + assert_eq!(remembered, Some(place([1500.0, 900.0], false))); + remembered = watch.note(remembered, &away, 5.0); + for now in [8.0, 8.6, 9.2, 30.0] { + remembered = watch.note(remembered, &dragged, now); + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?} at {now}"); + } + } + } + + /// Given a platform where the system says whether the window is maximized, when a window + /// grows until it fills its screen, then it is a window all the same: only the system's + /// word counts there. And the watch the app makes goes by that word everywhere but on macOS. + #[test] + fn where_the_system_says_only_its_word_counts() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + let mut remembered = Some(rest); + for (i, size) in [[1300.0, 800.0], [2000.0, 1100.0], [2560.0, 1335.0], [2560.0, 1335.0], [2560.0, 1335.0]].into_iter().enumerate() { + remembered = watch.note(remembered, &window(size), i as f64 * 0.4); + assert_eq!(remembered.map(|p| p.zoomed), Some(false)); + } + assert_eq!(remembered, Some(place([2560.0, 1335.0], false))); + // the app's own watch: opened zoomed and then seen at a hand size, the system's word unchanged + let mut watch = WindowWatch::default(); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let zoomed = watch.note(Some(rest), &Seen { zoomed: true, ..window([1500.0, 900.0]) }, 2.0).map(|p| p.zoomed); + assert_eq!(zoomed, Some(!cfg!(target_os = "macos")), "macOS goes by the size, the others by the word"); + } + + /// Given a window that left full screen a moment ago, when it is minimized or goes full + /// screen again, then nothing waits; and a window that covers its screen altogether (full + /// screen, though nothing says so) is not remembered as a size, however long it stays. + #[test] + fn nothing_waits_when_a_window_just_back_from_full_screen_goes_away_again() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { full_screen: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &Seen { full_screen: true, ..window(SCREEN) }, 1.0); + watch.note(Some(rest), &window([1300.0, 800.0]), 2.0); + assert!(watch.waits(2.0).is_some(), "the moment after full screen"); + for now in [2.1, 2.7, 60.0] { + assert_eq!(watch.note(Some(rest), &away, now), Some(rest)); + assert!(watch.waits(now).is_none(), "{away:?} at {now}"); + } + } + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + for now in [1.0, 1.6, 2.2, 60.0] { + // (where the system does not say, a window that comes to cover its screen is zoomed) + assert_eq!(watch.note(Some(rest), &window(SCREEN), now).map(|p| p.size), Some(rest.size), "covering its screen at {now}"); + } + assert!(watch.waits(60.0).is_none()); + } + } + + /// Given a window smaller than the app's smallest (a window manager that does not keep to + /// it, a script), when it rests, then the smallest size is remembered, once: what is + /// remembered and what is seen agree from then on, and nothing waits. + #[test] + fn a_window_smaller_than_the_smallest_is_remembered_once() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let small = window([640.0, 700.0]); + let mut remembered = watch.note(Some(place([1300.0, 800.0], false)), &small, 0.0); + remembered = watch.note(remembered.and_then(WindowPlace::checked), &small, 0.6); + assert_eq!(remembered, Some(place([MIN_SIZE[0], 700.0], false))); + for frame in 0..20 { + let now = 1.0 + f64::from(frame) * 0.1; + assert_eq!(watch.note(remembered.and_then(WindowPlace::checked), &small, now), remembered); + assert!(watch.waits(now).is_none(), "frame {frame}"); + } + } + } + + /// Full screen and a minimized window are not sizes to open at: nothing is remembered of + /// them. Nor of a window that reports no size. + #[test] + fn full_screen_and_minimized_windows_are_not_remembered() { + let rest = place([1300.0, 800.0], false); + for seen in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window([0.0, 0.0]) }, + ] { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + assert_eq!(watch.note(Some(rest), &seen, 0.0), Some(rest), "{seen:?}"); + assert_eq!(watch.note(Some(rest), &seen, 5.0), Some(rest), "{seen:?}"); + assert_eq!(watch.at_exit(Some(rest), 6.0), Some(rest)); + } + } + } + + /// Given a window that was resized, when the app quits before the size has rested its + /// moment, then the size is remembered if the window had been let go of, and not if it was + /// still changing with every frame (a zoom the system was animating). + #[test] + fn a_size_the_window_rested_at_is_remembered_when_the_app_quits() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert_eq!(watch.at_exit(Some(rest), 1.3), Some(place([1500.0, 900.0], false))); + assert_eq!(watch.at_exit(Some(rest), 9.0), Some(rest), "said once"); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.00); + watch.note(Some(rest), &window([1700.0, 1000.0]), 1.02); + assert_eq!(watch.at_exit(Some(rest), 1.04), Some(rest), "a size it was passing through"); + assert_eq!(WindowWatch::told_live(true).at_exit(None, 3.0), None); + } + + /// What egui holds of the window is read in the system's points: a size every platform + /// reports (the rectangle the app draws in), brought back from the interface's own scale. + #[test] + fn the_window_is_seen_in_the_systems_points() { + use egui::{Rect, ViewportInfo, pos2, vec2}; + let info = ViewportInfo { monitor_size: Some(vec2(1280.0, 720.0)), maximized: Some(true), fullscreen: Some(false), ..Default::default() }; + let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(650.0, 400.0)); + // (the interface at twice its size: egui's points are half the system's) + let seen = Seen::of(&info, content, 2.0); + assert_eq!(seen, Seen { size: Some([1300.0, 800.0]), screen: Some([2560.0, 1440.0]), zoomed: true, full_screen: false, minimized: false }); + let away = ViewportInfo { maximized: Some(false), fullscreen: Some(true), minimized: Some(true), ..Default::default() }; + let seen = Seen::of(&away, content, 1.0); + assert!(!seen.zoomed && seen.full_screen && seen.minimized); + // nothing reported but the content (Wayland): the size is there all the same + assert_eq!(Seen::of(&ViewportInfo::default(), content, 1.0).size, Some([650.0, 400.0])); + // a scale or a size that is none + assert_eq!(Seen::of(&info, content, f32::NAN).size, Some([650.0, 400.0])); + assert_eq!(Seen::of(&info, Rect::NOTHING, 1.0).size, None); + assert_eq!(Seen::of(&info, Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 0.0)), 1.0).size, None); + } + + /// What the settings hold is not trusted as it is: a size is brought within what a window can + /// be, and anything that is no window reads as nothing remembered, without an error (which + /// would have the whole settings file taken for damaged). + #[test] + fn what_was_remembered_is_checked_and_never_costs_the_other_settings() { + #[derive(Deserialize, Debug, PartialEq)] + struct Settings { + #[serde(default, deserialize_with = "WindowPlace::lenient")] + window: Option<WindowPlace>, + other: u8, + } + let read = |window: &str| serde_json::from_str::<Settings>(&format!(r#"{{"window":{window},"other":7}}"#)).map(|s| s.window); + assert_eq!(read(r#"{"size":[1300.0,800.0],"zoomed":true}"#).unwrap(), Some(place([1300.0, 800.0], true))); + assert_eq!(read(r#"{"size":[1300,800]}"#).unwrap(), Some(place([1300.0, 800.0], false))); + // (a place on the desktop, as a version that remembered one would write it, is passed over) + assert_eq!(read(r#"{"size":[1300,800],"position":[40,60],"zoomed":false}"#).unwrap(), Some(place([1300.0, 800.0], false))); + assert_eq!(read(r#"{"size":[200,100]}"#).unwrap().map(|p| p.size), Some(MIN_SIZE), "too small to be the app's window"); + for odd in [ + "null", + "{}", + r#""x""#, + "5", + "[]", + "true", + r#"{"size":[1,2,3]}"#, + r#"{"size":[1]}"#, + r#"{"size":"big"}"#, + r#"{"size":[null,800]}"#, + r#"{"size":[1300,800],"zoomed":1}"#, + r#"{"size":[0,0]}"#, + r#"{"size":[-5,800]}"#, + r#"{"size":[1e39,800]}"#, + r#"{"size":[99999,800]}"#, + ] { + assert_eq!(read(odd).unwrap(), None, "{odd}"); + } + assert_eq!(serde_json::from_str::<Settings>(r#"{"other":7}"#).unwrap(), Settings { window: None, other: 7 }, "settings from before"); + // and what is written is read back + let written = serde_json::to_string(&place([1300.5, 800.25], true)).unwrap(); + assert_eq!(read(&written).unwrap(), Some(place([1300.5, 800.25], true))); + for size in [[f32::NAN, 800.0], [f32::INFINITY, 800.0], [0.5, 800.0]] { + assert_eq!(place(size, false).checked(), None, "{size:?}"); + } + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..9e08afd26 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-BEHAV-WINDOW: the window opens at the size it was left at, zoomed if it was; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -49,13 +49,13 @@ cited here still exists. Formerly docs/parity.md. | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | +| X. Cross-cutting behaviours (BEHAV) | 23 | 7 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | +| **Total** | 409 | 54 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 535 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 174. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -481,6 +481,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | | LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | +| LR-BEHAV-WINDOW | The window opens as it was left | P2 | 🟡 | `crates/ui-egui/src/window_place.rs`, `apps/lightcraft/src/main.rs` (`as_remembered`, `PrefsWriter::tick`) | the desktop app's window opens at the size it last rested at, and zoomed (macOS) / maximized if it was, with the size it goes back to when un-zoomed (`window` in the UI state: `{size, zoomed}`, in the system's points); it used to open at 1600 × 1000 every time. Full screen is not remembered (the app opens as a window), and neither is the window's place on the desktop: the system places it, since a remembered place can be on a display that is gone. Windows and Linux say whether the window is maximized; macOS says so only at launch and when the app zooms the window itself, so there the watch also goes by the window's size: zoomed when the system said so or the window was seen to come to fill its screen (as wide as it, and no more than a menu bar and a Dock shorter), and for as long as it then keeps its size or is fitted to another screen that it fills; whatever is in doubt is a window of the size it has, which opens as large as it was left. A `window` in the settings that is not one reads as none and costs no other setting. Partial: run on macOS with one display only (Windows, X11 and Wayland follow the sources of winit and egui, not a run); on macOS a window zoomed from the Window menu beside a Dock at the side of the screen, or when it was already as wide as its screen, is remembered as a window of that size (the size it would go back to is not kept), and a window dragged out by hand until it is as wide as its screen and nearly as tall is taken for zoomed; a window is not looked at for a second after it leaves full screen; the web app has no window of its own | ## Y. Menus @@ -790,6 +791,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | LR-BEHAV-WINDOW (new, 🟡): the desktop app's window opens at the size it was left at, zoomed / maximized if it was; full screen and the place on the desktop are not remembered | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..9fd9d0729 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -8,6 +8,20 @@ ## 2026-10-13 +### The window opens at the size you left it +- LightCraft's window opens at the size it had when you last used it, and zoomed (macOS) / maximized if it was: it + used to open at 1600 × 1000 every time. Double-click the top bar to zoom the window, quit, and it is zoomed when + you come back; un-zoom it and it goes back to the size it had before. +- Full screen is not remembered (the app opens as a window), and neither is where on the desktop the window was: + the system places it. +- On macOS the system does not tell LightCraft when a window is zoomed from the Window menu or resized after a + zoom, so it goes by the window's size, and when in doubt takes the window for a window of the size it has: it + comes back as large as you left it, but not as a zoomed window. That is so after a zoom from the Window menu + with the Dock at a side of the screen, or of a window already as wide as the screen. Zoomed by a double-click + on the top bar it comes back zoomed. The other way round, a window you drag out until it is as wide as the + screen and nearly as tall counts as zoomed. +- For scripts: a size set with `ui.resize` is remembered like any other, so a script that needs a size sets it. + ### HDR merges keep the lens correction - An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it From 57375eb4b55459f80ba86c5487bf42e72fe149a8 Mon Sep 17 00:00:00 2001 From: Andrew Frahn <andrew@emmertex.com> Date: Wed, 14 Oct 2026 23:19:02 +1100 Subject: [PATCH 151/315] Colour grading wheels: vivid sRGB rim fading evenly to neutral Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- crates/ui-egui/src/panels/edit.rs | 49 ++++++++++++++++++++++------- crates/ui-egui/src/tests_grading.rs | 23 ++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..f01e8f9e6 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1629,20 +1629,47 @@ pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option<WheelLock>) -> (f } fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { + p.add(wheel_mesh(c, rad)); + p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); +} + +/// The picture of a grading wheel: a hue disc whose rim is fully saturated display sRGB at full +/// brightness, fading to a neutral grey at the centre. It is built from concentric rings so the +/// saturation ramps evenly with the radius instead of one fan of triangles blending each hue +/// straight into grey (which made the whole disc look muddy, issue #77). Only the drawing: +/// what a position means for the edit is [`wheel_pick`]. +pub(crate) fn wheel_mesh(c: Pos2, rad: f32) -> Mesh { + const SEGMENTS: u32 = 72; + const RINGS: u32 = 12; + /// Brightness (HSV value) at the centre; the rim is 1.0. + const CENTRE_V: f32 = 0.6; + let colour = |hue_deg: f32, t: f32| { + let rgb = lightcraft_color::perceptual::hsv_to_rgb(hue_deg, t, CENTRE_V + (1.0 - CENTRE_V) * t); + let u = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(u(rgb[0]), u(rgb[1]), u(rgb[2])) + }; let mut mesh = Mesh::default(); - let n = 48; - mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: Color32::from_gray(128) }); - for i in 0..=n { - let a = i as f32 / n as f32 * std::f32::consts::TAU; - let rgb = lightcraft_color::perceptual::hsv_to_rgb(a.to_degrees(), 0.75, 0.8); - let col = Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8); - mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad, uv: Pos2::ZERO, color: col }); - if i > 0 { - mesh.indices.extend([0, i as u32, i as u32 + 1]); + mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: colour(0.0, 0.0) }); + // ring k (1..=RINGS) holds SEGMENTS + 1 vertices starting at index 1 + (k - 1) * (SEGMENTS + 1) + let row = SEGMENTS + 1; + for k in 1..=RINGS { + let t = k as f32 / RINGS as f32; + for i in 0..=SEGMENTS { + let a = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad * t, uv: Pos2::ZERO, color: colour(a.to_degrees(), t) }); } } - p.add(mesh); - p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); + for i in 0..SEGMENTS { + mesh.indices.extend([0, 1 + i, 2 + i]); + } + for k in 1..RINGS { + let inner = 1 + (k - 1) * row; + let outer = inner + row; + for i in 0..SEGMENTS { + mesh.indices.extend([inner + i, outer + i, outer + i + 1, inner + i, outer + i + 1, inner + i + 1]); + } + } + mesh } /// Quick Develop (grid with several photos selected): relative steps applied to every selected diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs index 6abce5460..f6903550c 100644 --- a/crates/ui-egui/src/tests_grading.rs +++ b/crates/ui-egui/src/tests_grading.rs @@ -352,3 +352,26 @@ fn double_click_still_resets_the_wheel_with_a_modifier_held() { h.step(); assert_eq!(wheel_value(&h, "midtones"), (0.0, 0.0)); } + +/// The wheels used to look muddy. Painted on screen, the rim of each hue reaches full +/// saturation at normal brightness and the centre is neutral. +#[test] +fn wheel_rim_is_fully_saturated_and_the_centre_neutral() { + let mut h = demo(); + let img = h.snapshot(SETTLE); + let r = widget(&h, "wheel:midtones").unwrap(); + let at = |dx: f32, dy: f32| { + let p = r.center() + egui::vec2(dx, dy) * r.width() / 2.0; + img[(p.x as usize, p.y as usize)] + }; + for deg in [0.0f32, 60.0, 120.0, 180.0, 240.0, 300.0] { + let a = deg.to_radians(); + let p = at(a.cos() * 0.93, -a.sin() * 0.93); + let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b())); + assert!(hi >= 225 && lo <= 40, "rim at {deg}° is not a vivid hue: {p:?}"); + } + // just off the centre (the puck sits on it at zero), the disc is still close to neutral + let p = at(0.12, 0.12); + let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b())); + assert!(hi - lo <= 40 && lo >= 100, "centre is not a neutral grey: {p:?}"); +} From 11fb6f3156e1880ec812fbd588404e1f278f14a6 Mon Sep 17 00:00:00 2001 From: Aleksandr Batyrev <aiiiotus99i@gmail.com> Date: Wed, 14 Oct 2026 17:25:42 +0500 Subject: [PATCH 152/315] chore(contributors): add Aleksandr Batyrev's real-name credit --- contributors/people.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/contributors/people.toml b/contributors/people.toml index cf5af42d2..8acfa791f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -33,4 +33,5 @@ real_name = "Arekatla Nishanth Chowdary" sync_github_name = true [people.FooVoo] +real_name = "Aleksandr Batyrev" sync_github_name = true From d2523e85443b9ed84ae47163a18213c0bbbd38d2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Sun, 11 Oct 2026 15:12:54 -0400 Subject: [PATCH 153/315] Preview cache: cached thumbnails are no longer taken for the person's photos The thumbnail cache wrote .jpg files into the library's thumbs folder, which is usually inside Pictures: photo galleries listed every thumbnail as a photo (issues #610, #1066), and every read brought the file's date forward, so each launch made them all look new. - Cache files are named <32 hex>.lcthumb (JPEG data as before). A .jpg of before is taken over under the new name when it is asked for; the rest when the cache is tidied, which the engine does once per cache folder, on a thread of its own with a row in the activity stack that shows its progress and can stop it. Nothing is rendered again. - Reading writes nothing: what was read is remembered for the session (a full cache lets it go last), and only a file that went unused for a week has its date brought forward. - A folder that holds nothing but the cache gets a CACHEDIR.TAG, when the cache makes it and when it is tidied; one that also holds somebody's files is not tagged (issue #98). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/activity.rs | 2 +- crates/engine/src/library.rs | 33 +++ crates/engine/src/tests_face_tiles.rs | 2 +- crates/engine/src/tests_library.rs | 47 +++++ crates/preview/src/disk.rs | 252 ++++++++++++++++++++--- crates/preview/src/tests.rs | 278 +++++++++++++++++++++++++- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- docs/background-tasks.md | 2 +- docs/whats-new.md | 18 ++ 17 files changed, 624 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 6fc618039..8c0a44f18 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -73,7 +73,7 @@ impl Unit { pub struct TaskInfo { pub id: u64, /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, - /// `faces`, `denoise`, `findMissing`, `applePhotos`. + /// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`. pub kind: &'static str, /// English; the frontend translates it. pub label: String, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 0305623f2..286616d2e 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -322,6 +322,38 @@ impl SettingsLoad { } impl Session { + /// Take over what the thumbnail cache wrote under its name of before (`.jpg` files, which + /// photo galleries took for the person's photos: issues #610, #1066), once for a cache + /// folder that was not made or tidied by this build yet. On a thread of its own, with a row + /// in the activity stack that can stop it: a large cache is some 100 000 files to rename. + fn tidy_thumbnail_cache(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::atomic::{AtomicBool, Ordering}; + let cache = self.media.rendered.clone(); + if !cache.disk().is_some_and(|d| d.needs_tidying()) { + return; + } + let stop = std::sync::Arc::new(AtomicBool::new(false)); + let task = self.activity.start("cache", "Tidying the thumbnail cache", crate::activity::Cancel::Flag(stop.clone())); + let spawned = std::thread::Builder::new().name("lc-thumbs-tidy".into()).spawn(move || { + // the row stays while this runs (it is dropped with the closure, however that ends) + let _row = task; + if let Some(disk) = cache.disk() { + // (also given up when the library is closed or another is opened: the cache + // is retired then, and it is gone on with when this library opens again) + let stopped = || stop.load(Ordering::Relaxed) || cache.generation() == u64::MAX; + let taken = disk.take_over_old(&stopped, &|done, of| _row.progress(done as u64, of as u64)); + log::info!("thumbnail cache: {taken} thumbnails taken over under their new name in {}", disk.dir().display()); + } + }); + // (not started: the thumbnails are still found one by one, and it is tried at the next open) + if let Err(e) = spawned { + log::warn!("thumbnail cache: could not be tidied: {e}"); + } + } + } + /// Check, without closing the library open now, that [`Session::open_library`] can be expected /// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not /// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]). @@ -504,6 +536,7 @@ impl Session { self.media.clear_sources(); if on_disk { self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); + self.tidy_thumbnail_cache(); } let view_written = self.view_json(); self.library = Some(Library { diff --git a/crates/engine/src/tests_face_tiles.rs b/crates/engine/src/tests_face_tiles.rs index 3af23f0da..9aa4b05e8 100644 --- a/crates/engine/src/tests_face_tiles.rs +++ b/crates/engine/src/tests_face_tiles.rs @@ -222,7 +222,7 @@ fn face_tiles_never_crash() { assert!(job.run().rendered.is_ok()); let thumbs = s.library.as_ref().unwrap().thumbs_dir(); drop(s); - let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg")); + let entry = thumbs.join(&key[..2]).join(format!("{key}.lcthumb")); assert!(entry.exists(), "the tile was written to the thumbnail cache"); std::fs::write(&entry, b"not a jpeg").unwrap(); let mut s = open(&dir); diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..d06288678 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -675,3 +675,50 @@ fn an_album_filter_loop_is_refused() { assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused"); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None); } + +/// Given a library whose thumbnail cache is from before (`.jpg` files in `thumbs`), when it is +/// opened, then the cache is tidied in the background with a row in the activity stack: the +/// thumbnails are taken over under a name that is no picture's, the folder is tagged as a +/// cache, and the next time the library opens there is nothing to tidy and no row. +#[test] +fn a_thumbnail_cache_from_before_is_tidied_in_the_background_when_the_library_opens() { + let dir = temp_dir("tidy-thumbs"); + let mut s = Session::new().with_fs(); + s.open_library(&dir, true).unwrap(); + assert!(s.activity.list().is_empty(), "a new library has nothing to tidy"); + s.close_library().unwrap(); + drop(s); + // the cache as an older build left it + let thumbs = dir.join("thumbs"); + let _ = std::fs::remove_dir_all(&thumbs); + let img = lightcraft_raster::Rgba8 { width: 32, height: 20, data: vec![[90, 120, 150, 255]; 640] }; + let jpeg = lightcraft_preview::encode_jpeg(&img).unwrap(); + let names: Vec<String> = (0..2000u16).map(|i| lightcraft_preview::hash_bytes(&i.to_le_bytes()).to_string()).collect(); + for hex in &names { + std::fs::create_dir_all(thumbs.join(&hex[..2])).unwrap(); + std::fs::write(thumbs.join(&hex[..2]).join(format!("{hex}.jpg")), &jpeg).unwrap(); + } + + let mut s = Session::new().with_fs(); + s.open_library(&dir, true).unwrap(); + // (two thousand files to rename: the row is there when the library has just opened) + let rows = s.activity.list(); + assert!(rows.iter().any(|r| r.kind == "cache" && r.label == "Tidying the thumbnail cache" && r.cancellable), "{rows:?}"); + let t0 = std::time::Instant::now(); + while !s.activity.list().is_empty() && t0.elapsed() < std::time::Duration::from_secs(20) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(s.activity.list().is_empty()); + for hex in &names { + let shard = thumbs.join(&hex[..2]); + assert!(shard.join(format!("{hex}.lcthumb")).is_file() && !shard.join(format!("{hex}.jpg")).exists(), "{hex}"); + } + assert!(thumbs.join("CACHEDIR.TAG").is_file()); + s.close_library().unwrap(); + drop(s); + let mut s = Session::new().with_fs(); + s.open_library(&dir, true).unwrap(); + assert!(s.activity.list().is_empty(), "tidied once"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index e8f4e80bf..7510ffa38 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -1,15 +1,35 @@ -//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.jpg`, bounded in bytes. +//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.lcthumb`, bounded in bytes. //! //! The cache is disposable: files are written via temp + rename (readers never see partial //! files) but not fsynced; unreadable files are deleted and re-rendered. When the total size -//! exceeds the budget, the least recently used files (by modification time, refreshed on read) -//! are removed down to 80 % of the budget. +//! exceeds the budget, the least recently used files (by modification time) are removed down to +//! 80 % of the budget. //! -//! The cache only ever lists, counts or deletes files of its own name shape -//! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see -//! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a -//! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98). -//! A shard directory that is a symlink or junction is not followed (issue #1504). +//! **The cache is nobody's pictures** (issues #610, #1066). A library is often in the person's +//! Pictures folder, which photo galleries and backups look through, so: +//! - its files are JPEG data under a name that is no picture's ([`EXT`]). They used to be +//! `.jpg`, and every thumbnail (one per photo, size and state of its edits) showed up in the +//! system's gallery as a photo of the person's. Files from then are taken over under the new +//! name: one that is asked for at once, the rest when the cache is tidied +//! ([`DiskCache::take_over_old`], which the engine does once per folder in the background). +//! Nothing is rendered again for it. +//! - reading writes nothing. A file's date was brought forward on every read, so every launch +//! made every thumbnail in view look new; now only one that was not used for [`STALE`] is +//! touched. What was read in this session is remembered in memory, and a full cache lets it +//! go last. +//! - a folder that holds nothing but the cache is tagged as one ([`TAG`]): when the cache +//! makes it, and when it is tidied. +//! +//! An older build that is run again writes `.jpg` files once more; in a folder that was tidied +//! already those are taken over one by one as they are asked for, and the rest go as the cache +//! fills. +//! +//! The cache only ever lists, counts, renames or deletes files of its own name shape +//! (`<2 hex>/<32 hex>.lcthumb`, the `.jpg` of before, and the `<32 hex>.tmp…` temp files of +//! [`DiskCache::put`], see [`is_cache_file`]): the directory may be shared with other files +//! (e.g. a library opened on a photo folder that already has a `thumbs/` folder), and those are +//! never touched (issue #98). A shard directory that is a symlink or junction is not followed +//! (issue #1504). use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -21,6 +41,32 @@ use crate::Hash128; const QUALITY: u8 = 90; +/// What a cached thumbnail's file is named after its key: not a picture's ending, so that +/// nothing that looks for the person's photos takes the cache for them. +pub const EXT: &str = "lcthumb"; +/// What it was named before (JPEG data then as now). +const OLD_EXT: &str = "jpg"; + +/// How long a thumbnail goes unused before a read brings its date forward (the date is what a +/// full cache lets go by, oldest first). Reads within it write nothing. +pub const STALE: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600); + +/// The file that says the cache has looked through its folder for thumbnails under the name +/// of before and taken them over ([`DiskCache::take_over_old`]): written after a whole pass, +/// whatever else the folder holds, so that it is done once. +const TIDIED: &str = ".lightcraft-thumbs"; + +/// Files an operating system leaves in a folder that was looked at: nobody's, and no reason +/// to take a folder for more than a cache. +const DROPPINGS: [&str; 3] = [".DS_Store", "desktop.ini", "Thumbs.db"]; + +/// The file that says a folder is a cache, for backup programs and the like (the Cache +/// Directory Tagging Specification: the first line is the signature). +pub const TAG: &str = "CACHEDIR.TAG"; +const TAG_TEXT: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\ +# This folder holds LightCraft's thumbnail cache. It is safe to delete: LightCraft renders what it needs again.\n\ +# See https://bford.info/cachedir/\n"; + pub struct DiskCache { dir: PathBuf, /// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]). @@ -30,6 +76,8 @@ pub struct DiskCache { pub hits: AtomicU64, pub misses: AtomicU64, pub writes: AtomicU64, + /// The thumbnails read in this session: a full cache lets them go last ([`Self::prune`]). + read: Mutex<std::collections::HashSet<u128>>, } impl DiskCache { @@ -41,6 +89,7 @@ impl DiskCache { hits: AtomicU64::new(0), misses: AtomicU64::new(0), writes: AtomicU64::new(0), + read: Mutex::new(Default::default()), } } @@ -60,22 +109,57 @@ impl DiskCache { } fn path(&self, key: Hash128) -> PathBuf { + self.named(key, EXT) + } + + fn named(&self, key: Hash128, ext: &str) -> PathBuf { let hex = key.to_string(); - self.dir.join(&hex[..2]).join(format!("{hex}.jpg")) + // (32 hex digits: the first two are there) + self.dir.join(hex.get(..2).unwrap_or("00")).join(format!("{hex}.{ext}")) + } + + /// The file's bytes: under its name, or under the name it had before ([`OLD_EXT`]), which + /// it then no longer has. + fn read(&self, key: Hash128) -> Option<(PathBuf, Vec<u8>)> { + let p = self.path(key); + // a shard that is a symlink (or junction) leads outside the cache: nothing is read + // through it, under either name + if !p.parent().is_some_and(is_real_dir) { + return None; + } + if let Ok(bytes) = std::fs::read(&p) { + return Some((p, bytes)); + } + let old = self.named(key, OLD_EXT); + let Ok(bytes) = std::fs::read(&old) else { + // (taken over by the tidying between the two looks: it is under the new name now) + return std::fs::read(&p).ok().map(|bytes| (p, bytes)); + }; + // (taken over; if that can't be done it is read where it is, and tried again next time) + match std::fs::rename(&old, &p) { + Ok(()) => Some((p, bytes)), + Err(_) => Some((old, bytes)), + } } pub fn get(&self, key: Hash128) -> Option<Rgba8> { - let p = self.path(key); - let Some(bytes) = p.parent().filter(|d| is_real_dir(d)).and_then(|_| std::fs::read(&p).ok()) else { + let Some((p, bytes)) = self.read(key) else { self.misses.fetch_add(1, Ordering::Relaxed); return None; }; match decode_jpeg(&bytes) { Some(img) => { self.hits.fetch_add(1, Ordering::Relaxed); - // refresh recency for pruning - if let Ok(f) = std::fs::File::options().write(true).open(&p) { - let _ = f.set_modified(std::time::SystemTime::now()); + // Recency for pruning, without a write for every read: it is remembered here for + // this session, and only a file that was not used for a long while has its date + // brought forward (for the sessions after). + self.read.lock().unwrap_or_else(|e| e.into_inner()).insert(key.0); + let now = std::time::SystemTime::now(); + let unused = std::fs::metadata(&p).and_then(|m| m.modified()).ok().and_then(|at| now.duration_since(at).ok()); + if unused.is_some_and(|d| d > STALE) + && let Ok(f) = std::fs::File::options().write(true).open(&p) + { + let _ = f.set_modified(now); } Some(img) } @@ -92,9 +176,15 @@ impl DiskCache { let Some(bytes) = encode_jpeg(img) else { return }; let p = self.path(key); let Some(parent) = p.parent() else { return }; + // a folder the cache makes for itself says that it is a cache; one that was there + // already may hold somebody's files, and is not declared one + let made = !self.dir.exists(); if std::fs::create_dir_all(parent).is_err() || !is_real_dir(parent) { return; } + if made { + let _ = std::fs::write(self.dir.join(TAG), TAG_TEXT); + } // Serialize the file replacement and its size accounting. A repeated render of // the same key replaces its JPEG; it must not count as another cache entry. let mut total = self.total.lock().unwrap_or_else(|e| e.into_inner()); @@ -117,9 +207,17 @@ impl DiskCache { /// All cache files (only names of the cache's own shape, see [`is_cache_file`]): /// (path, size, modified). fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> { + self.scan_until(&|| false).unwrap_or_default() + } + + /// [`Self::scan`], given up (`None`) when `stop` says so, which is asked for each shard. + fn scan_until(&self, stop: &dyn Fn() -> bool) -> Option<Vec<(PathBuf, u64, std::time::SystemTime)>> { let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir(&self.dir) else { return out }; + let Ok(rd) = std::fs::read_dir(&self.dir) else { return Some(out) }; for sub in rd.flatten() { + if stop() { + return None; + } let sub_name = sub.file_name(); let Some(sub_name) = sub_name.to_str() else { continue }; if !is_shard_name(sub_name) { @@ -144,21 +242,123 @@ impl DiskCache { } } } - out + Some(out) } - /// Remove least recently used files until the cache is at most 80 % of its budget. + /// Whether the folder may hold thumbnails under the name they had before ([`OLD_EXT`]): it + /// is there, and neither made by a cache that names them as now (which tags it, [`TAG`]) + /// nor looked through by one ([`TIDIED`]). Cheap: asked when a library opens. + pub fn needs_tidying(&self) -> bool { + self.dir.is_dir() && !self.dir.join(TAG).exists() && !self.dir.join(TIDIED).exists() + } + + /// Take over the thumbnails cached under the name of before: each `<32 hex>.jpg` of the + /// cache's own shape is renamed (or removed, when the thumbnail is there under the new name + /// too). After a whole pass the folder is marked as looked through, so that this is done + /// once, and tagged as a cache if it holds nothing but the cache. Returns how many were + /// renamed. `stop` is asked for each shard listed and each file renamed; `progress` is told + /// (done, of) as the old files are gone through. + /// + /// This walks the whole folder and renames file by file (a cache of 2 GB is some 100 000 + /// files): it is for a background thread, never for the interface. + pub fn take_over_old(&self, stop: &dyn Fn() -> bool, progress: &dyn Fn(usize, usize)) -> usize { + // (listed in full before anything is renamed: a folder that changes while it is listed + // lists some of its files twice) + let Some(listed) = self.scan_until(stop) else { return 0 }; + let old: Vec<(PathBuf, u64)> = listed.into_iter().filter(|f| f.0.extension().is_some_and(|ext| ext == OLD_EXT)).map(|f| (f.0, f.1)).collect(); + let (mut taken, mut removed, mut blocked) = (0usize, 0u64, false); + for (done, (path, len)) in old.iter().enumerate() { + if stop() { + self.forget(removed); + return taken; + } + progress(done, old.len()); + let new = path.with_extension(EXT); + match std::fs::symlink_metadata(&new) { + // there under both names: one and the same thumbnail + Ok(m) if m.is_file() => { + if std::fs::remove_file(path).is_ok() { + removed = removed.saturating_add(*len); + } + } + // something that is no file has the name: not the cache's to move aside + Ok(_) => blocked = true, + Err(_) => match std::fs::rename(path, &new) { + Ok(()) => taken += 1, + Err(_) => blocked = true, + }, + } + } + progress(old.len(), old.len()); + self.forget(removed); + // done, also when the folder holds other things; not when something could not be taken + // over (a folder that can't be written: it is tried again at the next open) + if !blocked { + let _ = std::fs::write(self.dir.join(TIDIED), b""); + if self.holds_only_the_cache() { + let _ = std::fs::write(self.dir.join(TAG), TAG_TEXT); + } + } + taken + } + + /// Take `bytes` that are no longer on disk off the total, when it is known. + fn forget(&self, bytes: u64) { + if let Some(total) = self.total.lock().unwrap_or_else(|e| e.into_inner()).as_mut() { + *total = total.saturating_sub(bytes); + } + } + + /// Whether everything in the folder is the cache's: its own marks, and shard folders that + /// hold only its own files under the name they have now. What an operating system leaves in + /// a folder that was looked at ([`DROPPINGS`]) does not count against it. (A folder with + /// anything else in it may be somebody's, issue #98.) + fn holds_only_the_cache(&self) -> bool { + let dropping = |name: &str| DROPPINGS.contains(&name) || name.starts_with("._"); + let Ok(entries) = std::fs::read_dir(&self.dir) else { return false }; + entries.flatten().all(|e| { + let name = e.file_name(); + let Some(name) = name.to_str() else { return false }; + if name == TAG || name == TIDIED || dropping(name) { + return true; + } + // (a shard that is a symlink or junction leads somewhere else: not only the cache's) + if !is_shard_name(name) || !is_real_dir(&e.path()) { + return false; + } + let Ok(inside) = std::fs::read_dir(e.path()) else { return false }; + inside.flatten().all(|f| { + // a link among the files is not a file the cache wrote + let link = f.file_type().is_ok_and(|t| t.is_symlink()); + !link && f.file_name().to_str().is_some_and(|f| dropping(f) || (is_cache_file(name, f) && !f.ends_with(&format!(".{OLD_EXT}")))) + }) + }) + } + + /// Remove least recently used files until the cache is at most 80 % of its budget: by the + /// date of each file, the ones read in this session last. pub fn prune(&self) { let mut files = self.scan(); let mut total: u64 = files.iter().map(|f| f.1).sum(); let target = self.budget() / 5 * 4; - files.sort_by_key(|f| f.2); + let read = self.read.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let in_use = |p: &Path| key_of(p).is_some_and(|k| read.contains(&k)); + files.sort_by_cached_key(|f| (in_use(&f.0), f.2)); + let mut gone = Vec::new(); for (p, len, _) in files { if total <= target { break; } if std::fs::remove_file(&p).is_ok() { - total -= len; + total = total.saturating_sub(len); + gone.extend(key_of(&p)); + } + } + // (what is gone is no longer "read this session": the set holds only what is there) + if !gone.is_empty() { + let mut read = self.read.lock().unwrap_or_else(|e| e.into_inner()); + for key in gone { + read.remove(&key); } } *self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(total); @@ -183,6 +383,11 @@ fn is_real_dir(p: &Path) -> bool { std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()) } +/// The key a cache file is of, by its name. +fn key_of(path: &Path) -> Option<u128> { + path.file_stem().and_then(|s| s.to_str()).and_then(|hex| u128::from_str_radix(hex, 16).ok()) +} + fn is_lower_hex(s: &str) -> bool { s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } @@ -192,9 +397,10 @@ fn is_shard_name(s: &str) -> bool { s.len() == 2 && is_lower_hex(s) } -/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.jpg` -/// (whose first two digits are the shard) or a `<32 hex>.tmp<alphanumeric>` temp file of -/// [`DiskCache::put`]. Anything else in the cache directory belongs to someone else. +/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.lcthumb` +/// (whose first two digits are the shard), the `<32 hex>.jpg` it wrote before, or a +/// `<32 hex>.tmp<alphanumeric>` temp file of [`DiskCache::put`]. Anything else in the cache +/// directory belongs to someone else. pub fn is_cache_file(shard: &str, name: &str) -> bool { if !is_shard_name(shard) { return false; @@ -203,7 +409,7 @@ pub fn is_cache_file(shard: &str, name: &str) -> bool { if stem.len() != 32 || !is_lower_hex(stem) || !stem.starts_with(shard) { return false; } - ext == "jpg" || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric())) + ext == EXT || ext == OLD_EXT || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric())) } /// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0). diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 6c93e51af..17e1ba89c 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -114,7 +114,7 @@ fn disk_cache_roundtrip_and_prune() { let k2 = hash_bytes(b"corrupt"); d.put(k2, &img); let hex = k2.to_string(); - let p = dir.join(&hex[..2]).join(format!("{hex}.jpg")); + let p = dir.join(&hex[..2]).join(format!("{hex}.lcthumb")); std::fs::write(&p, b"not a jpeg").unwrap(); assert!(d.get(k2).is_none()); assert!(!p.exists()); @@ -143,7 +143,7 @@ fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() { cache.put(a, &gradient(120, 90, 9)); let path = |key: Hash128| { let hex = key.to_string(); - dir.join(&hex[..2]).join(format!("{hex}.jpg")) + dir.join(&hex[..2]).join(format!("{hex}.lcthumb")) }; let actual = std::fs::metadata(path(a)).unwrap().len() + std::fs::metadata(path(b)).unwrap().len(); assert_eq!(cache.size(), actual); @@ -375,8 +375,57 @@ fn disk_cache_never_touches_foreign_files() { for p in &foreign { assert!(p.exists(), "foreign file deleted: {}", p.display()); } + assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.lcthumb")); + // (what the cache wrote before it stopped naming its files as pictures is still its own) assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg")); assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); + assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.lcthumb.bak")); + let _ = std::fs::remove_dir_all(&dir); +} + +// ---- the cache is nobody's pictures (issues #610, #1066) + +/// Every file under `dir`, by its path from there. +fn files_under(dir: &std::path::Path) -> Vec<String> { + let mut out = Vec::new(); + let mut todo = vec![dir.to_path_buf()]; + while let Some(d) = todo.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + if e.path().is_dir() { + todo.push(e.path()); + } else { + out.push(e.path().strip_prefix(dir).unwrap().to_string_lossy().replace('\\', "/")); + } + } + } + out.sort(); + out +} + +/// The endings by which a photo gallery, a backup of "my pictures" or a media scanner takes a +/// file for a picture. +const PICTURE_ENDINGS: [&str; 12] = ["jpg", "jpeg", "jpe", "jfif", "png", "gif", "bmp", "tif", "tiff", "webp", "heic", "avif"]; + +fn is_named_as_a_picture(name: &str) -> bool { + name.rsplit_once('.').is_some_and(|(_, ending)| PICTURE_ENDINGS.contains(&ending.to_ascii_lowercase().as_str())) +} + +/// Given a library in the Pictures folder, when thumbnails are cached, then no file the cache +/// writes is named as a picture: a photo gallery that looks through Pictures does not list the +/// cache as the person's photos, however many thumbnails there are. +#[test] +fn nothing_the_cache_writes_is_named_as_a_picture() { + let dir = temp_dir("not-pictures"); + let d = DiskCache::new(&dir, 10 << 20); + for i in 0..12u8 { + d.put(hash_bytes(&[i, 9]), &gradient(160, 100, i)); + } + let written = files_under(&dir); + assert!(written.len() >= 12, "{written:?}"); + let pictures: Vec<&String> = written.iter().filter(|f| is_named_as_a_picture(f)).collect(); + assert!(pictures.is_empty(), "named as pictures: {pictures:?}"); + // and they are found again under those names + assert!((0..12u8).all(|i| d.get(hash_bytes(&[i, 9])).is_some())); let _ = std::fs::remove_dir_all(&dir); } @@ -407,6 +456,231 @@ fn disk_cache_ignores_symlinked_shard_directories() { let _ = std::fs::remove_dir_all(&outside); } +/// A folder whose shard is a symlink to somewhere else is not only the cache's: tidying it +/// neither follows the link nor tags the folder as a cache (issue #1504). +#[cfg(unix)] +#[test] +fn tidying_does_not_declare_a_folder_with_a_symlinked_shard_a_cache() { + let dir = temp_dir("symlink-tidy"); + let outside = temp_dir("symlink-tidy-outside"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let hex = hash_bytes(&[9, 8]).to_string(); + // (named as a thumbnail is now: only the link says it is not the cache's) + let sentinel = outside.join(format!("{hex}.lcthumb")); + std::fs::write(&sentinel, b"somebody's file").unwrap(); + std::os::unix::fs::symlink(&outside, dir.join(&hex[..2])).unwrap(); + let d = DiskCache::new(&dir, 100 << 20); + assert!(d.needs_tidying()); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 0, "nothing behind the link is taken over"); + assert!(sentinel.is_file(), "the file behind the link is where it was"); + assert!(!dir.join("CACHEDIR.TAG").exists(), "a folder with a link in it is not declared a cache"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&outside); +} + +/// The bytes of every file under `dir` that is the cache's own. +fn cache_bytes_on_disk(dir: &std::path::Path) -> u64 { + files_under(dir) + .iter() + .filter(|f| f.split_once('/').is_some_and(|(shard, name)| disk::is_cache_file(shard, name))) + .map(|f| std::fs::metadata(dir.join(f)).unwrap().len()) + .sum() +} + +/// Given a cache from before, whose thumbnails are `.jpg` files, when one is asked for, then it +/// is found (nothing is rendered again for the change of name) and is no longer a `.jpg`. When +/// the cache is tidied, none of its old files is a `.jpg` any more, each thumbnail is there +/// once and counted once, and the folder (which holds nothing but the cache) is tagged. Asking +/// how big the cache is renames nothing: that is asked from the interface. +#[test] +fn thumbnails_cached_as_jpg_files_are_taken_over_under_the_new_name() { + let dir = temp_dir("taken-over"); + let old_path = |k: Hash128| { + let hex = k.to_string(); + dir.join(&hex[..2]).join(format!("{hex}.jpg")) + }; + // (thousands in one folder: renaming while a folder is listed lists some of them twice) + let keys: Vec<Hash128> = (0..3000u128).map(|i| Hash128((0xab << 120) | i)).collect(); + let jpeg = disk::encode_jpeg(&gradient(32, 20, 3)).unwrap(); + for k in &keys { + std::fs::create_dir_all(old_path(*k).parent().unwrap()).unwrap(); + std::fs::write(old_path(*k), &jpeg).unwrap(); + } + // one that is there under both names (an older build ran after a newer one) + std::fs::write(old_path(keys[1]).with_extension("lcthumb"), &jpeg).unwrap(); + + let d = DiskCache::new(&dir, 100 << 20); + let back = d.get(keys[0]).expect("found under its old name"); + assert_eq!((back.width, back.height), (32, 20)); + assert!(!old_path(keys[0]).exists(), "and no longer a .jpg"); + assert_eq!(d.writes.load(std::sync::atomic::Ordering::Relaxed), 0, "nothing was rendered or written anew"); + let before = d.size(); + assert_eq!(before, cache_bytes_on_disk(&dir)); + assert!(old_path(keys[2]).exists(), "asking for the size renames nothing"); + + let seen = std::sync::atomic::AtomicUsize::new(0); + let taken = d.take_over_old(&|| false, &|done, of| { + assert_eq!(of, 2999, "the old files, less the one already asked for"); + seen.store(done, std::sync::atomic::Ordering::Relaxed); + }); + assert_eq!(taken, 2998, "all but the one already asked for and the one that was there twice"); + assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 2999, "and its progress came to the end"); + let left: Vec<String> = files_under(&dir).into_iter().filter(|f| is_named_as_a_picture(f)).collect(); + assert_eq!(left, Vec::<String>::new(), "nothing of the cache's is named as a picture"); + assert_eq!(d.size(), cache_bytes_on_disk(&dir), "each thumbnail counted once"); + assert_eq!(d.size(), jpeg.len() as u64 * 3000); + assert!(keys.iter().all(|k| d.get(*k).is_some())); + assert!(dir.join("CACHEDIR.TAG").is_file(), "a folder that holds nothing but the cache is tagged"); + // tidied once: there is nothing more to do, and it says so + assert!(!d.needs_tidying()); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 0); + // a thumbnail written while the size was not known is counted with the rest, not alone + let fresh = DiskCache::new(&dir, 100 << 20); + fresh.put(hash_bytes(b"one more"), &gradient(32, 20, 9)); + assert_eq!(fresh.size(), cache_bytes_on_disk(&dir)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Given a `thumbs` folder that also holds somebody else's files (a library opened on a photo +/// folder, issue #98), when the cache is tidied, then their files are as they were, whatever +/// they are named, and the folder is not declared a cache. A thing that is no file under a +/// thumbnail's new name keeps the old one from being touched. And tidying can be stopped. +#[test] +fn tidying_leaves_what_is_somebodys_and_can_be_stopped() { + let dir = temp_dir("tidy-shared"); + let jpeg = disk::encode_jpeg(&gradient(32, 20, 3)).unwrap(); + let key = hash_bytes(b"ours"); + let hex = key.to_string(); + let ours = dir.join(&hex[..2]).join(format!("{hex}.jpg")); + std::fs::create_dir_all(ours.parent().unwrap()).unwrap(); + std::fs::write(&ours, &jpeg).unwrap(); + let theirs = [dir.join("holiday.jpg"), dir.join(&hex[..2]).join("holiday.jpg"), dir.join("album").join("one.jpg")]; + for p in &theirs { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"somebody's picture").unwrap(); + } + // something that is no file has another thumbnail's new name + let blocked = hash_bytes(b"blocked").to_string(); + let blocked_old = dir.join(&blocked[..2]).join(format!("{blocked}.jpg")); + std::fs::create_dir_all(blocked_old.with_extension("lcthumb")).unwrap(); + std::fs::write(&blocked_old, &jpeg).unwrap(); + + let d = DiskCache::new(&dir, 100 << 20); + assert!(d.needs_tidying()); + assert_eq!(d.take_over_old(&|| true, &|_, _| {}), 0, "stopped before anything"); + assert!(ours.exists() && d.needs_tidying(), "and to be gone on with"); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1); + assert!(!ours.exists() && ours.with_extension("lcthumb").is_file()); + assert!(blocked_old.is_file(), "left where it is"); + for p in &theirs { + assert_eq!(std::fs::read(p).unwrap(), b"somebody's picture", "{}", p.display()); + } + assert!(!dir.join("CACHEDIR.TAG").exists(), "a folder with somebody's files in it is not declared a cache"); + assert!(d.needs_tidying(), "one could not be taken over: it is tried again"); + // with that out of the way it is done, once, though the folder is still shared + std::fs::remove_dir(blocked_old.with_extension("lcthumb")).unwrap(); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1); + assert!(!d.needs_tidying() && !dir.join("CACHEDIR.TAG").exists()); + + // what a file manager leaves behind in a folder it showed does not make it somebody's + let own = temp_dir("tidy-droppings"); + let k = hash_bytes(b"k").to_string(); + std::fs::create_dir_all(own.join(&k[..2])).unwrap(); + std::fs::write(own.join(&k[..2]).join(format!("{k}.jpg")), &jpeg).unwrap(); + for left in [own.join(".DS_Store"), own.join("desktop.ini"), own.join(&k[..2]).join("Thumbs.db"), own.join("._thumbs")] { + std::fs::write(left, b"x").unwrap(); + } + let d = DiskCache::new(&own, 100 << 20); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1); + assert!(own.join("CACHEDIR.TAG").is_file() && !d.needs_tidying()); + let _ = std::fs::remove_dir_all(&own); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Given a full cache, when it has to let thumbnails go, then the ones read in this session +/// stay over ones that were written later and never looked at: what is in use is not the first +/// to go, though reading it wrote nothing. +#[test] +fn a_full_cache_keeps_what_was_read_this_session() { + let dir = temp_dir("read-kept"); + let d = DiskCache::new(&dir, 60_000); + let keys: Vec<Hash128> = (0..8u8).map(|i| hash_bytes(&[i, 7])).collect(); + for (i, k) in keys.iter().enumerate() { + d.put(*k, &gradient(160, 100, i as u8)); + } + // the first two written are the ones in use + assert!(d.get(keys[0]).is_some() && d.get(keys[1]).is_some()); + for i in 0..40u8 { + d.put(hash_bytes(&[i, 8]), &gradient(160, 100, i)); + } + assert!(d.size() <= 60_000, "{}", d.size()); + assert!(d.get(keys[0]).is_some() && d.get(keys[1]).is_some(), "what was read stayed"); + assert!(d.get(keys[5]).is_none(), "what was written after it and never read went"); + let _ = std::fs::remove_dir_all(&dir); +} + +fn modified(p: &std::path::Path) -> std::time::SystemTime { + std::fs::metadata(p).unwrap().modified().unwrap() +} + +/// Given cached thumbnails, when they are read (as at every launch, for every photo in view), +/// then nothing is written: a file read today is as it was, date and all. Only one that was not +/// used for a long while has its date brought forward, so that what is still in use is not the +/// first to go when the cache is full. +#[test] +fn reading_a_thumbnail_writes_nothing() { + let dir = temp_dir("read-only"); + let d = DiskCache::new(&dir, 10 << 20); + let (fresh, stale) = (hash_bytes(b"fresh"), hash_bytes(b"stale")); + let path = |k: Hash128| { + let hex = k.to_string(); + dir.join(&hex[..2]).join(format!("{hex}.lcthumb")) + }; + d.put(fresh, &gradient(160, 100, 1)); + d.put(stale, &gradient(160, 100, 2)); + let day = std::time::Duration::from_secs(24 * 3600); + let now = std::time::SystemTime::now(); + let set = |k: Hash128, at: std::time::SystemTime| std::fs::File::options().write(true).open(path(k)).unwrap().set_modified(at).unwrap(); + set(fresh, now - day); + set(stale, now - day * 60); + let before = (modified(&path(fresh)), std::fs::read(path(fresh)).unwrap()); + for _ in 0..3 { + assert!(d.get(fresh).is_some()); + } + assert_eq!((modified(&path(fresh)), std::fs::read(path(fresh)).unwrap()), before, "read, and untouched"); + assert!(d.get(stale).is_some()); + assert!(modified(&path(stale)) > now - day, "one not used for two months is in use again"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A folder the cache makes for itself says that it is a cache (`CACHEDIR.TAG`, which backup +/// programs go by), and the tag is not one of its thumbnails: clearing the cache leaves it. A +/// folder that was there before (it may hold somebody's files, issue #98) is not tagged. +#[test] +fn a_folder_the_cache_makes_is_tagged_as_a_cache_and_one_that_was_there_is_not() { + let (made, theirs) = (temp_dir("tagged"), temp_dir("untagged")); + let own = made.join("thumbs"); + let d = DiskCache::new(&own, 10 << 20); + assert!(!own.exists(), "nothing is made before there is something to keep"); + d.put(hash_bytes(b"a"), &gradient(160, 100, 1)); + let tag = std::fs::read_to_string(own.join("CACHEDIR.TAG")).expect("tagged"); + assert!(tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55"), "{tag}"); + d.clear(); + assert_eq!(d.size(), 0); + assert!(own.join("CACHEDIR.TAG").is_file()); + + std::fs::create_dir_all(&theirs).unwrap(); + std::fs::write(theirs.join("holiday.jpg"), b"somebody's picture").unwrap(); + let d = DiskCache::new(&theirs, 10 << 20); + d.put(hash_bytes(b"a"), &gradient(160, 100, 1)); + assert!(d.get(hash_bytes(b"a")).is_some()); + assert!(!theirs.join("CACHEDIR.TAG").exists(), "a folder with somebody's files in it is not declared a cache"); + for dir in [made, theirs] { + let _ = std::fs::remove_dir_all(dir); + } +} + /// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or /// after the timeout. #[test] diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..3c4deff7d 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,6 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..a2e1563aa 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,6 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Tidying the thumbnail cache": "Ordenando la caché de miniaturas" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..8a384cb93 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,6 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Tidying the thumbnail cache": "Rangement du cache des miniatures" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..e5524a0cc 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,6 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Tidying the thumbnail cache": "サムネールキャッシュを整理中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..171f5d444 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,6 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Tidying the thumbnail cache": "Organizando o cache de miniaturas" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..16dd44c5c 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,6 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Tidying the thumbnail cache": "Упорядочивание кэша миниатюр" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..89ec0a3df 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,6 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Tidying the thumbnail cache": "Упорядкування кешу мініатюр" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..0535a140f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,6 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Tidying the thumbnail cache": "正在整理缩略图缓存" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..aa4c48d7e 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,6 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Tidying the thumbnail cache": "正在整理縮圖快取" } diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 02336c97c..d4e1b34c0 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -33,7 +33,7 @@ guard.detail(&file_name); dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, `Clone + Send`). - **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, - `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + `download`, `faces`, `cache`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog in `crates/ui-egui/locales/`. - **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..b8f9e0767 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -218,6 +218,24 @@ - Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a magnified preview isn't taken for the photo when checking focus at 1:1. +### The thumbnail cache is no longer taken for your photos +- LightCraft's cached thumbnails no longer show up in Windows Photos / Gallery or other photo apps as pictures of + yours (issues #610, #1066). They were ordinary `.jpg` files in the library's `thumbs` folder, which is usually + inside Pictures, so every thumbnail (one for each photo, size and state of its edits) was listed as a photo. + They are now kept under a name that is no picture's. The ones already there are taken over, not rendered again: + the first time a library opens after this update, "Tidying the thumbnail cache" shows in the activity stack + while they are renamed (it can be stopped, and goes on at the next open). +- Opening a library no longer rewrites the date of every thumbnail it shows, which made them all look new at each + launch. Reading a thumbnail writes nothing (but for one that went unused for a week, whose date is brought + forward); what was read is remembered for the session, so a full cache still lets go of what is not in use first. +- A `thumbs` folder that holds nothing but the cache says that it is one (`CACHEDIR.TAG`), so backup programs that + go by that can leave it out. It is safe to delete while LightCraft is closed, as before. A `thumbs` folder that + also holds files of yours (a library opened on a photo folder that had one) is not tagged, and your files in it + are left as they are. Note that files there named exactly like the cache's own (`ab/ab…` with 32 hex digits and + `.jpg`) have always been taken for the cache's, and are renamed with it. +- If you go back to an older version, it renders its thumbnails again as `.jpg`. This version then takes those + over one by one as they are used; the rest stay until the cache makes room. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a From e0690b5f639d0538958ba537be57a0e54be4128b Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 07:11:56 -0500 Subject: [PATCH 154/315] LR-EXP-QUALITY: Use optimized Huffman tables for JPEG exports (#1373) Build the tables from each image's own symbol counts. Same pixels, smaller files. --- crates/codecs/src/jpeg_par.rs | 319 ++++++++++++++++++++++++++++++---- docs/file-format-parity.md | 5 +- docs/parity-checklist.md | 5 +- docs/whats-new.md | 5 + 4 files changed, 296 insertions(+), 38 deletions(-) diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index 6ec9b6a80..c8d9e5023 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -3,8 +3,11 @@ //! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC //! prediction restarts at every marker, so bands are entropy-coded independently on all cores and //! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100 -//! quality convention; Huffman coding uses the Annex K example tables (interleaved baseline, readable -//! by every decoder). Supports grayscale and YCbCr 4:4:4 / 4:2:0. +//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81 +//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays +//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is +//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr +//! 4:4:4 / 4:2:0. use crate::encode::ChromaSubsampling; @@ -61,16 +64,103 @@ impl Huff { let (mut c, mut k) = (0u16, 0usize); for (len, &n) in bits.iter().enumerate() { for _ in 0..n { - code[vals[k] as usize] = (c, len as u8 + 1); - c += 1; + if let Some(&v) = vals.get(k) { + code[v as usize] = (c, len as u8 + 1); + } + c = c.wrapping_add(1); k += 1; } - c <<= 1; + c = c.wrapping_shl(1); } Huff { code } } } +/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte). +type Freq = [u64; 256]; + +/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal +/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code +/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol +/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`. +fn optimal_table(freq: &Freq) -> ([u8; 16], Vec<u8>) { + // 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is + // then dropped so no real code is all ones (K.2) + let mut f = [0u64; 257]; + f[..256].copy_from_slice(freq); + if f[..256].iter().all(|&c| c == 0) { + f[0] = 1; + } + f[256] = 1; + let mut size = [0usize; 257]; + let mut next: [Option<usize>; 257] = [None; 257]; + loop { + // the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1) + let mut c1: Option<usize> = None; + for i in 0..257 { + if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) { + c1 = Some(i); + } + } + let Some(c1) = c1 else { break }; + let mut c2: Option<usize> = None; + for i in 0..257 { + if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) { + c2 = Some(i); + } + } + let Some(c2) = c2 else { break }; + f[c1] = f[c1].saturating_add(f[c2]); + f[c2] = 0; + // every symbol in both trees goes one level deeper; c2's chain is appended to c1's + let mut k = c1; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + next[k] = Some(c2); + let mut k = c2; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + } + // counts per code length; 257 symbols need at most 256 levels + let mut bits = [0usize; 257]; + for &s in &size { + if s > 0 { + bits[s.min(256)] += 1; + } + } + // limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the + // deepest level of a full tree always holds an even number of codes) + for i in (17..257).rev() { + while bits[i] >= 2 { + let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break }; + bits[i] -= 2; + bits[i - 1] += 1; + bits[j + 1] += 2; + bits[j] -= 1; + } + } + // drop the reserved symbol's code: it is one of the longest + if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) { + bits[i] -= 1; + } + // symbols by code length, then by value (K.4); the reserved symbol is not listed + let mut vals = Vec::new(); + for len in 1..257 { + vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8)); + } + let mut out = [0u8; 16]; + for (o, &b) in out.iter_mut().zip(&bits[1..=16]) { + *o = b.min(255) as u8; + } + (out, vals) +} + fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] { let q = quality.clamp(1, 100) as u32; let scale = if q < 50 { 5000 / q } else { 200 - 2 * q }; @@ -177,13 +267,15 @@ fn magnitude(v: i32) -> (u32, u32) { (size, bits & ((1u32 << size) - 1)) } -fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huff) { +/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the +/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by +/// its `n` extra bits `bits`. +#[inline(always)] +fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) { let d = q[0] as i32 - *pred; *pred = q[0] as i32; let (s, b) = magnitude(d); - let (c, l) = dc.code[s as usize]; - w.put(c as u32, l as u32); - w.put(b, s); + sym(false, s as usize, b, s); let mut run = 0u32; for &zi in &ZIGZAG[1..] { let v = q[zi] as i32; @@ -192,22 +284,45 @@ fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huf continue; } while run >= 16 { - let (c, l) = ac.code[0xF0]; - w.put(c as u32, l as u32); + sym(true, 0xF0, 0, 0); run -= 16; } let (s, b) = magnitude(v); - let (c, l) = ac.code[((run << 4) | s) as usize]; - w.put(c as u32, l as u32); - w.put(b, s); + sym(true, ((run << 4) | s) as usize, b, s); run = 0; } if run > 0 { - let (c, l) = ac.code[0x00]; - w.put(c as u32, l as u32); + sym(true, 0x00, 0, 0); } } +/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma, +/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a +/// baseline coefficient needs at most 11). +type Token = u32; + +/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]). +fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec<Token>, freq: &mut [Freq; 4]) { + let base = if chroma { 2 } else { 0 }; + block_symbols(q, pred, |is_ac, s, bits, n| { + let table = base + is_ac as usize; + freq[table][s & 0xFF] += 1; + out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14); + }); +} + +/// Code a band's symbols with `tables` (indexed as in [`Token`]). +fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec<u8> { + let mut w = Bits::new(); + for &t in tokens { + let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14); + let (c, l) = tables[table as usize].code[s as usize]; + w.put(c as u32, l as u32); + w.put(bits, n); + } + w.finish() +} + /// Planar component samples (level-shifted to −128..127) with edge replication on read. struct Plane { w: usize, @@ -249,7 +364,8 @@ fn dht(out: &mut Vec<u8>, class_id: u8, bits: &[u8; 16], vals: &[u8]) { segment(out, 0xC4, &p); } -/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped). +/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman +/// tables made for the image. pub fn encode( data: &[u8], width: usize, @@ -258,6 +374,21 @@ pub fn encode( quality: u8, sub: ChromaSubsampling, app_segments: &[(u8, Vec<u8>)], +) -> Vec<u8> { + encode_with(data, width, height, channels, quality, sub, app_segments, true) +} + +/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables. +#[allow(clippy::too_many_arguments)] +fn encode_with( + data: &[u8], + width: usize, + height: usize, + channels: usize, + quality: u8, + sub: ChromaSubsampling, + app_segments: &[(u8, Vec<u8>)], + optimize: bool, ) -> Vec<u8> { let gray = channels == 1; let (hs, vs) = match (gray, sub) { @@ -331,42 +462,67 @@ pub fn encode( let ql = scaled_quant(&Q_LUMA, quality); let qc = scaled_quant(&Q_CHROMA, quality); let (rl, rc) = (recip_table(&ql), recip_table(&qc)); - let (dcl, acl) = (Huff::new(&DC_LUMA_BITS, &DC_VALS), Huff::new(&AC_LUMA_BITS, &AC_LUMA_VALS)); - let (dcc, acc) = (Huff::new(&DC_CHROMA_BITS, &DC_VALS), Huff::new(&AC_CHROMA_BITS, &AC_CHROMA_VALS)); - let (mcu_w, mcu_h) = (8 * hs, 8 * vs); let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h)); // restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1)); let bands: Vec<usize> = (0..my.div_ceil(rows_per_band)).collect(); - let encode_band = |band: &usize| -> Vec<u8> { - let mut w = Bits::new(); - let mut pred = [0i32; 3]; + // first pass: each band's quantized blocks as symbols, with their counts per table (DC + // prediction restarts per band, as the restart markers say) + let tokenize_band = |band: &usize| -> (Vec<Token>, [Freq; 4]) { let (mut blk, mut q) = ([0f32; 64], [0i16; 64]); + let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]); + let mut pred = [0i32; 3]; for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) { for mcu_x in 0..mx { for vy in 0..vs { for hx in 0..hs { planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk); fdct_quant(&blk, &rl, &mut q); - encode_block(&mut w, &q, &mut pred[0], &dcl, &acl); + tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq); } } if !gray { - for k in 1..3 { - planes[k].block(mcu_x, mcu_y, &mut blk); + for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) { + plane.block(mcu_x, mcu_y, &mut blk); fdct_quant(&blk, &rc, &mut q); - encode_block(&mut w, &q, &mut pred[k], &dcc, &acc); + tokenize_block(&q, pred, true, &mut tokens, &mut freq); } } } } - w.finish() + (tokens, freq) }; #[cfg(feature = "parallel")] - let coded: Vec<Vec<u8>> = bands.par_iter().map(encode_band).collect(); + let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect(); #[cfg(not(feature = "parallel"))] - let coded: Vec<Vec<u8>> = bands.iter().map(encode_band).collect(); + let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.iter().map(tokenize_band).collect(); + drop(planes); + // the tables: DC luma, AC luma, DC chroma, AC chroma + let tables: [([u8; 16], Vec<u8>); 4] = if optimize { + let mut total = [[0u64; 256]; 4]; + for (_, f) in &tokenized { + for (t, f) in total.iter_mut().zip(f) { + for (t, f) in t.iter_mut().zip(f) { + *t = t.saturating_add(*f); + } + } + } + total.each_ref().map(optimal_table) + } else { + [ + (DC_LUMA_BITS, DC_VALS.to_vec()), + (AC_LUMA_BITS, AC_LUMA_VALS.to_vec()), + (DC_CHROMA_BITS, DC_VALS.to_vec()), + (AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()), + ] + }; + let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals)); + // second pass: code the symbols + #[cfg(feature = "parallel")] + let coded: Vec<Vec<u8>> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); + #[cfg(not(feature = "parallel"))] + let coded: Vec<Vec<u8>> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); // headers let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::<usize>() + 4096); @@ -396,11 +552,12 @@ pub fn encode( sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]); } segment(&mut out, 0xC0, &sof); - dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_VALS); - dht(&mut out, 0x10, &AC_LUMA_BITS, &AC_LUMA_VALS); + let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables; + dht(&mut out, 0x00, dlb, dlv); + dht(&mut out, 0x10, alb, alv); if !gray { - dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_VALS); - dht(&mut out, 0x11, &AC_CHROMA_BITS, &AC_CHROMA_VALS); + dht(&mut out, 0x01, dcb, dcv); + dht(&mut out, 0x11, acb, acv); } let interval = (rows_per_band * mx) as u16; if coded.len() > 1 { @@ -495,6 +652,100 @@ mod tests { } } + /// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits, + /// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1). + fn check_table(bits: &[u8; 16], vals: &[u8]) { + assert_eq!(bits.iter().map(|&b| b as usize).sum::<usize>(), vals.len()); + let mut seen = [false; 256]; + for &v in vals { + assert!(!seen[v as usize], "symbol {v} listed twice"); + seen[v as usize] = true; + } + let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum(); + assert!(kraft < 1.0, "code space full: {kraft}"); + let h = Huff::new(bits, vals); + for &v in vals { + let (c, l) = h.code[v as usize]; + assert!((1..=16).contains(&l)); + assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code"); + } + } + + #[test] + fn optimal_tables_are_valid_and_bounded() { + // one symbol, two, a typical spread and every symbol + let mut one = [0u64; 256]; + one[7] = 5; + let mut two = [0u64; 256]; + two[0] = 1; + two[0xF0] = 1_000_000; + let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 }); + let all: Freq = std::array::from_fn(|s| s as u64 + 1); + // Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies + let mut fib = [0u64; 256]; + let (mut a, mut b) = (1u64, 1u64); + for f in fib.iter_mut().take(40) { + *f = a; + (a, b) = (b, a + b); + } + for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] { + let (bits, vals) = optimal_table(&f); + check_table(&bits, &vals); + for s in 0..256 { + assert_eq!(vals.contains(&(s as u8)), f[s] > 0 || (name == "none" && s == 0), "{name}: symbol {s}"); + } + } + // more frequent symbols never get longer codes + let (bits, vals) = optimal_table(&typical); + let h = Huff::new(&bits, &vals); + for &x in &vals { + for &y in &vals { + if typical[x as usize] > typical[y as usize] { + assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}"); + } + } + } + // the Annex K example tables pass the same checks + check_table(&AC_LUMA_BITS, &AC_LUMA_VALS); + check_table(&DC_CHROMA_BITS, &DC_VALS); + } + + /// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as + /// the Annex K example tables (the quantized coefficients are the same), at every quality and + /// subsampling, and both decoders read it. + #[test] + fn optimized_tables_shrink_files_without_changing_pixels() { + let (w, h) = (517, 333); + // gradients plus deterministic noise, like a photo's grain + let mut seed = 0x2545_f491_u32; + let src: Vec<u8> = test_rgb(w, h) + .into_iter() + .map(|v| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + v.saturating_add((seed % 23) as u8) + }) + .collect(); + for (q, sub) in [(100, ChromaSubsampling::S444), (92, ChromaSubsampling::S444), (75, ChromaSubsampling::S420), (30, ChromaSubsampling::S420)] + { + let std = encode_with(&src, w, h, 3, q, sub, &[], false); + let opt = encode(&src, w, h, 3, q, sub, &[]); + assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len()); + let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables"); + let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables"); + assert_eq!(a, b, "q{q} {sub:?}: pixels differ"); + let z = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg"); + assert_eq!(z.len(), b.len()); + } + // grayscale too + let gray: Vec<u8> = src.iter().step_by(3).copied().collect(); + let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false); + let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]); + assert!(opt.len() < std.len()); + assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap()); + } + #[test] fn grayscale_and_quality_monotone() { let (w, h) = (300, 200); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d4bfb8fa9..6f8d2f1e4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Render to DNG as an export format) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** patch (JPEG write: optimized Huffman tables, #1373) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every format Lightroom reads or writes, with our support, fidelity and tests. Camera raw formats are deep enough to have their own document: [raw-parity.md](raw-parity.md). @@ -15,7 +15,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | Format | Target reads | Target writes | LightCraft reads | LightCraft writes | Notes / tests | |---|---|---|---|---|---| -| JPEG | ✅ | ✅ | ✅ (parallel decoder, ICC, EXIF orientation) | ✅ quality, size limit, ICC, metadata | `crates/codecs/src/jpeg.rs`, export tests | +| JPEG | ✅ | ✅ | ✅ (parallel decoder, ICC, EXIF orientation) | ✅ quality, size limit, ICC, metadata; Huffman tables optimized per image (issue #1373) | `crates/codecs/src/jpeg.rs`, `crates/codecs/src/jpeg_par.rs`, export tests | | TIFF | ✅ 8/16/32-bit | ✅ 8/16-bit, LZW/ZIP | ✅ 1–64-bit integer, 16–64-bit float, bilevel, CCITT fax, WhiteIsZero | ✅ 8/16-bit, 32-bit float linear, none / LZW / ZIP | `crates/tiff`, `crates/codecs/src/tiff_codec.rs` | | PNG | ✅ | ✅ | ✅ | ✅ | | | PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` | @@ -78,3 +78,4 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte | 2026-10-10 | major | Created from the code and the installed Lightroom's document types | | 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR | | 2026-10-12 | minor | Render to DNG: "Rendered DNG" export format (linear 16-bit float DNG of the rendered photo) | +| 2026-10-13 | patch | JPEG write (#1373): Huffman tables made for each image, 12–14 % smaller files at the same pixels; statuses unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..d2a563be7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-EXP-QUALITY: optimized JPEG Huffman tables; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -387,7 +387,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | -| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | patch | LR-EXP-QUALITY: JPEG exports use Huffman tables made for each image (same pixels, 12–14 % smaller files, issue #1373); status unchanged | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..8f886738b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Smaller JPEG exports +- JPEG exports are 12–14 % smaller at the same quality, with exactly the same pixels (issue #1373): each file now + carries Huffman tables made for its own picture instead of the standard example tables. Every viewer opens them + as before. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 697010d520c341241431627074ab7cdfb900d69e Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 07:15:33 -0500 Subject: [PATCH 155/315] LR-IMP-LOCAL: Forget untouched records of deleted files when browsing a folder again (#1392) Rated, flagged or edited photos stay and show as missing. --- crates/catalog/src/local.rs | 34 ++++++++---- crates/engine/src/cmd/browse.rs | 71 +++++++++++++++++++++++-- crates/engine/src/tests_forget_local.rs | 43 +++++++++++++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 5 ++ 5 files changed, 143 insertions(+), 15 deletions(-) diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 9cfc14f35..093c22427 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -183,15 +183,7 @@ impl Catalog { let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() }; let now_s = crate::stacks::iso_seconds(now); let max_age = i64::from(days) * 86_400; - let mut referenced: HashSet<PhotoId> = HashSet::new(); - for a in self.albums.values() { - referenced.extend(a.photos.iter().copied()); - referenced.extend(a.cover); - } - for s in self.stacks.values() { - referenced.extend(s.photos.iter().copied()); - } - referenced.extend(self.photos.values().filter_map(|p| p.copy_of)); + let referenced = self.referenced(); // the fingerprints are the costly part: work them out for the candidates in parallel let candidates: Vec<&Photo> = self .photos @@ -237,6 +229,30 @@ impl Catalog { plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect(); plan } + + /// Of `ids`, the untouched Local records nothing refers to (no album, stack or virtual copy): + /// the ones [`Catalog::forget_local_plan`] could forget, whatever their folder's time. + pub fn forgettable_local(&self, ids: &[PhotoId]) -> Vec<PhotoId> { + let referenced = self.referenced(); + let candidates: Vec<&Photo> = + ids.iter().filter(|id| !referenced.contains(id)).filter_map(|id| self.photos.get(id)).filter(|p| p.local).map(|p| &**p).collect(); + let untouched = untouched_of(&candidates); + ids.iter().copied().filter(|id| untouched.contains(id)).collect() + } + + /// Photos an album (as a member or its cover), a stack or a virtual copy refers to. + fn referenced(&self) -> HashSet<PhotoId> { + let mut referenced: HashSet<PhotoId> = HashSet::new(); + for a in self.albums.values() { + referenced.extend(a.photos.iter().copied()); + referenced.extend(a.cover); + } + for s in self.stacks.values() { + referenced.extend(s.photos.iter().copied()); + } + referenced.extend(self.photos.values().filter_map(|p| p.copy_of)); + referenced + } } /// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable). diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14b..80475651c 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -6,11 +6,14 @@ //! Each browse records when the folders it listed were browsed. Untouched records of folders //! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with //! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files -//! and sidecars on disk are never touched, and browsing the folder again brings them back. +//! and sidecars on disk are never touched, and browsing the folder again brings them back. A +//! browse also forgets at once the untouched records of the folder's files that are gone from +//! the disk (deleted or moved by another app), so the folder shows what is on disk. use std::path::Path; -use lightcraft_catalog::{Op, PhotoId}; +use lightcraft_catalog::query::in_folder; +use lightcraft_catalog::{Op, PhotoId, Source}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; @@ -42,11 +45,63 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> { // looking at a folder never brings its deleted photos back (importing the files does) let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() }; let report = import_with(s, &files, &opts)?; + let forgotten = forget_gone(s, &dir_s, subfolders, &files); stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders }); s.source = LibrarySource::Folder; let shown = s.visible().len(); - Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()})) + Ok(json!({ + "path": dir_s, + "subfolders": subfolders, + "photos": shown, + "new": report.imported.len(), + "failed": report.failed.len(), + "forgotten": forgotten, + })) +} + +/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by +/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets +/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on +/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those +/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how +/// many went. +fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize { + let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect(); + let held = held(s); + let smart = s.media.smart_dir.clone(); + let gone: Vec<PhotoId> = s + .catalog + .photos() + .filter(|p| p.local && !p.deleted && !held.contains(&p.id)) + // a smart preview may be all that is left of the picture + .filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists())) + .filter(|p| match &p.source { + // a file left out of the listing is checked on disk: another spelling of its path, or + // a listing that missed it, keeps it + Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(), + Source::Demo { .. } => false, + }) + .map(|p| p.id) + .collect(); + if gone.is_empty() { + return 0; + } + let mut forgotten = 0; + for chunk in s.catalog.forgettable_local(&gone).chunks(2000) { + let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }; + match s.catalog.apply(op.clone()) { + Ok(_) => { + s.pending_log.push(op); + forgotten += chunk.len(); + } + Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"), + } + } + if forgotten > 0 { + log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}"); + } + forgotten } /// Folders whose browse time is refreshed at most this often (keeps repeated browsing from @@ -82,7 +137,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) { /// Photos the session still needs, whatever their state: shown, selected, being edited, or /// referred to by an undo / redo step. fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> { - let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect(); + let mut keep = held(s); + keep.extend(s.visible().iter().copied()); + keep +} + +/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo +/// / redo step. +fn held(s: &Session) -> std::collections::HashSet<PhotoId> { + let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new(); keep.extend(s.selection.ids.iter().copied()); keep.extend(s.selection.active); keep.extend(s.previous_active); diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests_forget_local.rs index 14a985cd4..229b262a0 100644 --- a/crates/engine/src/tests_forget_local.rs +++ b/crates/engine/src/tests_forget_local.rs @@ -216,3 +216,46 @@ fn upgraded_catalogs_forget_nothing_at_first() { assert!(local_paths(&s).is_empty(), "the untouched legacy record goes once 30 days passed"); let _ = std::fs::remove_dir_all(&root); } + +/// Issue #1392: files deleted by another app after their folder was browsed in Local stayed in +/// it, failing to load (and logging an error) on every visit. Browsing the folder again forgets +/// the untouched records of files that are gone; a rated one stays (shown as missing), so does a +/// photo added to the library, and nothing on disk is touched. +#[test] +fn browsing_again_forgets_untouched_records_of_deleted_files() { + let root = temp_dir("gone"); + let (a, lib) = (root.join("a"), root.join("lib")); + for (i, n) in ["a1.png", "a2.png", "a3.png", "a4.png"].iter().enumerate() { + png(&a.join(n), i as u8); + } + let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string())); + let mut s = open(&lib, &clock); + let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap(); + assert_eq!((r["new"].as_u64(), r["forgotten"].as_u64()), (Some(4), Some(0)), "{r}"); + let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a2, a4) = (id(&s, "a2.png"), id(&s, "a4.png")); + s.selection = Selection::single(a2); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + s.execute("photo.addToLibrary", &json!({"ids": [a4.0]})).unwrap(); + look_elsewhere(&mut s); + for n in ["a1.png", "a2.png", "a4.png"] { + std::fs::remove_file(a.join(n)).unwrap(); + } + + let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap(); + assert_eq!((r["new"].as_u64(), r["forgotten"].as_u64(), r["photos"].as_u64()), (Some(0), Some(1), Some(3)), "{r}"); + assert_eq!(local_paths(&s), vec!["a2.png", "a3.png"], "the untouched record of a1 is forgotten, the rated a2 stays"); + assert!(s.catalog.photo(a4).is_some_and(|p| !p.local), "a library photo of a deleted file stays"); + assert!(a.join("a3.png").exists()); + assert!(s.undo.is_empty(), "not an undo step"); + // browsing once more changes nothing + let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap(); + assert_eq!(r["forgotten"], 0, "{r}"); + + // journaled: the library opens in the same state + let expect = s.catalog.to_snapshot(); + drop(s); + let s = open(&lib, &clock); + assert_eq!(s.catalog.to_snapshot(), expect); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..fa2a5e830 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-IMP-LOCAL: browsing again forgets untouched records of deleted files (#1392); integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -78,7 +78,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..98ed2c2c2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Local folders +- Photos deleted (or moved) by another app no longer stay in a Local folder, failing to load and logging an error + each time you look (issue #1392): browsing the folder again leaves them out. A photo you had rated, flagged or + edited stays, marked missing, so its changes aren't lost; photos in My Photos are unchanged. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From b0b86aca841e9f8546f937fb86bde84e6d1f985c Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 07:15:51 -0500 Subject: [PATCH 156/315] LR-BEHAV-GPU: Turn GPU rendering off after the GPU is lost while running (#1337) Report it as LightCraft stopping instead of the window failing to open, and start on the CPU next time. --- apps/lightcraft/src/main.rs | 130 +++++++++++++++++++- crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/i18n.rs | 11 ++ docs/gpu-pipeline.md | 8 ++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 12 +- 23 files changed, 204 insertions(+), 25 deletions(-) diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 1c70e19ea..a6b64f8da 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -162,6 +162,46 @@ fn gpu_crash_notice(what: &str) -> String { ) } +/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next +/// launch renders on the CPU and says why. +fn gpu_lost_marker_path() -> Option<std::path::PathBuf> { + gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) +} + +/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a +/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it +/// never failed. +fn gpu_lost_reason() -> Option<String> { + lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) +} + +/// Record that LightCraft stopped after the GPU was lost, for the next launch. +fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { + let Some(m) = marker else { return }; + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(m, format!("{reason}\n")) { + log::warn!("could not write {}: {e}", m.display()); + } +} + +/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a +/// `--memory` session, which leaves the disk as it found it). +fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option<String> { + let m = marker?; + let reason = std::fs::read_to_string(m).ok()?; + if !in_memory { + let _ = std::fs::remove_file(m); + } + Some(format!( + "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ + is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ + an up-to-date graphics driver may help.", + reason.trim() + )) +} + /// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's /// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. fn display_profile_dir() -> Option<std::path::PathBuf> { @@ -808,7 +848,7 @@ fn main() -> eframe::Result { let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it - let gpu_crash = gpu_crash_check(in_memory); + let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory)); let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); lightcraft_engine::gpu::set_enabled(gpu_on); // the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't @@ -817,6 +857,9 @@ fn main() -> eframe::Result { let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); win_files.arm(window_renderer); let win_files_app = win_files.clone(); + // the window came up: a failure after this is LightCraft stopping, not a start that failed + let came_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let came_up_app = came_up.clone(); let options = eframe::NativeOptions { renderer, viewport: egui::ViewportBuilder::default() @@ -854,6 +897,7 @@ fn main() -> eframe::Result { Box::new(move |cc| { // the window's renderer is up (eframe calls this after creating it) win_files_app.started(window_renderer); + came_up_app.store(true, std::sync::atomic::Ordering::Relaxed); let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) @@ -915,6 +959,31 @@ fn main() -> eframe::Result { }), ) })); + if came_up.load(std::sync::atomic::Ordering::Relaxed) { + // the window was up: a panic that escaped it now is LightCraft stopping, typically the + // window's renderer after the graphics driver reset the GPU (issue #1337: egui-wgpu panics + // on the lost device). Not a failed start: no other renderer is tried, and the message + // says what happened. + let stopped = match started { + Ok(r) => r, + Err(_) => Err(eframe::Error::AppCreation("its window stopped drawing (the renderer panicked)".into())), + }; + if let Err(e) = &stopped { + let lost = gpu_lost_reason(); + if let Some(reason) = &lost + && !in_memory + { + record_gpu_lost(gpu_lost_marker_path().as_deref(), reason); + } + // (not "app creation error: …": the app had been created) + let what = match e { + eframe::Error::AppCreation(m) => m.to_string(), + e => e.to_string(), + }; + stopped_while_running(&stopped_text(&what, lost.as_deref()), log_file.as_deref()); + } + return stopped; + } // a panic that still escaped the renderer's start (the probe and error handler should // prevent it): an error like any other failed start let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into()))); @@ -929,6 +998,34 @@ fn main() -> eframe::Result { started } +/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU +/// was lost (`lost`): GPU rendering is off from the next start. +fn stopped_text(error: &str, lost: Option<&str>) -> String { + match lost { + Some(reason) => format!( + "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ + Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" + ), + None => error.to_string(), + } +} + +/// LightCraft stopped while its window was up: say so in the log and in a native message box +/// naming the log file (as [`startup_failed`] does for a start that failed). +fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft stopped: {text}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + /// The window could not start (e.g. no usable graphics device): say so in the log and in a native /// message box naming the log file, instead of exiting without a trace — the release builds have /// no console on Windows, and an app started from Finder shows no standard error (issue #260). @@ -1131,6 +1228,37 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an + /// export, and the dialog said the window "could not open". The stop is recorded; the next + /// launch says why once and renders on the CPU (a `--memory` session leaves the record). + #[test] + fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { + let d = dir("gpu-lost"); + let m = d.join("gpu-lost.marker"); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); + assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); + record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); + let notice = gpu_lost_check_at(Some(&m), true).unwrap(); + assert!( + notice.contains("(device lost (Unknown): Device is lost)") + && notice.contains("GPU rendering is now off") + && notice.contains("Use the GPU for rendering"), + "{notice}" + ); + assert!(m.exists(), "a --memory session leaves it"); + assert!(gpu_lost_check_at(Some(&m), false).is_some()); + assert!(!m.exists()); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); + // the message box: LightCraft stopped (not "could not open its window"), and why + let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); + assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); + assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); + let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); + assert_eq!(title, "LightCraft stopped"); + assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); + let _ = std::fs::remove_dir_all(&d); + } + /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. #[test] fn gpu_crash_marker_is_reported_once() { diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..61f8ecc68 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..b0cc60940 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,6 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "LightCraft stopped": "LightCraft wurde beendet" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..dfc316eb7 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se detuvo inesperadamente: {e}\n\nLos detalles están en el archivo de registro: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..e91ee5ef7 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,6 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "LightCraft stopped": "LightCraft se detuvo" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..1598e8f45 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft s’est arrêté de façon inattendue : {e}\n\nConsultez le fichier journal pour plus de détails : {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..ab6f9875a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,6 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "LightCraft stopped": "LightCraft s’est arrêté" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..f5cab4392 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraftが予期せず終了しました: {e}\n\n詳しくはログファイルをご覧ください: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..3c1aca00e 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,6 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "LightCraft stopped": "LightCraftが終了しました" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..36173ddb5 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "O LightCraft parou inesperadamente: {e}\n\nO arquivo de log tem os detalhes: {path}", + "LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..b747e3e8a 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,6 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "LightCraft stopped": "O LightCraft parou" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..473f32567 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft неожиданно остановился: {e}\n\nПодробности в файле журнала: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..df993ca3b 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,6 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "LightCraft stopped": "LightCraft остановился" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..d8c5b1ba1 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft несподівано зупинився: {e}\n\nПодробиці у файлі журналу: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..cfb348b73 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,6 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "LightCraft stopped": "LightCraft зупинився" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..cdb1a2a00 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n详细信息见日志文件:{path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..505b3d028 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,6 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "LightCraft stopped": "LightCraft 已停止" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..163c8d5ce 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..7bbb2c675 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,6 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "LightCraft stopped": "LightCraft 已停止" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..59a8a1cb2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -212,6 +212,17 @@ pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, S (tr("LightCraft could not start").to_string(), text) } +/// The title and text of the message box shown when LightCraft stopped while its window was up +/// (issue #1337: the window's renderer after the graphics driver reset the GPU), in the UI +/// language: what happened and, when there is one, the log file to look in. +pub fn stopped_message(error: &str, log_file: Option<&str>) -> (String, String) { + let text = match log_file { + Some(path) => tr_format!("LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", e = error, path = path), + None => tr_format!("LightCraft stopped unexpectedly: {e}", e = error), + }; + (tr("LightCraft stopped").to_string(), text) +} + pub fn builtin_label(source: &str, builtin: bool) -> &str { if builtin { tr(source) } else { source } } diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 1fc80c95b..b74af85a2 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -105,6 +105,14 @@ rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a spec from every program started with it. Help ▸ System Info and `app.gpu` show the adapter and backend in use (e.g. `Intel(R) UHD Graphics 630 (Dx12)`). +**LightCraft stops during an export or a render; the log says "device lost" (Windows, issue #1337).** +When the graphics driver resets the GPU (the Windows watchdog, a driver hang: DXGI `0x887A0005`, "GPU +device instance has been suspended"), renders fall back to the CPU, but the window's renderer (egui-wgpu) +panics on the lost device and LightCraft stops. It says so ("LightCraft stopped unexpectedly", not "could +not open its window") and records it in `gpu-lost.marker` in the settings folder: the next start turns +*Use the GPU for rendering* off and says why once. Turn it back on in Settings ▸ Performance after a +driver update. + **Window never appears, "not responding" (Windows, NVIDIA; issue #374).** With *Shader Cache Size* set to **Disabled** in NVIDIA Control Panel (Manage 3D settings → Global Settings), NVIDIA's driver walks the whole system drive while the window's GPU device is created (`D3D12CreateDevice` → diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..afe3ce836 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-BEHAV-GPU: a GPU lost while running turns GPU rendering off for the next start (#1337); integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -459,7 +459,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | -| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | patch | LR-BEHAV-GPU: a GPU lost while LightCraft runs (driver reset) is reported as a stop, not a failed start, and the next start renders on the CPU and says why (issue #1337); status unchanged | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..b0ff44a22 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,10 @@ ## 2026-10-14 +### Reliability +- A photo whose Defringe amount was stored far past the sliders' 20 (in a damaged catalog or sidecar) no longer + crashes LightCraft each time it renders: the amount acts as 20. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. @@ -18,10 +22,6 @@ models in a `LightCraftData` folder beside it instead of the per-user settings folder (issue #1200), as PhotoCraft does. If that folder can't be written, the usual folder is used and the log says why. -### Reliability -- A photo whose Defringe amount was stored far past the sliders' 20 (in a damaged catalog or sidecar) no longer - crashes LightCraft each time it renders: the amount acts as 20. - ### Reliability - Importing a preset zip that unpacks to far more than any preset (a "zip bomb") is refused with a message naming the file, instead of asking for gigabytes of memory, which could end LightCraft. @@ -108,6 +108,10 @@ - An export enlarged far past the photo's size (20000 typed for 2000 with Don't Enlarge off) no longer ends LightCraft: an enlargement stops at 512 megapixels, as in Lightroom, and one that needs more memory than the computer has is refused with the size it would take, before it starts. +- When the graphics driver resets the GPU while LightCraft is exporting or rendering (issue #1337, seen on Windows + with NVIDIA), LightCraft still stops, because its window can't keep drawing on the lost device, but it now says + "LightCraft stopped unexpectedly" and why, instead of "could not open its window", and the next start renders + on the CPU and explains how to turn the GPU back on. ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you From 02d5d6bc0342954ba5bfa02c5c20e28a52ed1224 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 07:15:34 -0500 Subject: [PATCH 157/315] LR-MERGE-PANO: Keep panoramas at full resolution when memory allows (#1455) Drop the 40 MP default and free each photo's pixels once it's projected. --- crates/engine/src/cmd/merge.rs | 2 +- crates/engine/src/merge.rs | 66 +++++++++++++++++++-- crates/engine/src/tests_merge.rs | 27 +++++++++ crates/merge/src/pano/mod.rs | 27 +++++++-- crates/merge/tests/pano.rs | 23 +++++++ crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/src/merge.rs | 46 +++++++++++++- docs/merge.md | 13 +++- docs/parity-checklist.md | 5 +- docs/whats-new.md | 7 +++ 18 files changed, 225 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..eb649c401 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> { "Panorama Merge", [], None, - "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}", + "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}", can_merge, |s, p| run(s, "merge.panorama", p) ), diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..ac525f631 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use lightcraft_catalog::{PhotoId, Source}; use lightcraft_develop::DevelopSettings; +use lightcraft_merge::pano::MAX_MEGAPIXELS; use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection}; use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo}; use lightcraft_raster::{Rgb32f, Rgba8}; @@ -149,8 +150,9 @@ impl MergeJob { MergeKind::Panorama { pano } => { let frames = self.load(true, progress, (0.0, 0.25))?; let p = sub(0.25, 0.9); - let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?; - let info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&frames), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(frames, &fit, &p).map_err(err)?; + let info = pano_info(&r, &self.sources, pano, &fit); ( r.image, r.color, @@ -192,8 +194,9 @@ impl MergeJob { merged.push(f); } let p = sub(0.6, 0.9); - let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?; - let mut info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&merged), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(merged, &fit, &p).map_err(err)?; + let mut info = pano_info(&r, &self.sources, pano, &fit); info["bracket"] = json!(b); ( r.image, @@ -230,7 +233,20 @@ impl MergeJob { } } -fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value { +/// `asked` is what the command asked for, `fit` what ran (held to memory): when the panorama came +/// out smaller than the photos' own resolution, `limit` says why (`memory`, `size`: Lightroom's +/// 512 MP / 65,000 px, or `asked`). +fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)], asked: &PanoOptions, fit: &PanoOptions) -> Value { + let megapixels = (r.image.width * r.image.height) as f64 / 1e6; + let native = r.native_megapixels; + let why = if fit.max_megapixels < asked.max_megapixels && fit.max_megapixels < native { + "memory" + } else if asked.max_megapixels < MAX_MEGAPIXELS && asked.max_megapixels < native { + "asked" + } else { + "size" + }; + let limit = (megapixels < native * 0.98).then_some(why); json!({ "projection": r.projection, "used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::<Vec<_>>(), @@ -238,9 +254,46 @@ fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> "rmsPx": r.rms, "gains": r.gains, "crop": r.crop, + "megapixels": megapixels, + "nativeMegapixels": native, + "limit": limit, }) } +/// Peak memory of a panorama per pixel of its output (bytes), measured as the peak resident size +/// of synthetic stitches of 20–81 MP from 38–154 MP of photos, rounded up from the smaller ones +/// (the larger need less): while the photos are projected they are still held, beside the tiles +/// ([`PANO_TILE_BYTES`] on top of the photos' own 12 bytes a pixel); the blend that follows, the +/// photos freed, peaks at [`PANO_BLEND_BYTES`]. Writing the DNG takes less than either. +const PANO_TILE_BYTES: f64 = 36.0; +const PANO_BLEND_BYTES: f64 = 68.0; + +/// What a merge may use of the computer's memory: three quarters of it (the rest is the system's +/// and the other apps'), less the app's own memory budget (its caches). +fn merge_memory(ram: Option<usize>) -> f64 { + // a computer whose memory we can't read (Windows) is taken to have 8 GB + let ram = ram.unwrap_or(8 << 30) as f64; + (ram * 0.75 - crate::memory::budget() as f64).max(0.0) +} + +/// `pano` with its size held to what fits in memory beside its photos (`frame_px` pixels, 12 +/// bytes each, loaded already): a panorama of five 51 MP photos may have up to 166 MP on a 16 GB +/// computer, 355 MP on 32 GB. Never below 40 megapixels, the size every panorama had before it +/// followed the photos' resolution, and never above what was asked. +pub(crate) fn fit_in_memory(pano: &PanoOptions, frame_px: f64, ram: Option<usize>) -> PanoOptions { + let room = merge_memory(ram); + let fits = ((room - frame_px * 12.0) / PANO_TILE_BYTES).min(room / PANO_BLEND_BYTES) / 1e6; + PanoOptions { max_megapixels: pano.max_megapixels.min(fits.max(MIN_FITTED_MEGAPIXELS)), ..pano.clone() } +} + +/// The pixels of `frames`. +fn pixels(frames: &[Frame]) -> f64 { + frames.iter().map(|f| (f.width() * f.height()) as f64).sum() +} + +/// The smallest size [`fit_in_memory`] holds a panorama to (megapixels). +pub(crate) const MIN_FITTED_MEGAPIXELS: f64 = 40.0; + /// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2). pub fn detect_bracket(frames: &[Frame]) -> Option<usize> { let ev: Vec<f64> = frames.iter().map(|f| f.exposure).collect::<Option<_>>()?; @@ -331,7 +384,8 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0), auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false), fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false), - max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0), + // the photos' own resolution unless asked (held to what fits in memory when it runs) + max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(MAX_MEGAPIXELS).min(MAX_MEGAPIXELS), }; let kind = match id { "merge.hdr" => MergeKind::Hdr { hdr }, diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index cb42dbc2a..8cb85120f 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -164,7 +164,34 @@ fn panorama_command_with_auto_crop() { assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height); let crop = s.develop_of(id).unwrap().crop.geometry.rect; assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}"); + // the photos' own resolution, so nothing held it back (issue #1455) + let (mp, native) = (r["megapixels"].as_f64().unwrap(), r["nativeMegapixels"].as_f64().unwrap()); + assert!((mp / native - 1.0).abs() < 0.02 && r["limit"].is_null(), "{r}"); + // a size asked for says so + let small = + s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoSettings": false, "maxMegapixels": native / 2.0})).unwrap(); + assert_eq!(small["limit"], "asked", "{small}"); + assert!(small["megapixels"].as_f64().unwrap() <= native / 2.0 * 1.02, "{small}"); // too few photos assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #1455: a panorama keeps its photos' resolution, held to what fits in memory beside them +/// (never below the 40 MP every panorama used to have), and never above a size asked for. +#[test] +fn panorama_size_is_held_to_memory() { + use crate::merge::{MIN_FITTED_MEGAPIXELS, fit_in_memory}; + let full = lightcraft_merge::PanoOptions::default(); + let gb = |g: usize| Some(g << 30); + let photos = 5.0 * 51e6; // five 51 MP photos + let mp = |o: lightcraft_merge::PanoOptions| o.max_megapixels; + assert_eq!(mp(fit_in_memory(&full, photos, gb(512))), lightcraft_merge::pano::MAX_MEGAPIXELS, "plenty of memory: the full size"); + let m16 = mp(fit_in_memory(&full, photos, gb(16))); + assert!(m16 > MIN_FITTED_MEGAPIXELS && m16 < 512.0, "16 GB: {m16} MP"); + assert!(mp(fit_in_memory(&full, photos, gb(32))) > m16, "more memory, more pixels"); + assert_eq!(mp(fit_in_memory(&full, photos, gb(4))), MIN_FITTED_MEGAPIXELS, "never below 40 MP"); + let asked = lightcraft_merge::PanoOptions { max_megapixels: 20.0, ..full.clone() }; + assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits"); + assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB"); +} diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 309505614..73d19f515 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -39,16 +39,23 @@ pub struct PanoOptions { pub boundary_warp: f64, pub auto_crop: bool, pub fill_edges: bool, - /// Output size limit (megapixels); the panorama is scaled down to fit. + /// Output size limit (megapixels); the panorama is scaled down to fit. At most + /// [`MAX_MEGAPIXELS`] (the default: the photos' own resolution). pub max_megapixels: f64, } impl Default for PanoOptions { fn default() -> Self { - PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: 40.0 } + PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: MAX_MEGAPIXELS } } } +/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down. +pub const MAX_MEGAPIXELS: f64 = 512.0; + +/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down. +pub const MAX_EDGE: f64 = 65_000.0; + /// The stitched panorama. #[derive(Clone, Debug)] pub struct PanoResult { @@ -71,6 +78,8 @@ pub struct PanoResult { pub rms: f64, /// Horizontal and vertical field of view (degrees). pub fov: (f64, f64), + /// The panorama's size at the photos' own resolution (megapixels), before any limit. + pub native_megapixels: f64, /// Output focal length and the surface coordinates of the canvas's top-left corner: canvas /// pixel (x, y) (before boundary warp) shows surface point (x + 0.5 + origin.0, y + 0.5 + origin.1). pub focal: f64, @@ -210,7 +219,7 @@ pub fn register(frames: &[Frame], progress: &Progress) -> Result<(Vec<Option<Cam } /// Stitch `frames` (oriented, linear) into a panorama. -pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> { +pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> { let n = frames.len(); if n < 2 { return Err(MergeError::TooFew(n, 2)); @@ -231,9 +240,9 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re if projection == Projection::Perspective && (bx1 - bx0) * (by1 - by0) > 12.0 * area { return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); } - // output scale: native resolution, limited to max_megapixels - let max_px = (opts.max_megapixels.max(0.1) * 1e6).min(400e6); - let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(1.0); + // output scale: native resolution, limited to max_megapixels and the longest edge + let max_px = if opts.max_megapixels.is_nan() { MAX_MEGAPIXELS } else { opts.max_megapixels.clamp(0.1, MAX_MEGAPIXELS) } * 1e6; + let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(MAX_EDGE / (bx1 - bx0).max(by1 - by0)).min(1.0); let f_out = fmed * s; let (ox, oy) = (bx0 * s, by0 * s); let (cw, ch) = (((bx1 - bx0) * s).ceil() as usize + 1, ((by1 - by0) * s).ceil() as usize + 1); @@ -298,6 +307,11 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re .collect(); check(progress, 0.6, "Exposure compensation")?; compensate_gains(&tiles, &mut gains, &frames); + // the tiles hold every pixel the blend needs: the frames' own go now, before the blend + // takes its memory (12 bytes a pixel of every photo: 3 GB for five 51 MP frames) + for f in &mut frames { + f.image = Rgb32f::new(0, 0); + } // seams: label = max feather weight let mut best = vec![0f32; pw * ph]; @@ -373,6 +387,7 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re rms_before, rms, fov, + native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6, focal: f_out, origin: (ox, oy), color: r.color.clone(), diff --git a/crates/merge/tests/pano.rs b/crates/merge/tests/pano.rs index 402037ca0..fdb736524 100644 --- a/crates/merge/tests/pano.rs +++ b/crates/merge/tests/pano.rs @@ -245,3 +245,26 @@ fn unrelated_photos_do_not_stitch() { let frames: Vec<Frame> = a.iter().take(2).map(|s| Frame::from_linear_rec2020(s.render(400, 300), None)).collect(); assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err()); } + +/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their +/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down, +/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail. +#[test] +fn panoramas_keep_the_photos_resolution_unless_asked() { + assert_eq!(PanoOptions::default().max_megapixels, lightcraft_merge::pano::MAX_MEGAPIXELS); + let shots = [ + Shot { yaw: -22.0, pitch: 0.5, roll: 0.3, gain: 1.0 }, + Shot { yaw: 0.0, pitch: -0.4, roll: 0.0, gain: 1.0 }, + Shot { yaw: 21.0, pitch: 0.8, roll: -0.4, gain: 1.0 }, + ]; + let opts = |max_megapixels: f64| PanoOptions { projection: Projection::Perspective, max_megapixels, ..Default::default() }; + let mp = |r: &lightcraft_merge::pano::PanoResult| (r.image.width * r.image.height) as f64 / 1e6; + let full = run(&shots, 800.0, &PanoOptions { projection: Projection::Perspective, ..Default::default() }).0; + assert!((mp(&full) / full.native_megapixels - 1.0).abs() < 0.02, "{} MP for {} MP", mp(&full), full.native_megapixels); + let quarter = run(&shots, 800.0, &opts(full.native_megapixels / 4.0)).0; + assert!((quarter.native_megapixels / full.native_megapixels - 1.0).abs() < 1e-6); + let k = mp(&quarter) / (full.native_megapixels / 4.0); + assert!(k > 0.9 && k <= 1.02, "a quarter of the size asked: {} MP", mp(&quarter)); + let nan = run(&shots, 800.0, &opts(f64::NAN)).0; + assert_eq!((nan.image.width, nan.image.height), (full.image.width, full.image.height)); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..4424af1b2 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..403762642 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..2fd99dc91 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..4d3504f22 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..2e6f73223 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..c8b848f12 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..d9e04c0bb 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..65ba929b7 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..e7efa5bb2 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})" } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..ee0262ef0 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -269,8 +269,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { }); match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { Ok(v) => { + let message = added_message(what, &v); app.merge.last_result = Some(v); - app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what)); + app.toast(ctx, message); } Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), Err(e) => { @@ -292,6 +293,27 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } } +/// The message for a finished merge (`what`: "Panorama", …; `info`: its result): a panorama +/// smaller than its photos' resolution says why (issue #1455). +fn added_message(what: &str, info: &Value) -> String { + let size = |k: &str| info[k].as_f64().map(|mp| format!("{mp:.0} MP")); + match (info["limit"].as_str(), size("megapixels"), size("nativeMegapixels")) { + (Some("memory"), Some(mp), Some(full)) => crate::i18n::tr_format!( + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + what = what, + mp = mp, + full = full + ), + (Some("size"), Some(mp), Some(full)) => crate::i18n::tr_format!( + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + what = what, + mp = mp, + full = full + ), + _ => crate::i18n::tr_format!("{what} merge added", what = what), + } +} + fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { app.merge.failed_options.as_ref() == Some(opts) } @@ -423,3 +445,25 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) - None => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so. + #[test] + fn a_smaller_panorama_says_why() { + let added = |info: Value| added_message("Panorama", &info); + assert_eq!( + added(json!({"limit": "memory", "megapixels": 166.2, "nativeMegapixels": 194.0})), + "Panorama merge added at 166 MP: its full size, 194 MP, needs more memory than this computer has" + ); + assert_eq!( + added(json!({"limit": "size", "megapixels": 512.0, "nativeMegapixels": 700.4})), + "Panorama merge added at 512 MP, the largest a panorama can be (its full size is 700 MP)" + ); + assert_eq!(added(json!({"limit": null, "megapixels": 90.0, "nativeMegapixels": 90.0})), "Panorama merge added"); + assert_eq!(added(json!({"limit": "asked", "megapixels": 20.0, "nativeMegapixels": 90.0})), "Panorama merge added"); + assert_eq!(added_message("HDR", &json!({"ev": [0.0]})), "HDR merge added"); + } +} diff --git a/docs/merge.md b/docs/merge.md index 7c298cb10..03c7bae57 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -13,7 +13,7 @@ All algorithms are our own implementations from published papers (cited in the m `<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` - - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` + - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels (default: the photos' own resolution), preview, previewPath?}` - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` - **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). @@ -67,5 +67,14 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) not patch synthesis. - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its caption also lists them. -- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- A panorama comes out at its photos' own resolution, at most 512 MP and 65,000 px on its long edge + (Lightroom's limits), unless `maxMegapixels` asks for less (issue #1455; it was 40 MP for every panorama). + It is held to what fits in memory: three quarters of the computer's RAM less the app's memory budget + (a computer whose memory can't be read, Windows, counts as 8 GB), never below 40 MP. Measured peak + resident sizes of synthetic stitches (20–81 MP out of 38–154 MP of photos): while the photos are projected + they are still held beside the tiles, 12 bytes per photo pixel plus 27–34 per output pixel; the blend that + follows, with the photos' pixels freed, peaks at 55–67 bytes per output pixel; the DNG takes less. The + limit uses 36 and 68. Five 51 MP photos can make up to 166 MP with 16 GB, 355 MP with 32 GB. The result's + `megapixels`, `nativeMegapixels` and `limit` (`memory`, `size` or `asked`) say when it came out smaller, + and the app's message says why. - The browser build has no file access for merges yet. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..e21508b7f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MERGE-PANO: panoramas at full resolution, held to memory (#1455); integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -348,7 +348,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG at the photos' own resolution up to 512 MP / 65,000 px, held to what fits in memory (issue #1455; was a fixed 40 MP); no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..4f321c1c7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-14 +### Full-resolution panoramas +- Panorama and HDR Panorama merges keep the resolution of their photos instead of stopping at 40 megapixels + (issue #1455), up to 512 MP and 65,000 pixels on the long edge, as in Lightroom. When the full size needs more + memory than the computer has, the panorama is made as large as fits (five 51 MP photos: up to 166 MP with + 16 GB, 355 MP with 32 GB) and the message says so. The merge also frees each photo's pixels once they are + projected, so it needs less memory than before at any size (an 81 MP stitch: 4.4 GB instead of 6.2 GB). + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 1acbf8866186aca1fb4af641791dbaf8d9621ae1 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 10:53:27 -0500 Subject: [PATCH 158/315] LR-META-INFO: Show the lens for Nikon DSLR raws They don't write a lens name, so read the range from the maker note's Lens tag. --- crates/engine/src/files.rs | 17 +++++++++++- crates/meta/src/exif.rs | 57 ++++++++++++++++++++++++++++++++++++++ crates/meta/src/lib.rs | 23 +++++++++++++++ docs/parity-checklist.md | 5 ++-- docs/whats-new.md | 6 ++++ 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb85..88d82f7f5 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -18,7 +18,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) { let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); let meta = Meta { camera, - lens: m.lens_model.clone().unwrap_or_default(), + // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) + lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), focal_mm: m.focal_length.map(|f| f as f32), aperture: m.f_number.map(|f| f as f32), shutter, @@ -1052,6 +1053,20 @@ mod tests { /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides /// them; an exported `travel|Italy|Rome` comes back as itself. + #[test] + fn a_lens_without_a_name_is_shown_by_its_range() { + // a Nikon DSLR raw: no LensModel, the range from the maker note + let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; + assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); + let named = lightcraft_meta::Metadata { + lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), + lens_spec: Some([24.0, 70.0, 4.0, 4.0]), + ..Default::default() + }; + assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); + assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); + } + #[test] fn imported_keywords_keep_their_hierarchy() { let m = lightcraft_meta::Metadata { diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..4255c4d50 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -54,6 +54,10 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata { if let Some(s) = spec.filter(|s| s.len() == 4 && s[0] > 0.0) { m.lens_spec = Some([s[0], s[1], s[2], s[3]]); } + // Nikon DSLRs write neither LensModel nor LensSpecification, only the same four values in their maker note + if m.lens_spec.is_none() { + m.lens_spec = nikon_lens_spec(exif, m.make.as_deref(), tiff.order); + } m.orientation = ifd0.and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif); m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| r.clamp(-1, 5) as i8); @@ -138,11 +142,64 @@ fn read_gps(g: &Ifd) -> Option<Gps> { Some(Gps { latitude: lat, longitude: lon, altitude }) } +/// Nikon maker note tag `0x0084` (Lens in the ExifTool Nikon tag names): shortest and longest focal length, then the +/// widest aperture at each, as rationals, like Exif LensSpecification. Zero for a lens that doesn't report itself. +const NIKON_LENS: u16 = 0x0084; + +/// The lens a Nikon camera records in its maker note, when it's plausible. +fn nikon_lens_spec(exif: Option<&Ifd>, make: Option<&str>, order: lightcraft_tiff::ByteOrder) -> Option<[f64; 4]> { + let make = make?; + if !make.trim().to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let note = exif?.bytes(t::MAKER_NOTE)?; + let mn = lightcraft_tiff::makernote::parse_makernote(note, 0, note.len() as u64, order, make)?; + let s = mn.ifd.f64s(NIKON_LENS)?; + let [short, long, wide, narrow] = s.as_slice() else { return None }; + let focal = |f: f64| f.is_finite() && f > 0.0 && f < 10_000.0; + let aperture = |a: f64| a.is_finite() && a > 0.0 && a < 100.0; + (focal(*short) && focal(*long) && long >= short && aperture(*wide) && aperture(*narrow)).then_some([*short, *long, *wide, *narrow]) +} + #[cfg(test)] pub(crate) mod tests { use super::*; use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + /// An Exif block from a Nikon body whose maker note records the lens (tag 0x0084) and no LensModel or + /// LensSpecification, as the D700, D750, D80 and D5300 write it. + fn nikon_exif(make: &str, lens: Option<[(u32, u32); 4]>, spec: Option<[(u32, u32); 4]>) -> Vec<u8> { + let mut note = IfdBuilder::new(); + note.set(0x0001, Value::Undefined(b"0210".to_vec())); + if let Some(l) = lens { + note.set(NIKON_LENS, Value::Rational(l.to_vec())); + } + let mut blob = b"Nikon\0\x02\x10\0\0".to_vec(); + blob.extend(TiffWriter::new(ByteOrder::Little, false).write(&[note]).unwrap()); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(blob)); + if let Some(sp) = spec { + exif.set(t::LENS_SPECIFICATION, Value::Rational(sp.to_vec())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn nikon_maker_note_lens_fills_the_lens_spec() { + let zoom = [(18, 1), (135, 1), (35, 10), (56, 10)]; + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), None)).lens_spec, Some([18.0, 135.0, 3.5, 5.6])); + // Exif's own LensSpecification wins, another make's note is not read, and a lens that reports nothing is left out + let prime = [(50, 1), (50, 1), (18, 10), (18, 10)]; + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), Some(prime))).lens_spec, Some([50.0, 50.0, 1.8, 1.8])); + assert_eq!(read_exif(&nikon_exif("Canon", Some(zoom), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(0, 1), (0, 1), (0, 1), (0, 1)]), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(200, 1), (80, 1), (28, 10), (28, 10)]), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", None, None)).lens_spec, None); + } + pub(crate) fn sample_exif(order: ByteOrder) -> Vec<u8> { let mut exif = IfdBuilder::new(); exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)])); diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31a35cc65..895f68caa 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -283,6 +283,18 @@ pub fn extract(bytes: &[u8]) -> Metadata { m } +/// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the +/// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`. +pub fn lens_spec_name(spec: [f64; 4]) -> Option<String> { + let [short, long, wide, narrow] = spec; + if ![short, long, wide, narrow].iter().all(|v| v.is_finite() && *v > 0.0) || long < short { + return None; + } + let focal = if (long - short).abs() < 0.05 { format!("{short:.1}") } else { format!("{short:.1}-{long:.1}") }; + let aperture = if (narrow - wide).abs() < 0.05 { format!("{wide:.1}") } else { format!("{wide:.1}-{narrow:.1}") }; + Some(format!("{focal} mm f/{aperture}")) +} + /// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`. pub(crate) fn parse_number(s: &str) -> Option<f64> { let s = s.trim(); @@ -297,6 +309,17 @@ pub(crate) fn parse_number(s: &str) -> Option<f64> { mod tests { use super::*; + #[test] + fn lens_names_from_a_spec_read_like_lightroom() { + assert_eq!(lens_spec_name([50.0, 50.0, 1.8, 1.8]).as_deref(), Some("50.0 mm f/1.8")); + assert_eq!(lens_spec_name([18.0, 135.0, 3.5, 5.6]).as_deref(), Some("18.0-135.0 mm f/3.5-5.6")); + assert_eq!(lens_spec_name([24.0, 70.0, 2.8, 2.8]).as_deref(), Some("24.0-70.0 mm f/2.8")); + assert_eq!(lens_spec_name([70.0, 210.0, 4.0, 5.6]).as_deref(), Some("70.0-210.0 mm f/4.0-5.6")); + assert_eq!(lens_spec_name([0.0, 0.0, 0.0, 0.0]), None); + assert_eq!(lens_spec_name([200.0, 80.0, 2.8, 2.8]), None); + assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None); + } + #[test] fn numbers() { assert_eq!(parse_number("28/10"), Some(2.8)); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..49b63b17a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-META-INFO: Nikon DSLR lens names from the maker note; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -163,7 +163,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0faaa8b3a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Lens names for Nikon DSLR raws +- Photos from Nikon DSLRs like the D700, D750, D80 or D5300 now show which lens they were taken with, for example + "24.0-70.0 mm f/2.8". Those cameras don't write a lens name, so the lens field was empty and you couldn't filter or + search by lens. LightCraft now reads the lens range Nikon keeps in its own maker note, the way Lightroom shows a + lens it doesn't have a name for. Newly imported photos show it. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 441f46683a242e90fdc0656fb36787bce3c2f0ce Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 11:49:31 -0500 Subject: [PATCH 159/315] LR-IMP-DEVICE: Show the import review when a card is connected Adds an option under Settings > Import > Cameras and Cards that opens Import from Device when a camera or memory card shows up. It's off by default, and a card that was already in at startup doesn't ask. --- crates/engine/src/cmd/prefs.rs | 5 +++- crates/engine/src/devices.rs | 38 ++++++++++++++++++++++++ crates/engine/src/import.rs | 2 ++ crates/engine/src/tests_prefs.rs | 3 +- crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/headless.rs | 42 +++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 22 ++++++++++++++ crates/ui-egui/src/lib.rs | 10 +++++++ crates/ui-egui/src/panels/settings.rs | 5 ++++ crates/ui-egui/src/state.rs | 5 ++++ docs/parity-checklist.md | 5 ++-- docs/whats-new.md | 7 +++++ 20 files changed, 149 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 78de9d850..1f1301a51 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -43,6 +43,9 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = i.get("perCamera").and_then(Value::as_bool) { d.per_camera = v; } + if let Some(v) = i.get("showOnCard").and_then(Value::as_bool) { + d.show_on_card = v; + } if let Some(v) = i.get("copyright").and_then(Value::as_str) { d.copyright = v.trim().to_string(); } @@ -107,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> { "Library Preferences", [], None, - "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}", + "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one), showOnCard?: bool (open the import review when a camera or card is connected)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}", always, prefs )] diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 399d03fde..366cb5273 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -104,6 +104,18 @@ impl DeviceWatch { s.list.clone() } + /// Like [`DeviceWatch::get`], but `None` until the first scan has landed, so an empty list + /// means no cards rather than not looked yet. + pub fn get_scanned(self: &Arc<Self>) -> Option<Vec<Device>> { + let _ = self.get(); + let s = self.state(); + #[cfg(not(target_arch = "wasm32"))] + let scanned = s.scanned.is_some(); + #[cfg(target_arch = "wasm32")] + let scanned = false; + scanned.then(|| s.list.clone()) + } + /// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls. pub fn scan_now(&self) -> Vec<Device> { let list = (self.scanner)(); @@ -148,6 +160,12 @@ pub fn devices() -> Vec<Device> { global().get() } +/// [`devices`], but `None` until the first scan lands. The import prompt uses it so a card that was +/// already in when LightCraft started isn't taken for a new one. +pub fn devices_scanned() -> Option<Vec<Device>> { + global().get_scanned() +} + /// Scan the mounted volumes now (blocking), for explicit requests. pub fn devices_now() -> Vec<Device> { global().scan_now() @@ -285,4 +303,24 @@ mod tests { assert!(watch.scan_now().is_empty()); assert_eq!(changes.load(Ordering::SeqCst), 2); } + + #[test] + fn get_scanned_waits_for_the_first_scan() { + let gate = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + let watch = DeviceWatch::new( + move || { + while !g.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(5)); + } + vec![card("NIKON Z 8")] + }, + Duration::from_secs(60), + ); + assert_eq!(watch.get_scanned(), None, "nothing scanned yet"); + assert_eq!(watch.get_scanned(), None); + gate.store(true, Ordering::SeqCst); + assert!(until(|| watch.get_scanned().is_some())); + assert_eq!(watch.get_scanned(), Some(vec![card("NIKON Z 8")])); + } } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e..ffe979e01 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -310,6 +310,8 @@ pub struct ImportDefaults { pub auto_folder: Option<String>, pub auto_copy: bool, pub auto_album: Option<String>, + /// Open the import review when a camera or memory card is connected. + pub show_on_card: bool, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3534064f2..dec2ddcc0 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -108,7 +108,7 @@ fn preferences_persist_with_the_library() { s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); s.execute( "library.preferences", - &json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}), + &json!({"import": {"rawPreset": "user.rawlook", "perCamera": true, "showOnCard": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}), ) .unwrap(); s.close_library().unwrap(); @@ -118,6 +118,7 @@ fn preferences_persist_with_the_library() { assert!(s.xmp.auto_write); assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook")); assert!(s.import_defaults.per_camera); + assert!(s.import_defaults.show_on_card); assert_eq!(s.import_defaults.cameras.len(), 1); assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook")); assert_eq!(s.cache_mb, 512); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..c6cb6fdc5 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1402,6 +1402,7 @@ "Show overlay (O)": "Überlagerung anzeigen (O)", "Show ratings, flags and edit badges": "Bewertungen, Markierungen und Bearbeitungssymbole anzeigen", "Show the Navigator while zoomed in": "Navigator beim Hineinzoomen anzeigen", + "Show the import review when a camera or card is connected": "Importfenster öffnen, wenn eine Kamera oder Karte angeschlossen wird", "Show the template tags; click one to insert it": "Vorlagenplatzhalter anzeigen; zum Einfügen anklicken", "Show/Hide Mask": "Maske ein-/ausblenden", "Show/hide My Photos panel": "Bereich „Meine Fotos“ ein-/ausblenden", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..1895de569 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1372,6 +1372,7 @@ "Show overlay (O)": "Mostrar superposición (O)", "Show ratings, flags and edit badges": "Mostrar valoraciones, marcas e indicadores de edición", "Show the Navigator while zoomed in": "Mostrar el Navegador al ampliar", + "Show the import review when a camera or card is connected": "Abrir la importación al conectar una cámara o tarjeta", "Show the template tags; click one to insert it": "Mostrar las etiquetas de plantilla; hacer clic en una para insertarla", "Show/Hide Mask": "Mostrar/ocultar máscara", "Show/hide My Photos panel": "Mostrar/ocultar panel Mis fotos", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..cd3c5aec7 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1338,6 +1338,7 @@ "Show overlay (O)": "Afficher l'incrustation (O)", "Show ratings, flags and edit badges": "Afficher les notes, les drapeaux et les pastilles de retouche", "Show the Navigator while zoomed in": "Afficher le Navigateur lors du zoom", + "Show the import review when a camera or card is connected": "Ouvrir l'importation lorsqu'un appareil photo ou une carte est connecté", "Show the template tags; click one to insert it": "Afficher les balises du modèle ; cliquez sur l'une d'elles pour l'insérer", "Show/Hide Mask": "Afficher/masquer le masque", "Show/hide My Photos panel": "Afficher/masquer le panneau Mes photos", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..dcbf07031 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1284,6 +1284,7 @@ "Show overlay (O)": "重ね表示(O)", "Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示", "Show the Navigator while zoomed in": "拡大時にナビゲーターを表示", + "Show the import review when a camera or card is connected": "カメラやメモリーカードを接続したときに読み込み画面を表示", "Show the template tags; click one to insert it": "テンプレートのタグを表示(クリックで挿入)", "Show/Hide Mask": "マスクの表示/非表示", "Show/hide My Photos panel": "マイフォトパネルを表示/非表示", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..50197de2b 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1361,6 +1361,7 @@ "Show overlay (O)": "Mostrar sobreposição (O)", "Show ratings, flags and edit badges": "Mostrar avaliações, marcações e indicadores de edição", "Show the Navigator while zoomed in": "Mostrar o Navegador quando ampliado", + "Show the import review when a camera or card is connected": "Abrir a importação ao conectar uma câmera ou cartão", "Show the template tags; click one to insert it": "Mostrar os marcadores do modelo; clique em um para inseri-lo", "Show/Hide Mask": "Mostrar/ocultar máscara", "Show/hide My Photos panel": "Mostrar/ocultar painel Minhas fotos", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..493acd188 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1129,6 +1129,7 @@ "Show overlay (O)": "Показать наложение (O)", "Show ratings, flags and edit badges": "Показывать оценки, флаги и значки правок", "Show the Navigator while zoomed in": "Показывать навигатор при увеличении", + "Show the import review when a camera or card is connected": "Открывать импорт при подключении камеры или карты", "Show the template tags; click one to insert it": "Показать теги шаблона; щёлкните по тегу, чтобы вставить его", "Show/Hide Mask": "Показать/скрыть маску", "Show/hide My Photos panel": "Показать/скрыть панель «Мои фото»", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..2dc7c2209 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1351,6 +1351,7 @@ "Show overlay (O)": "Показати накладання (O)", "Show ratings, flags and edit badges": "Показати оцінки, прапорці й позначки змін", "Show the Navigator while zoomed in": "Показувати навігатор під час збільшення", + "Show the import review when a camera or card is connected": "Відкривати імпорт під час підключення камери або карти", "Show the template tags; click one to insert it": "Показати теги шаблону; клацніть тег, щоб вставити його", "Show/Hide Mask": "Показати/приховати маску", "Show/hide My Photos panel": "Показати/приховати панель моїх фото", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..21359c381 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1346,6 +1346,7 @@ "Show overlay (O)": "显示叠加 (O)", "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", "Show the Navigator while zoomed in": "放大时显示导航器", + "Show the import review when a camera or card is connected": "连接相机或存储卡时显示导入窗口", "Show the template tags; click one to insert it": "显示模板标签;单击即可插入", "Show/Hide Mask": "显示/隐藏蒙版", "Show/hide My Photos panel": "显示/隐藏我的照片面板", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..2b4b79e18 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1346,6 +1346,7 @@ "Show overlay (O)": "顯示疊加(O)", "Show ratings, flags and edit badges": "顯示評分、旗標與編輯標記", "Show the Navigator while zoomed in": "放大時顯示導覽器", + "Show the import review when a camera or card is connected": "連接相機或記憶卡時顯示匯入視窗", "Show the template tags; click one to insert it": "顯示範本標籤;點擊即可插入", "Show/Hide Mask": "顯示/隱藏遮色片", "Show/hide My Photos panel": "顯示/隱藏我的照片面板", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..5a64d2036 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2948,6 +2948,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Settings → Import → "Show the import review when a camera or card is connected": a card that + /// was already in doesn't ask, a new one opens Import from Device (copying), and a busy window + /// asks once it's free. + #[test] + fn a_connected_card_opens_the_import_review() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let root = std::env::temp_dir().join(format!("lc-card-{}", std::process::id())); + let dcim = root.join("DCIM/100NIKON"); + std::fs::create_dir_all(&dcim).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dcim.join("DSC_0001.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let card = lightcraft_engine::devices::Device { + name: "NIKON Z 8".into(), + path: root.join("DCIM").to_string_lossy().to_string(), + root: root.to_string_lossy().to_string(), + }; + h.app.ui.dialog = None; + // first look: the card was already in + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none() && h.app.ui.dialog.is_none(), "a card that was already in doesn't ask"); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none()); + // taken out, then put back while another dialog is open: it waits + crate::import::cards_seen(&mut h.app, Vec::new()); + h.app.ui.dialog = Some(crate::state::Dialog::About); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none(), "a busy window isn't interrupted"); + h.app.ui.dialog = None; + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_some(), "the card is scanned once the window is free"); + assert!(h.step_until(t, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy, "a card is copied into the library"); + // with the option off nothing is remembered, so turning it on again starts with a first look + assert!(!h.app.session.import_defaults.show_on_card); + h.step(); + assert!(h.app.ui.known_cards.is_none()); + let _ = std::fs::remove_dir_all(&root); + } + /// The folder scan runs in the background: the request returns at once and the review opens /// when the scan finishes (a folder on a network share must not freeze the window). /// Clicking the folder being read again keeps the running read (and its progress). diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..569e13fc2 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -444,6 +444,28 @@ pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } } +/// Settings → Import → "Show the import review when a camera or card is connected", called with +/// each device scan while it's on. The first look only notes the cards already in. A card that +/// shows up after that opens Import from Device once nothing else is open, and a card that is +/// taken out and put back in asks again. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec<lightcraft_engine::devices::Device>) { + let Some(known) = app.ui.known_cards.as_mut() else { + app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); + return; + }; + known.retain(|root| now.iter().any(|d| d.root == *root)); + let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; + // busy: ask when the window is free (the card isn't noted until then) + if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { + return; + } + known.push(card.root); + if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { + log::warn!("import from {}: {e}", card.name); + } +} + /// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> { if app.scan.is_some() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..6145498c5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1092,6 +1092,16 @@ impl LightcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(3)); } + // Settings → Import: open the import review when a camera or card is connected + #[cfg(not(target_arch = "wasm32"))] + if self.session.import_defaults.show_on_card { + if let Some(now) = lightcraft_engine::devices::devices_scanned() { + import::cards_seen(self, now); + } + ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); + } else { + self.ui.known_cards = None; + } self.session.persist_if_dirty(); panels::denoise::pump(self, ctx); model_setup::pump(self, ctx); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a0717ccfd..29f114ea7 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -358,6 +358,11 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); if !cfg!(target_arch = "wasm32") { + heading(ui, t, crate::i18n::tr("Cameras and Cards")); + let mut on_card = d.show_on_card; + if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { + let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}})); + } heading(ui, t, crate::i18n::tr("Auto Import")); hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); row(ui, t, crate::i18n::tr("Watched folder"), |ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..a0d2b37df 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -613,6 +613,10 @@ pub struct UiState { /// When the watched folder was last scanned (egui time). #[serde(skip)] pub auto_import_at: f64, + /// The cards and cameras already seen (volume roots) while Settings → Import → "Show the + /// import review when a camera or card is connected" is on; `None` = not looked yet. + #[serde(skip)] + pub known_cards: Option<Vec<String>>, /// The develop control whose slider is being dragged (geometry sliders show a grid). #[serde(skip)] pub dragging_control: Option<String>, @@ -1101,6 +1105,7 @@ impl Default for UiState { external_edits: Vec::new(), was_focused: true, auto_import_at: 0.0, + known_cards: None, search: String::new(), focus_search: false, renaming_mask: None, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..97b61004f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-IMP-DEVICE: open the import review when a camera or card is connected; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -72,7 +72,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | -| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | +| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | @@ -795,6 +795,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | | 2026-10-14 | patch | LR-REM-HEAL, LR-REM-CLONE: zero-feather edges preserve exact-radius pixels; zero-opacity regression covers all spot modes and image corners. Statuses and feature counts unchanged. | | 2026-10-13 | patch | LR-EXP-DIM: an enlarged export stops at 512 megapixels and is refused when it needs more memory than the computer has, instead of aborting the app; status unchanged | +| 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..e2fee05a0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Import when a card is connected +- Settings → Import → Cameras and Cards has an option to open the import review as soon as a camera or memory card + is connected, the same review File → Import from Device opens. It's off by default. A card that's already in when + LightCraft starts doesn't ask, and if a dialog is open or an import is running it waits until that's done. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. @@ -109,6 +114,8 @@ LightCraft: an enlargement stops at 512 megapixels, as in Lightroom, and one that needs more memory than the computer has is refused with the size it would take, before it starts. +## 2026-10-13 + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From c854e172e41a59d59a1315c095517c82eb3f57e9 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 10:00:43 -0500 Subject: [PATCH 160/315] LR-PROF-CAMERACOLOR: Match the camera look against lens-corrected JPEGs The fit compared the uncorrected raw with the corrected JPEG. It now warps the raw with the file's own lens correction when that lines up better. --- crates/engine/src/camera_preview.rs | 252 ++++++++++++++++++++++++--- crates/engine/src/camera_profiles.rs | 6 +- crates/engine/src/files.rs | 5 +- docs/camera-preview-colour.md | 2 + docs/parity-checklist.md | 5 +- docs/whats-new.md | 5 + 6 files changed, 248 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..a958b9666 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -30,7 +30,8 @@ use lightcraft_raw::{ /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). -pub const LOOK_VERSION: u32 = 5; +/// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. +pub const LOOK_VERSION: u32 = 6; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -74,8 +75,10 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option<CameraLook>) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from +/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. +pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> (Xy, CameraTransform, Option<CameraLook>) { + starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)) } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -119,7 +122,8 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C if lightcraft_raw::color::has_matrix(&raw.color) { return None; } - let (_, t, look) = starting_colour(raw, bytes); + // (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling) + let (_, t, look) = starting_colour(raw, bytes, None); dng_colour(&raw.color, &t, look.as_ref()) } @@ -176,11 +180,11 @@ fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> { } /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) { +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, lens) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -340,7 +344,13 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> { +fn proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + size: usize, + lens: Option<&LensGeometry>, +) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview( bytes, @@ -370,6 +380,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; let mut sensor = sensor_proxy(raw, k, edge as usize)?; let mut clipped = clip_mask(&sensor); + // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % + // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, + // kept below when it lines up with the JPEG better than the uncorrected one. + let warped = lens + .filter(|_| raw.format != RawFormat::Cr3) + .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); let gain = 2f32.powf(transform.baseline_exposure as f32); let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); if raw.format == RawFormat::Cr3 { @@ -383,7 +399,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); } let mut sensor = fit(&sensor, size, size, Filter::Box); - let reference = reference_at(&reference, &sensor); + let mut reference_small = reference_at(&reference, &sensor); + if let Some((warped, warped_clipped)) = warped { + let candidate = fit(&warped, size, size, Filter::Box); + let candidate_reference = reference_at(&reference, &candidate); + if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { + (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); + } + } + let reference = reference_small; // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like // the reference, so the mask pairs pixel for pixel. @@ -545,12 +569,12 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg /// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the /// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own /// colour matrices, other files or unusable previews. -pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> { +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option<Vec<([f64; 3], [f64; 3])>> { if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { return None; } let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, lens)?; let to_camera = transform.matrix.inverse()?; // clipped sensor pixels don't record the scene's colour (see `fit_look`) let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); @@ -565,6 +589,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio Some((matrix, fit_hue_sat(pairs, &matrix))) } +/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of +/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without +/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LensGeometry { + /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. + k: [f64; 4], + /// The optical centre, relative to the active area (as the opcode has it). + center: [f64; 2], +} + +pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option<LensGeometry> { + use lightcraft_raw::Opcode; + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); + if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { + return None; + } + ops.into_iter().find_map(|op| match op { + Opcode::WarpRectilinear { planes, center } => { + let p = planes.get(1).or(planes.first())?; + let k = [p[0], p[1], p[2], p[3]]; + (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) + .then_some(LensGeometry { k, center: *center }) + } + _ => None, + }) +} + +/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both +/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. +fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { + let (w, h) = (a.width, a.height); + if (b.width, b.height) != (w, h) || w < 3 || h < 3 { + return -1.0; + } + let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::<Vec<f64>>(); + let high = |l: &[f64]| -> Vec<f64> { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if x == 0 || y == 0 || x + 1 == w || y + 1 == h { + return f64::NAN; + } + let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; + l[i] - around.iter().map(|&j| l[j]).sum::<f64>() / 8.0 + }) + .collect() + }; + let (ha, hb) = (high(&lum(a)), high(&lum(b))); + let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + if pairs.len() < 64 { + return -1.0; + } + let n = pairs.len() as f64; + let (ma, mb) = (pairs.iter().map(|p| p.0).sum::<f64>() / n, pairs.iter().map(|p| p.1).sum::<f64>() / n); + let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); + let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); + let r = cov / (va * vb).sqrt().max(1e-12); + if r.is_finite() { r } else { -1.0 } +} + +/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is +/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` +/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another +/// size (the demosaiced fallback of linear raws). +fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option<Rgb32f> { + let a = raw.active_area; + let c = raw.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; + let (w, h) = (img.width, img.height); + if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { + return None; + } + let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); + let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); + let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); + let [k0, k1, k2, k3] = lens.k; + let kf = k as f64; + let (x0, y0) = (c.x as f64, c.y as f64); + let mut out = Rgb32f::new(w, h); + for (i, px) in out.data.iter_mut().enumerate() { + // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor + let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); + let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); + let r2 = dx * dx + dy * dy; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); + *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { + img.sample_bilinear(sx as f32, sy as f32) + } else { + [f32::NAN; 3] + }; + } + Some(out) +} + fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, @@ -1052,11 +1172,11 @@ fn monochrome(reference: &Rgb32f) -> bool { /// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and /// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The /// chroma curve stays neutral: the JPEG has no colourfulness to learn. -fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> { +fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> { if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, lens)?; if !monochrome(&reference) { return None; } @@ -2005,6 +2125,93 @@ mod tests { } } + /// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain + /// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. + #[test] + fn lens_geometry_reads_the_first_rectilinear_warp() { + use lightcraft_raw::Opcode; + let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { + planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + center: c, + }; + assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); + let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); + assert!(lens_geometry(&[]).is_none()); + assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); + assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); + let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + p[0] = 1.0; + p[18] = 1e6; + let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; + assert!(lens_geometry(&[chain, single]).is_none()); + } + + /// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp + /// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside + /// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. + #[test] + fn the_sensor_proxy_follows_the_cameras_lens_warp() { + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); + raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); + raw.crop = raw.active_area; + // a 1/4 proxy whose pixels hold their own coordinates + let mut img = Rgb32f::new(16, 12); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; + } + let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); + assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); + // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor + let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); + assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); + let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); + assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); + assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); + } + + /// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected + /// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every + /// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. + #[test] + fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(entries) = std::fs::read_dir(&dir) else { + eprintln!("skip: no corpus at {}", dir.display()); + return; + }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + let (mut checked, mut gain) = (0, 0.0); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; + let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = + (proxies(&raw, &bytes, &t, PROXY, None), proxies(&raw, &bytes, &t, PROXY, Some(&lens))) + else { + continue; + }; + let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); + eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); + // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) + assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); + gain += after - before; + checked += 1; + } + eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); + assert!(checked == 0 || gain > 0.0); + } + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit /// is tried. #[test] @@ -2052,7 +2259,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -2111,13 +2318,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -2136,13 +2343,14 @@ mod tests { continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + let lens = lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); let measured = lightcraft_raw::spectral::find(make, model).is_some(); let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref()); let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2249,7 +2457,7 @@ mod tests { assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0; + let look = fit_preview(&raw, &bytes, &transform, None).0; eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2282,7 +2490,7 @@ mod tests { let raw = lightcraft_raw::decode(&bytes).unwrap(); assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); for (row, want) in look.matrix.0.iter().zip(expected.0) { for (got, want) in row.iter().zip(want) { @@ -2565,9 +2773,9 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, None).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -3155,12 +3363,12 @@ mod tests { tiff }; let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG"); + let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); let mid = curve.apply(0.25); assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); - assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); } /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 6c35c2a45..49dfad408 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -194,10 +194,12 @@ impl Pool { /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; - // colour only: geometric lens corrections would stop the sensor proxy from binning + // colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow + // the warp the camera applied to its JPEG) + let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; - let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) }; + let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) }; let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); let entry = self.models.entry(model.to_owned()).or_default(); entry.0 += 1; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb85..af027ff7b 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -400,12 +400,15 @@ fn load_bytes_now( // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). let info = raw.info(); let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); + // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) + let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = + rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref()), || dng_mattes(&bytes, &info)); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..a12cfc326 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -19,6 +19,8 @@ The matrix is applied to scene-linear sensor pixels. The curve is carried in `So One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. +**Lens-corrected camera JPEGs.** Some raws carry the camera's own lens correction (Fujifilm and Panasonic tables, the ILCE-7RM4A, Nikon's NikonNEFInfo) and the camera often applies it to its JPEG. Nikon Z lenses always do, and that JPEG can be zoomed in by up to 9% to fill the frame. The fit used to pair the uncorrected sensor with the corrected JPEG, so near the edges it compared pixels from different parts of the scene. Now the sensor proxy is also warped with the file's correction (`into_lens_geometry`, pixels that land outside the picture are dropped), and the fit uses whichever of the two lines up better with the JPEG, going by the correlation of their high-passed log luminance. Files whose JPEG isn't corrected keep the plain proxy. On the 29 CC0 corpus raws with a warp the alignment goes up by 0.13 on average (Panasonic GX80 0.41 to 0.83, Fujifilm X-T20 0.44 to 0.95). Mean ΔE against the camera JPEG over the 35 corpus raws of those formats goes from 3.73 to 3.61, and on 299 private Z 6II photos with Nikon's correction read it goes from 3.22 to 2.68. + **A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9). **A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..46e126f6b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-PROF-CAMERACOLOR: the camera look follows lens-corrected JPEGs; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -815,6 +815,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..cd0fd0935 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Closer camera colour for lens-corrected raws +- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the + Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that + didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 2bc2f60277e39422df6b6e680225e7fdf2e48344 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 11:27:36 -0500 Subject: [PATCH 161/315] LR-PROF-CAMERACOLOR: Add a bundled Nikon Z 8 camera profile Fitted with lightcraft-cli calibrate to 261 of my own Z 8 photos. --- README.md | 2 +- README.zh-hans.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/NIKON_Z_8.json | 1819 +++++++++++++++++++++++++ crates/engine/src/camera_preview.rs | 33 +- crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 8 +- docs/parity-checklist.md | 3 +- docs/whats-new.md | 4 +- xtask/src/main.rs | 6 + 10 files changed, 1863 insertions(+), 16 deletions(-) create mode 100644 assets/camera-profiles/NIKON_Z_8.json diff --git a/README.md b/README.md index 3e93b41ee..c16ded23c 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4 and Nikon Z 6II profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II and Z 8 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 5183d40a8..0bc8239d6 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4 和尼康 Z 6II 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II 和 Z 8 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index a5e1716d7..d3fbc4d7b 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -15,6 +15,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/camera-profiles/NIKON_Z_8.json b/assets/camera-profiles/NIKON_Z_8.json new file mode 100644 index 000000000..0c63c0d84 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_8.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 8", + "files": 261, + "samples": 969860, + "matrix": [ + [1.0990362785046823, -0.20725005493106963, 0.10268209021537977], + [-0.029117321941279695, 1.1458550830734442, -0.11855469779814065], + [-0.10582269960146817, -0.749496743582382, 1.900598651063195] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [7.111473, 1.181401, 1.0], + [2.9113195, 1.1633477, 1.0], + [1.8994527, 1.137069, 1.0], + [0.53534466, 1.0345078, 1.0], + [0.0, 1.0, 1.0], + [6.794022, 1.1620317, 1.0], + [1.6603756, 1.1454334, 1.0], + [0.65508085, 1.131233, 1.0], + [0.18744454, 1.0536848, 1.0], + [0.0, 1.0, 1.0], + [6.2740474, 1.1378076, 1.0], + [0.39785913, 1.126624, 1.0], + [-1.5408971, 1.0759563, 1.0], + [-1.7140917, 1.0247983, 1.0], + [0.0, 1.0, 1.0], + [5.2532353, 1.1126455, 1.0], + [-0.7449979, 1.1205007, 1.0], + [-3.0170422, 1.0559602, 1.0], + [-3.354577, 0.9959453, 1.0], + [0.0, 1.0, 1.0], + [4.8965535, 1.0955226, 1.0], + [-0.70895463, 1.1094975, 1.0], + [-3.1364403, 1.0476824, 1.0], + [-4.1989164, 0.96736634, 1.0], + [0.0, 1.0, 1.0], + [3.7045963, 1.0627788, 1.0], + [-1.3298069, 1.0956368, 1.0], + [-2.823391, 1.0708525, 1.0], + [-2.8166869, 1.0153031, 1.0], + [0.0, 1.0, 1.0], + [2.7337725, 1.037307, 1.0], + [-1.8260294, 1.0655074, 1.0], + [-3.5580583, 1.0488383, 1.0], + [-4.437229, 1.0113343, 1.0], + [0.0, 1.0, 1.0], + [2.3106112, 1.0036124, 1.0], + [-2.1256907, 1.0205824, 1.0], + [-4.961178, 1.0156583, 1.0], + [-6.710935, 0.9911362, 1.0], + [0.0, 1.0, 1.0], + [1.3536092, 0.9934402, 1.0], + [-2.5290065, 0.99898964, 1.0], + [-5.4174004, 0.9998236, 1.0], + [-6.597085, 0.9757822, 1.0], + [0.0, 1.0, 1.0], + [1.1366612, 1.002107, 1.0], + [-2.4894423, 0.9997597, 1.0], + [-5.4894304, 0.9861238, 1.0], + [-7.5021935, 0.92478454, 1.0], + [0.0, 1.0, 1.0], + [1.5145179, 1.021503, 1.0], + [-1.5998987, 1.0070803, 1.0], + [-4.508984, 0.9658881, 1.0], + [-7.158313, 0.89098585, 1.0], + [0.0, 1.0, 1.0], + [1.5639887, 1.0445297, 1.0], + [-0.915344, 1.0133737, 1.0], + [-3.1283567, 0.9622828, 1.0], + [-5.1986914, 0.8836733, 1.0], + [0.0, 1.0, 1.0], + [1.1355672, 1.0603209, 1.0], + [-0.56755894, 1.0115352, 1.0], + [-1.8851622, 0.95658684, 1.0], + [-3.4127562, 0.8781371, 1.0], + [0.0, 1.0, 1.0], + [0.5467292, 1.0580485, 1.0], + [-0.41302148, 1.0018384, 1.0], + [-1.0522548, 0.94771504, 1.0], + [-1.7539524, 0.876335, 1.0], + [0.0, 1.0, 1.0], + [-0.0725913, 1.0581536, 1.0], + [-0.4528266, 0.9990066, 1.0], + [-0.57961255, 0.9448505, 1.0], + [-0.5526036, 0.8833151, 1.0], + [0.0, 1.0, 1.0], + [-0.46772903, 1.0652338, 1.0], + [-0.02375252, 1.0044563, 1.0], + [0.43251258, 0.94911736, 1.0], + [0.87126744, 0.89478797, 1.0], + [0.0, 1.0, 1.0], + [0.16140896, 1.0854058, 1.0], + [1.07174, 1.0152693, 1.0], + [1.7586585, 0.95322454, 1.0], + [2.3524222, 0.8983894, 1.0], + [0.0, 1.0, 1.0], + [0.2469127, 1.1031706, 1.0], + [1.1097546, 1.0245277, 1.0], + [1.8333874, 0.96286947, 1.0], + [2.6193652, 0.9074495, 1.0], + [0.0, 1.0, 1.0], + [-0.6874714, 1.1182649, 1.0], + [0.16355518, 1.0515357, 1.0], + [1.1668121, 0.98603916, 1.0], + [2.3031707, 0.9206025, 1.0], + [0.0, 1.0, 1.0], + [-2.0503242, 1.1499335, 1.0], + [-1.1552397, 1.0879966, 1.0], + [-0.10152792, 1.0231872, 1.0], + [1.0718579, 0.95476115, 1.0], + [0.0, 1.0, 1.0], + [-3.8791447, 1.188533, 1.0], + [-2.9817088, 1.1327138, 1.0], + [-1.7755328, 1.0618997, 1.0], + [-0.21666291, 0.94862247, 1.0], + [0.0, 1.0, 1.0], + [-5.8841834, 1.226612, 1.0], + [-4.98382, 1.1740679, 1.0], + [-3.1025357, 1.0698453, 1.0], + [-0.9934275, 0.9242727, 1.0], + [0.0, 1.0, 1.0], + [-8.169295, 1.2657825, 1.0], + [-7.0660734, 1.2132481, 1.0], + [-3.1967385, 1.0559258, 1.0], + [-0.6672474, 0.97608167, 1.0], + [0.0, 1.0, 1.0], + [-10.515467, 1.3097212, 1.0], + [-8.952165, 1.2460706, 1.0], + [-2.6956723, 1.0394313, 1.0], + [-0.24548055, 0.9968061, 1.0], + [0.0, 1.0, 1.0], + [-13.161258, 1.348111, 1.0], + [-10.267992, 1.2503926, 1.0], + [-1.3459312, 1.0174122, 1.0], + [-0.040196028, 1.0000353, 1.0], + [0.0, 1.0, 1.0], + [-15.790122, 1.339094, 1.0], + [-11.393451, 1.2008783, 1.0], + [-2.688318, 1.0172836, 1.0], + [-0.20984244, 1.0009907, 1.0], + [0.0, 1.0, 1.0], + [-18.627531, 1.308051, 1.0], + [-12.985201, 1.160609, 1.0], + [-2.6413164, 1.014484, 1.0], + [-0.1162438, 1.0004718, 1.0], + [0.0, 1.0, 1.0], + [-19.562479, 1.2632611, 1.0], + [-12.712911, 1.108189, 1.0], + [-2.095739, 1.0048033, 1.0], + [-0.04567434, 0.99998564, 1.0], + [0.0, 1.0, 1.0], + [-20.291159, 1.2343935, 1.0], + [-12.380699, 1.0716544, 1.0], + [-1.7035112, 0.9987354, 1.0], + [-0.026139155, 0.9999211, 1.0], + [0.0, 1.0, 1.0], + [-19.989433, 1.200086, 1.0], + [-11.1118, 1.0480033, 1.0], + [-1.400926, 0.996738, 1.0], + [-0.024835525, 0.9998831, 1.0], + [0.0, 1.0, 1.0], + [-19.566994, 1.17641, 1.0], + [-10.257852, 1.0363005, 1.0], + [-1.2149851, 0.99804825, 1.0], + [-0.015627315, 0.9999487, 1.0], + [0.0, 1.0, 1.0], + [-18.913862, 1.134827, 1.0], + [-10.370755, 1.0167582, 1.0], + [-1.6505148, 0.99528724, 1.0], + [-0.026268613, 0.9998695, 1.0], + [0.0, 1.0, 1.0], + [-18.999414, 1.1098753, 1.0], + [-9.642386, 1.0115949, 1.0], + [-1.2675271, 0.9941188, 1.0], + [-0.018203475, 0.99986815, 1.0], + [0.0, 1.0, 1.0], + [-17.586956, 1.091192, 1.0], + [-8.722237, 1.0048285, 1.0], + [-1.1602849, 0.9960165, 1.0], + [-0.013571129, 0.9999434, 1.0], + [0.0, 1.0, 1.0], + [-16.729242, 1.0722094, 1.0], + [-9.583627, 0.9960505, 1.0], + [-2.039792, 0.9939141, 1.0], + [-0.04050307, 0.99985063, 1.0], + [0.0, 1.0, 1.0], + [-16.906395, 1.075484, 1.0], + [-8.840361, 1.0053103, 1.0], + [-1.4046353, 0.9954329, 1.0], + [-0.031679757, 0.99983394, 1.0], + [0.0, 1.0, 1.0], + [-17.531858, 1.0629116, 1.0], + [-8.608148, 0.9959869, 1.0], + [-1.1416123, 0.99636537, 1.0], + [-0.013378057, 0.99994534, 1.0], + [0.0, 1.0, 1.0], + [-17.99668, 1.0072999, 1.0], + [-10.570478, 0.9696376, 1.0], + [-1.9599729, 0.9910149, 1.0], + [-0.022619616, 0.99988747, 1.0], + [0.0, 1.0, 1.0], + [-18.86213, 0.96442, 1.0], + [-11.836861, 0.9444823, 1.0], + [-2.469638, 0.984263, 1.0], + [-0.030691203, 0.99978447, 1.0], + [0.0, 1.0, 1.0], + [-20.236654, 0.90864915, 1.0], + [-13.107022, 0.9121311, 1.0], + [-2.9203506, 0.97508854, 1.0], + [-0.041130785, 0.9996122, 1.0], + [0.0, 1.0, 1.0], + [-20.714214, 0.8536292, 1.0], + [-14.74853, 0.8750625, 1.0], + [-3.946734, 0.9621585, 1.0], + [-0.05774192, 0.9994195, 1.0], + [0.0, 1.0, 1.0], + [-20.098251, 0.80836505, 1.0], + [-14.476766, 0.851234, 1.0], + [-5.6719704, 0.9378101, 1.0], + [-0.12513989, 0.9985625, 1.0], + [0.0, 1.0, 1.0], + [-19.542671, 0.76297987, 1.0], + [-13.628136, 0.83560556, 1.0], + [-7.0074353, 0.91483426, 1.0], + [-0.28871387, 0.99629575, 1.0], + [0.0, 1.0, 1.0], + [-18.084225, 0.74823433, 1.0], + [-12.162158, 0.84476614, 1.0], + [-6.7999973, 0.9135009, 1.0], + [-0.40371415, 0.9944375, 1.0], + [0.0, 1.0, 1.0], + [-16.452961, 0.72636646, 1.0], + [-9.328942, 0.86091775, 1.0], + [-4.9608183, 0.9375208, 1.0], + [-0.27671492, 0.9964092, 1.0], + [0.0, 1.0, 1.0], + [-13.168701, 0.74453783, 1.0], + [-6.218363, 0.90167975, 1.0], + [-3.7400405, 0.95979875, 1.0], + [-0.46412376, 0.995171, 1.0], + [0.0, 1.0, 1.0], + [-10.4688425, 0.77134514, 1.0], + [-5.7574325, 0.8950614, 1.0], + [-3.034646, 0.9650316, 1.0], + [-0.36110362, 0.9969887, 1.0], + [0.0, 1.0, 1.0], + [-7.514723, 0.7996464, 1.0], + [-3.7689729, 0.9172442, 1.0], + [-0.9782931, 1.0084524, 1.0], + [0.31402144, 1.0211289, 1.0], + [0.0, 1.0, 1.0], + [-5.6537147, 0.8334752, 1.0], + [-2.504266, 0.96418613, 1.0], + [-0.07010336, 1.052774, 1.0], + [0.894411, 1.0569643, 1.0], + [0.0, 1.0, 1.0], + [-4.493737, 0.7882688, 1.0], + [-2.3410633, 0.936153, 1.0], + [-0.32673165, 1.0513396, 1.0], + [0.49912313, 1.0484643, 1.0], + [0.0, 1.0, 1.0], + [-1.7034019, 0.73160046, 1.0], + [-0.831635, 0.8589306, 1.0], + [-0.3882015, 1.0134665, 1.0], + [-0.18704136, 1.0146226, 1.0], + [0.0, 1.0, 1.0], + [0.61953825, 0.7258855, 1.0], + [0.81030744, 0.8431724, 1.0], + [0.00040164185, 1.0047435, 1.0], + [-0.10240378, 1.0050768, 1.0], + [0.0, 1.0, 1.0], + [2.0624804, 0.7425685, 1.0], + [1.5886667, 0.8645959, 1.0], + [0.4176404, 1.0037674, 1.0], + [-0.00398716, 1.0012411, 1.0], + [0.0, 1.0, 1.0], + [3.623457, 0.7510747, 1.0], + [1.7844758, 0.885587, 1.0], + [-0.036421247, 1.0089039, 1.0], + [-0.020441405, 1.001009, 1.0], + [0.0, 1.0, 1.0], + [4.7174945, 0.7708686, 1.0], + [3.2143273, 0.90923154, 1.0], + [0.25317824, 1.0065747, 1.0], + [-0.016773207, 1.0006565, 1.0], + [0.0, 1.0, 1.0], + [5.9824314, 0.7961609, 1.0], + [3.939417, 0.94614726, 1.0], + [0.7240807, 1.0062363, 1.0], + [0.030703958, 1.0001671, 1.0], + [0.0, 1.0, 1.0], + [7.3545732, 0.82526684, 1.0], + [4.982609, 0.9743153, 1.0], + [1.4535582, 1.0078721, 1.0], + [0.062866114, 1.0002259, 1.0], + [0.0, 1.0, 1.0], + [7.4568152, 0.8617716, 1.0], + [3.6539178, 1.0011972, 1.0], + [0.9039327, 1.0089482, 1.0], + [0.03915892, 1.000176, 1.0], + [0.0, 1.0, 1.0], + [7.3634186, 0.91159344, 1.0], + [3.4343126, 1.0100657, 1.0], + [0.695478, 1.010742, 1.0], + [0.03255169, 1.0004065, 1.0], + [0.0, 1.0, 1.0], + [7.238165, 0.9486031, 1.0], + [4.089919, 1.0266953, 1.0], + [1.1045972, 1.0186851, 1.0], + [0.057033073, 1.0010694, 1.0], + [0.0, 1.0, 1.0], + [7.0543814, 0.9770822, 1.0], + [3.9128, 1.0336262, 1.0], + [1.1944752, 1.0113511, 1.0], + [0.07083795, 1.0004443, 1.0], + [0.0, 1.0, 1.0], + [8.143442, 0.99413896, 1.0], + [4.040843, 1.0496417, 1.0], + [0.8080747, 1.0116086, 1.0], + [0.042427447, 0.99993575, 1.0], + [0.0, 1.0, 1.0], + [7.18577, 0.9783746, 1.0], + [3.2767346, 1.0421677, 1.0], + [1.2492384, 1.016086, 1.0], + [0.15854485, 1.0005085, 1.0], + [0.0, 1.0, 1.0], + [7.194208, 1.0016203, 1.0], + [3.0112083, 1.0349026, 1.0], + [0.91562295, 1.0119883, 1.0], + [0.0654759, 1.0005552, 1.0], + [0.0, 1.0, 1.0], + [7.414966, 0.998843, 1.0], + [3.1521475, 1.0262057, 1.0], + [0.9972541, 1.0102346, 1.0], + [0.060677312, 1.0006106, 1.0], + [0.0, 1.0, 1.0], + [9.276159, 0.9916557, 1.0], + [4.375442, 1.0329365, 1.0], + [1.5409418, 1.015073, 1.0], + [0.13645935, 1.0012853, 1.0], + [0.0, 1.0, 1.0], + [9.8098955, 1.0221618, 1.0], + [5.381683, 1.0610034, 1.0], + [2.1655529, 1.0283315, 1.0], + [0.15032019, 1.0015818, 1.0], + [0.0, 1.0, 1.0], + [8.995072, 1.0311706, 1.0], + [5.2788897, 1.0783035, 1.0], + [2.6601613, 1.0430328, 1.0], + [0.23225841, 1.0026511, 1.0], + [0.0, 1.0, 1.0], + [10.116063, 1.0506214, 1.0], + [6.2658486, 1.0895245, 1.0], + [3.640211, 1.0535048, 1.0], + [0.39292678, 1.0040704, 1.0], + [0.0, 1.0, 1.0], + [9.800368, 1.0582323, 1.0], + [6.5737424, 1.0822098, 1.0], + [6.3034444, 1.0650462, 1.0], + [1.6771196, 1.0111806, 1.0], + [0.0, 1.0, 1.0], + [9.205544, 1.097704, 1.0], + [5.412633, 1.1020687, 1.0], + [4.3975163, 1.0531521, 1.0], + [1.2703937, 1.0038551, 1.0], + [0.0, 1.0, 1.0], + [8.4540615, 1.1362739, 1.0], + [4.478308, 1.1312087, 1.0], + [2.8681872, 1.0796018, 1.0], + [0.52417415, 1.009483, 1.0], + [0.0, 1.0, 1.0], + [6.872955, 1.1356013, 1.0], + [2.2753267, 1.1355586, 1.0], + [1.7234284, 1.1281133, 1.0], + [1.1223476, 1.0683283, 1.0], + [0.0, 1.0, 1.0], + [6.124278, 1.1170305, 1.0], + [0.4689261, 1.1054591, 1.0], + [-0.22648379, 1.088597, 1.0], + [-0.41844296, 1.0501652, 1.0], + [0.0, 1.0, 1.0], + [5.5311556, 1.0830601, 1.0], + [-0.7712272, 1.0794301, 1.0], + [-2.3635144, 1.0375766, 1.0], + [-2.6961873, 1.0000627, 1.0], + [0.0, 1.0, 1.0], + [4.776837, 1.051424, 1.0], + [-1.30482, 1.0655525, 1.0], + [-3.4497104, 1.0115541, 1.0], + [-4.008624, 0.9658105, 1.0], + [0.0, 1.0, 1.0], + [4.325843, 1.0289863, 1.0], + [-0.89701647, 1.0507549, 1.0], + [-3.5267189, 0.9840365, 1.0], + [-4.8896995, 0.92570627, 1.0], + [0.0, 1.0, 1.0], + [3.2383246, 0.9979077, 1.0], + [-1.0782118, 1.0382204, 1.0], + [-2.7714362, 1.0106578, 1.0], + [-3.8654947, 0.9470026, 1.0], + [0.0, 1.0, 1.0], + [2.3593056, 0.97518253, 1.0], + [-1.4679712, 1.0127448, 1.0], + [-3.3288295, 1.0004958, 1.0], + [-4.8622665, 0.95781606, 1.0], + [0.0, 1.0, 1.0], + [1.5416691, 0.94487643, 1.0], + [-1.9971544, 0.97270143, 1.0], + [-4.8065176, 0.9733487, 1.0], + [-6.8176403, 0.9401679, 1.0], + [0.0, 1.0, 1.0], + [0.57058907, 0.9429605, 1.0], + [-2.4935536, 0.96301734, 1.0], + [-5.3679204, 0.9637666, 1.0], + [-6.831026, 0.91412973, 1.0], + [0.0, 1.0, 1.0], + [0.26053378, 0.9610666, 1.0], + [-2.7039409, 0.9701933, 1.0], + [-5.4002733, 0.9458906, 1.0], + [-6.9784875, 0.86947393, 1.0], + [0.0, 1.0, 1.0], + [0.53928864, 0.9811304, 1.0], + [-2.0121186, 0.97265023, 1.0], + [-4.3233175, 0.9172091, 1.0], + [-6.005866, 0.8446555, 1.0], + [0.0, 1.0, 1.0], + [0.6283378, 1.0006269, 1.0], + [-1.4216532, 0.9777993, 1.0], + [-3.27393, 0.91542476, 1.0], + [-4.512793, 0.8313424, 1.0], + [0.0, 1.0, 1.0], + [0.36547175, 1.0127599, 1.0], + [-1.0703696, 0.9762654, 1.0], + [-2.3095455, 0.9178097, 1.0], + [-3.2788665, 0.82293683, 1.0], + [0.0, 1.0, 1.0], + [-0.073588796, 1.014126, 1.0], + [-0.83646154, 0.96881944, 1.0], + [-1.3723681, 0.9147329, 1.0], + [-1.7665399, 0.82023007, 1.0], + [0.0, 1.0, 1.0], + [-0.63139224, 1.017959, 1.0], + [-0.81763905, 0.96828437, 1.0], + [-0.8057263, 0.91791266, 1.0], + [-0.65346074, 0.85073775, 1.0], + [0.0, 1.0, 1.0], + [-0.9540565, 1.0314627, 1.0], + [-0.4009139, 0.97503597, 1.0], + [0.16406323, 0.91989255, 1.0], + [0.69900894, 0.8609631, 1.0], + [0.0, 1.0, 1.0], + [-0.29338956, 1.051337, 1.0], + [0.6638694, 0.9824637, 1.0], + [1.4182094, 0.92527664, 1.0], + [2.0502326, 0.8743307, 1.0], + [0.0, 1.0, 1.0], + [-0.19034386, 1.060521, 1.0], + [0.7418265, 0.9920945, 1.0], + [1.5311259, 0.94175714, 1.0], + [2.3645382, 0.89062786, 1.0], + [0.0, 1.0, 1.0], + [-1.1072253, 1.0738078, 1.0], + [-0.17039186, 1.0250928, 1.0], + [1.0041893, 0.9713254, 1.0], + [2.2262986, 0.9069038, 1.0], + [0.0, 1.0, 1.0], + [-2.3444607, 1.0938511, 1.0], + [-1.396141, 1.0545899, 1.0], + [-0.19473651, 1.0042597, 1.0], + [1.0580148, 0.93644226, 1.0], + [0.0, 1.0, 1.0], + [-3.9988203, 1.1291953, 1.0], + [-3.121686, 1.0951587, 1.0], + [-1.7795982, 1.0358233, 1.0], + [-0.33727086, 0.92730236, 1.0], + [0.0, 1.0, 1.0], + [-5.7153583, 1.1640722, 1.0], + [-4.88902, 1.1327654, 1.0], + [-3.3953416, 1.0511376, 1.0], + [-1.5109448, 0.885162, 1.0], + [0.0, 1.0, 1.0], + [-7.723858, 1.1939173, 1.0], + [-6.834126, 1.162886, 1.0], + [-4.4360557, 1.0393157, 1.0], + [-2.0289664, 0.9277147, 1.0], + [0.0, 1.0, 1.0], + [-9.929747, 1.233579, 1.0], + [-8.989848, 1.1980727, 1.0], + [-5.8884463, 1.0467408, 1.0], + [-1.4371129, 0.9823743, 1.0], + [0.0, 1.0, 1.0], + [-12.79711, 1.2787904, 1.0], + [-11.43725, 1.2281201, 1.0], + [-5.0934134, 1.0372151, 1.0], + [-0.3870912, 0.99834675, 1.0], + [0.0, 1.0, 1.0], + [-15.6093645, 1.2775221, 1.0], + [-13.393321, 1.1939902, 1.0], + [-7.004254, 1.02318, 1.0], + [-0.8632698, 1.0002364, 1.0], + [0.0, 1.0, 1.0], + [-18.6125, 1.2465408, 1.0], + [-15.71152, 1.1482728, 1.0], + [-7.1894984, 1.0131606, 1.0], + [-0.6019608, 0.9991237, 1.0], + [0.0, 1.0, 1.0], + [-19.882221, 1.2043245, 1.0], + [-16.663143, 1.0912375, 1.0], + [-6.9870176, 0.9956438, 1.0], + [-0.2885382, 0.9992374, 1.0], + [0.0, 1.0, 1.0], + [-20.625488, 1.1759886, 1.0], + [-16.814833, 1.0537983, 1.0], + [-6.23361, 0.9808524, 1.0], + [-0.16323197, 0.99922365, 1.0], + [0.0, 1.0, 1.0], + [-21.191574, 1.1593459, 1.0], + [-16.181896, 1.0420355, 1.0], + [-4.434142, 0.979971, 1.0], + [-0.098916985, 0.9993104, 1.0], + [0.0, 1.0, 1.0], + [-21.118694, 1.1469342, 1.0], + [-15.308236, 1.0306582, 1.0], + [-3.593454, 0.9800867, 1.0], + [-0.11090971, 0.99904144, 1.0], + [0.0, 1.0, 1.0], + [-20.06453, 1.1023701, 1.0], + [-16.392384, 0.9912434, 1.0], + [-6.5470276, 0.95821047, 1.0], + [-0.29561612, 0.99735236, 1.0], + [0.0, 1.0, 1.0], + [-20.86482, 1.0911682, 1.0], + [-16.905964, 0.9835901, 1.0], + [-6.5230694, 0.95751834, 1.0], + [-0.18875386, 0.99822915, 1.0], + [0.0, 1.0, 1.0], + [-19.563591, 1.0770866, 1.0], + [-15.724344, 0.9894494, 1.0], + [-5.0314937, 0.9782653, 1.0], + [-0.08916562, 0.99951047, 1.0], + [0.0, 1.0, 1.0], + [-19.648596, 1.066223, 1.0], + [-15.772977, 0.97731113, 1.0], + [-6.1778703, 0.97096556, 1.0], + [-0.17319372, 0.999023, 1.0], + [0.0, 1.0, 1.0], + [-19.83075, 1.0626674, 1.0], + [-15.684646, 0.9758881, 1.0], + [-5.8669987, 0.96583444, 1.0], + [-0.21658812, 0.99854726, 1.0], + [0.0, 1.0, 1.0], + [-19.55086, 1.04659, 1.0], + [-14.666198, 0.9744223, 1.0], + [-4.026313, 0.98011285, 1.0], + [-0.07394151, 0.9995447, 1.0], + [0.0, 1.0, 1.0], + [-20.196869, 0.988159, 1.0], + [-17.390198, 0.93337435, 1.0], + [-7.4060826, 0.95599365, 1.0], + [-0.14739579, 0.99894, 1.0], + [0.0, 1.0, 1.0], + [-21.26852, 0.9264197, 1.0], + [-18.344461, 0.8974489, 1.0], + [-8.743559, 0.936538, 1.0], + [-0.19807786, 0.99823266, 1.0], + [0.0, 1.0, 1.0], + [-21.510828, 0.87103164, 1.0], + [-18.518517, 0.86176705, 1.0], + [-10.065859, 0.9085589, 1.0], + [-0.2821744, 0.99699414, 1.0], + [0.0, 1.0, 1.0], + [-21.584885, 0.8148765, 1.0], + [-18.512503, 0.82746816, 1.0], + [-11.73013, 0.8824068, 1.0], + [-0.3848725, 0.9958189, 1.0], + [0.0, 1.0, 1.0], + [-20.356161, 0.77016896, 1.0], + [-16.736427, 0.8110233, 1.0], + [-13.46877, 0.8463329, 1.0], + [-1.0992389, 0.9864028, 1.0], + [0.0, 1.0, 1.0], + [-19.00107, 0.7378436, 1.0], + [-14.466894, 0.8157218, 1.0], + [-12.32627, 0.84496945, 1.0], + [-2.4299142, 0.9672269, 1.0], + [0.0, 1.0, 1.0], + [-17.491095, 0.72382104, 1.0], + [-11.5228195, 0.84021336, 1.0], + [-9.1774845, 0.87830716, 1.0], + [-2.8254447, 0.9605242, 1.0], + [0.0, 1.0, 1.0], + [-15.435101, 0.7124373, 1.0], + [-8.480166, 0.8606328, 1.0], + [-5.882448, 0.91346925, 1.0], + [-1.6596885, 0.9736952, 1.0], + [0.0, 1.0, 1.0], + [-9.965368, 0.78840566, 1.0], + [-5.0230317, 0.92467666, 1.0], + [-3.841451, 0.95225155, 1.0], + [-2.0082777, 0.97166634, 1.0], + [0.0, 1.0, 1.0], + [-7.585785, 0.8164634, 1.0], + [-4.2593484, 0.9317818, 1.0], + [-2.753388, 0.96636844, 1.0], + [-0.8575842, 0.98519945, 1.0], + [0.0, 1.0, 1.0], + [-5.9080076, 0.82606196, 1.0], + [-2.4876971, 0.95708805, 1.0], + [-0.017117301, 1.0234948, 1.0], + [1.1949928, 1.0367807, 1.0], + [0.0, 1.0, 1.0], + [-4.3217926, 0.8621694, 1.0], + [-1.1579821, 1.013248, 1.0], + [0.829161, 1.0717134, 1.0], + [1.6019802, 1.078209, 1.0], + [0.0, 1.0, 1.0], + [-3.4365811, 0.801541, 1.0], + [-1.142285, 0.9963089, 1.0], + [0.6441237, 1.0807948, 1.0], + [1.3309326, 1.0855218, 1.0], + [0.0, 1.0, 1.0], + [-1.0976561, 0.7120078, 1.0], + [-0.8466615, 0.89612305, 1.0], + [-0.7710759, 1.0564098, 1.0], + [-0.6550111, 1.07382, 1.0], + [0.0, 1.0, 1.0], + [0.8495644, 0.71387726, 1.0], + [0.49764365, 0.8776602, 1.0], + [-0.9067246, 1.0398972, 1.0], + [-1.0517284, 1.0432695, 1.0], + [0.0, 1.0, 1.0], + [2.4473016, 0.72682, 1.0], + [1.7983775, 0.88795257, 1.0], + [0.20548964, 1.0286621, 1.0], + [-0.35707185, 1.0189359, 1.0], + [0.0, 1.0, 1.0], + [4.0796447, 0.7306628, 1.0], + [2.1504579, 0.89834225, 1.0], + [0.34639126, 1.0416607, 1.0], + [-0.23788506, 1.019259, 1.0], + [0.0, 1.0, 1.0], + [5.6701546, 0.74359995, 1.0], + [4.0726633, 0.91694146, 1.0], + [1.3216503, 1.0316267, 1.0], + [-0.045850165, 1.0076267, 1.0], + [0.0, 1.0, 1.0], + [7.0043573, 0.7694095, 1.0], + [5.4557076, 0.9445862, 1.0], + [4.0584545, 1.0185257, 1.0], + [0.56355107, 1.0008852, 1.0], + [0.0, 1.0, 1.0], + [8.084995, 0.78970784, 1.0], + [5.8458114, 0.970168, 1.0], + [4.5538306, 1.024938, 1.0], + [0.49345702, 1.0022416, 1.0], + [0.0, 1.0, 1.0], + [8.05806, 0.817034, 1.0], + [4.4210987, 1.0048858, 1.0], + [3.0152867, 1.0326685, 1.0], + [0.38135135, 1.0021529, 1.0], + [0.0, 1.0, 1.0], + [9.005357, 0.85885465, 1.0], + [4.763144, 1.0251466, 1.0], + [2.17603, 1.0425395, 1.0], + [0.36619714, 1.0030634, 1.0], + [0.0, 1.0, 1.0], + [8.993758, 0.90082896, 1.0], + [5.2438936, 1.0453547, 1.0], + [2.549889, 1.0553246, 1.0], + [0.32793412, 1.0068744, 1.0], + [0.0, 1.0, 1.0], + [9.829749, 0.9506837, 1.0], + [6.0920825, 1.0672028, 1.0], + [3.7592368, 1.0513718, 1.0], + [0.4075813, 1.0041977, 1.0], + [0.0, 1.0, 1.0], + [9.575895, 0.9809986, 1.0], + [5.147536, 1.0732907, 1.0], + [2.6697485, 1.0479143, 1.0], + [0.23983598, 1.0018369, 1.0], + [0.0, 1.0, 1.0], + [8.565204, 0.9654144, 1.0], + [4.159953, 1.0592482, 1.0], + [2.982862, 1.046513, 1.0], + [0.51444083, 1.0031579, 1.0], + [0.0, 1.0, 1.0], + [9.271386, 0.9671414, 1.0], + [3.9021678, 1.0480341, 1.0], + [2.3847647, 1.0395492, 1.0], + [0.338095, 1.0032356, 1.0], + [0.0, 1.0, 1.0], + [9.753154, 0.9583014, 1.0], + [4.58982, 1.0358695, 1.0], + [3.3719125, 1.0326496, 1.0], + [0.62065965, 1.0025914, 1.0], + [0.0, 1.0, 1.0], + [10.777158, 0.9585278, 1.0], + [5.382428, 1.024311, 1.0], + [3.983915, 1.028954, 1.0], + [0.95064604, 1.0067729, 1.0], + [0.0, 1.0, 1.0], + [10.107686, 0.98531634, 1.0], + [5.2336845, 1.0421758, 1.0], + [3.7427158, 1.0352445, 1.0], + [0.91338927, 1.0056044, 1.0], + [0.0, 1.0, 1.0], + [10.010471, 1.002975, 1.0], + [5.0806794, 1.0562643, 1.0], + [3.9300086, 1.0432692, 1.0], + [1.2768706, 1.0071726, 1.0], + [0.0, 1.0, 1.0], + [10.721367, 1.020363, 1.0], + [5.9179363, 1.0760281, 1.0], + [4.653525, 1.0701501, 1.0], + [1.5535151, 1.0154992, 1.0], + [0.0, 1.0, 1.0], + [10.558615, 1.0318329, 1.0], + [6.080362, 1.067487, 1.0], + [6.671132, 1.0704038, 1.0], + [4.238108, 1.0257412, 1.0], + [0.0, 1.0, 1.0], + [9.48343, 1.0585111, 1.0], + [4.4426146, 1.0725648, 1.0], + [4.5216165, 1.0536869, 1.0], + [3.2611907, 1.0090235, 1.0], + [0.0, 1.0, 1.0], + [8.163124, 1.0967343, 1.0], + [3.4163864, 1.1021494, 1.0], + [3.0036535, 1.0894843, 1.0], + [1.6298203, 1.0331198, 1.0], + [0.0, 1.0, 1.0], + [4.7200303, 0.99990386, 1.0], + [-0.18434145, 0.9829197, 1.0], + [-1.7782454, 0.9463851, 1.0], + [-0.8780288, 0.9674484, 1.0], + [0.0, 1.0, 1.0], + [3.3225217, 0.9776916, 1.0], + [-1.491963, 0.9430486, 1.0], + [-2.2781556, 0.91853374, 1.0], + [-1.9229406, 0.92325467, 1.0], + [0.0, 1.0, 1.0], + [2.4281845, 0.93801886, 1.0], + [-2.0302162, 0.92144394, 1.0], + [-2.984083, 0.90740937, 1.0], + [-2.8202295, 0.9010818, 1.0], + [0.0, 1.0, 1.0], + [2.21699, 0.9216663, 1.0], + [-1.478845, 0.9148489, 1.0], + [-3.1692345, 0.9026742, 1.0], + [-3.4822135, 0.8847937, 1.0], + [0.0, 1.0, 1.0], + [2.6095815, 0.91278374, 1.0], + [-0.50396746, 0.90550876, 1.0], + [-2.5619872, 0.8661648, 1.0], + [-3.3487625, 0.8425053, 1.0], + [0.0, 1.0, 1.0], + [2.2401028, 0.9033001, 1.0], + [-0.25377062, 0.8960398, 1.0], + [-2.032842, 0.87459487, 1.0], + [-2.7365766, 0.83498585, 1.0], + [0.0, 1.0, 1.0], + [1.695987, 0.8888191, 1.0], + [-0.67016447, 0.88870287, 1.0], + [-2.36245, 0.87585235, 1.0], + [-3.2576814, 0.8303152, 1.0], + [0.0, 1.0, 1.0], + [0.9194321, 0.8731023, 1.0], + [-1.3481888, 0.8760105, 1.0], + [-3.32636, 0.8514322, 1.0], + [-4.274623, 0.8039877, 1.0], + [0.0, 1.0, 1.0], + [0.23748975, 0.8785199, 1.0], + [-1.6813989, 0.8869438, 1.0], + [-4.1970234, 0.867815, 1.0], + [-4.993503, 0.8058187, 1.0], + [0.0, 1.0, 1.0], + [-0.0443294, 0.89978844, 1.0], + [-1.9890785, 0.90232074, 1.0], + [-3.950284, 0.85917366, 1.0], + [-4.1894603, 0.7918877, 1.0], + [0.0, 1.0, 1.0], + [0.29374087, 0.92003465, 1.0], + [-1.5607008, 0.90463936, 1.0], + [-2.7787287, 0.8298095, 1.0], + [-2.9107876, 0.7702355, 1.0], + [0.0, 1.0, 1.0], + [0.2690241, 0.9420806, 1.0], + [-1.3042494, 0.91745335, 1.0], + [-2.3432212, 0.8269896, 1.0], + [-2.5380266, 0.7619623, 1.0], + [0.0, 1.0, 1.0], + [0.00078135007, 0.9576381, 1.0], + [-1.0908285, 0.9226896, 1.0], + [-1.7685524, 0.83202, 1.0], + [-1.8833431, 0.7369599, 1.0], + [0.0, 1.0, 1.0], + [-0.3298981, 0.9611947, 1.0], + [-0.9030142, 0.9218993, 1.0], + [-1.1803176, 0.84522974, 1.0], + [-1.2364491, 0.7347839, 1.0], + [0.0, 1.0, 1.0], + [-0.90065134, 0.96639967, 1.0], + [-0.92589504, 0.9220938, 1.0], + [-0.7533085, 0.86246485, 1.0], + [-0.58836806, 0.7862185, 1.0], + [0.0, 1.0, 1.0], + [-1.2350203, 0.982918, 1.0], + [-0.63451254, 0.92791486, 1.0], + [-0.05854761, 0.8623331, 1.0], + [0.30643168, 0.8013406, 1.0], + [0.0, 1.0, 1.0], + [-0.6747929, 0.99744356, 1.0], + [0.28179812, 0.93459874, 1.0], + [0.98867965, 0.88537693, 1.0], + [1.5234042, 0.83812547, 1.0], + [0.0, 1.0, 1.0], + [-0.5743862, 1.0066322, 1.0], + [0.4175759, 0.9530498, 1.0], + [1.1614387, 0.9119914, 1.0], + [1.933506, 0.86427605, 1.0], + [0.0, 1.0, 1.0], + [-1.3801366, 1.0321106, 1.0], + [-0.38676104, 0.9921655, 1.0], + [0.70755553, 0.94378203, 1.0], + [1.802139, 0.8818721, 1.0], + [0.0, 1.0, 1.0], + [-2.5278633, 1.0482858, 1.0], + [-1.6080537, 1.0186095, 1.0], + [-0.4270131, 0.96137357, 1.0], + [0.52042204, 0.89067566, 1.0], + [0.0, 1.0, 1.0], + [-3.9787476, 1.078391, 1.0], + [-3.1643727, 1.050093, 1.0], + [-1.7115612, 0.9687348, 1.0], + [-0.4047129, 0.8798787, 1.0], + [0.0, 1.0, 1.0], + [-5.596516, 1.1130706, 1.0], + [-4.6923046, 1.0734227, 1.0], + [-2.8240192, 0.92865837, 1.0], + [-1.3897139, 0.8587299, 1.0], + [0.0, 1.0, 1.0], + [-7.4136, 1.1421592, 1.0], + [-6.2911453, 1.0910819, 1.0], + [-3.5203412, 0.91654646, 1.0], + [-1.5387671, 0.8988556, 1.0], + [0.0, 1.0, 1.0], + [-9.345446, 1.175205, 1.0], + [-7.8788543, 1.1139026, 1.0], + [-3.9887538, 0.95732474, 1.0], + [-0.7494897, 0.9770353, 1.0], + [0.0, 1.0, 1.0], + [-11.726303, 1.2018178, 1.0], + [-9.235658, 1.1204212, 1.0], + [-2.6193218, 0.99971265, 1.0], + [-0.17413116, 0.9981184, 1.0], + [0.0, 1.0, 1.0], + [-13.852155, 1.1776309, 1.0], + [-9.851412, 1.0741005, 1.0], + [-3.3592322, 0.99141484, 1.0], + [-0.30808955, 0.99881417, 1.0], + [0.0, 1.0, 1.0], + [-15.295475, 1.1342069, 1.0], + [-10.099276, 1.0167769, 1.0], + [-3.3911893, 0.9816512, 1.0], + [-0.22844037, 0.99877155, 1.0], + [0.0, 1.0, 1.0], + [-15.857363, 1.0911952, 1.0], + [-10.737116, 0.98477983, 1.0], + [-3.0231202, 0.9772009, 1.0], + [-0.08856626, 0.99933064, 1.0], + [0.0, 1.0, 1.0], + [-16.511763, 1.0658559, 1.0], + [-10.546561, 0.97217023, 1.0], + [-2.431429, 0.97843724, 1.0], + [-0.04837607, 0.9995322, 1.0], + [0.0, 1.0, 1.0], + [-17.32334, 1.046019, 1.0], + [-9.642773, 0.9659112, 1.0], + [-1.8824904, 0.9784988, 1.0], + [-0.039536994, 0.9995053, 1.0], + [0.0, 1.0, 1.0], + [-16.230003, 1.0588695, 1.0], + [-7.7154303, 0.98527706, 1.0], + [-1.1871514, 0.9866838, 1.0], + [-0.05562747, 0.99940675, 1.0], + [0.0, 1.0, 1.0], + [-14.798325, 1.0390636, 1.0], + [-9.137856, 0.95683104, 1.0], + [-3.1458273, 0.96849316, 1.0], + [-0.17147331, 0.99812067, 1.0], + [0.0, 1.0, 1.0], + [-15.813182, 1.0053109, 1.0], + [-10.048948, 0.94771963, 1.0], + [-3.4650283, 0.9650353, 1.0], + [-0.12416336, 0.9983432, 1.0], + [0.0, 1.0, 1.0], + [-16.047022, 0.98889244, 1.0], + [-8.209296, 0.96882516, 1.0], + [-2.0700836, 0.98336583, 1.0], + [-0.05653161, 0.9994855, 1.0], + [0.0, 1.0, 1.0], + [-16.9603, 0.97119784, 1.0], + [-8.66278, 0.9401887, 1.0], + [-2.2208483, 0.9735744, 1.0], + [-0.05832822, 0.9992916, 1.0], + [0.0, 1.0, 1.0], + [-15.93582, 0.9818394, 1.0], + [-9.735115, 0.91714615, 1.0], + [-2.8493056, 0.9654458, 1.0], + [-0.06305568, 0.99927205, 1.0], + [0.0, 1.0, 1.0], + [-14.386252, 0.98472387, 1.0], + [-8.998497, 0.9461114, 1.0], + [-2.1540806, 0.9810613, 1.0], + [-0.04269774, 0.99961257, 1.0], + [0.0, 1.0, 1.0], + [-15.852745, 0.9424422, 1.0], + [-13.541525, 0.9004132, 1.0], + [-5.0671234, 0.95313597, 1.0], + [-0.12552416, 0.99874544, 1.0], + [0.0, 1.0, 1.0], + [-17.605848, 0.8931836, 1.0], + [-11.864264, 0.9022595, 1.0], + [-3.5728223, 0.95726645, 1.0], + [-0.09980284, 0.9986085, 1.0], + [0.0, 1.0, 1.0], + [-18.33637, 0.8163921, 1.0], + [-12.252236, 0.8543749, 1.0], + [-3.4134076, 0.94611895, 1.0], + [-0.07950901, 0.9985124, 1.0], + [0.0, 1.0, 1.0], + [-19.060442, 0.7734047, 1.0], + [-15.23906, 0.8017895, 1.0], + [-5.954306, 0.91588944, 1.0], + [-0.12067752, 0.9982148, 1.0], + [0.0, 1.0, 1.0], + [-19.100754, 0.72602576, 1.0], + [-15.591848, 0.7819147, 1.0], + [-9.629234, 0.86622393, 1.0], + [-0.3522098, 0.99482995, 1.0], + [0.0, 1.0, 1.0], + [-18.568441, 0.6896306, 1.0], + [-14.766998, 0.7783395, 1.0], + [-11.441, 0.83353055, 1.0], + [-0.95255774, 0.98503613, 1.0], + [0.0, 1.0, 1.0], + [-17.873262, 0.66292804, 1.0], + [-11.700582, 0.7895474, 1.0], + [-7.4423504, 0.87738365, 1.0], + [-0.7649209, 0.9871464, 1.0], + [0.0, 1.0, 1.0], + [-15.81971, 0.65139574, 1.0], + [-9.3584585, 0.78892857, 1.0], + [-5.956205, 0.8730324, 1.0], + [-0.8720731, 0.9798331, 1.0], + [0.0, 1.0, 1.0], + [-9.644368, 0.7343603, 1.0], + [-5.3256245, 0.8688635, 1.0], + [-4.0904546, 0.905134, 1.0], + [-1.9802942, 0.942991, 1.0], + [0.0, 1.0, 1.0], + [-6.834648, 0.768598, 1.0], + [-3.7342772, 0.8917114, 1.0], + [-2.275454, 0.9256257, 1.0], + [-0.91388834, 0.94262, 1.0], + [0.0, 1.0, 1.0], + [-4.958538, 0.79897887, 1.0], + [-1.7752197, 0.92934436, 1.0], + [-0.052028436, 0.9775266, 1.0], + [1.0069743, 0.99931246, 1.0], + [0.0, 1.0, 1.0], + [-3.5435648, 0.83892524, 1.0], + [-0.45535767, 0.99585503, 1.0], + [1.1220992, 1.0467564, 1.0], + [1.688386, 1.0540297, 1.0], + [0.0, 1.0, 1.0], + [-2.584903, 0.7611296, 1.0], + [-0.29603556, 0.99536204, 1.0], + [1.0826626, 1.0670043, 1.0], + [1.5285076, 1.0699658, 1.0], + [0.0, 1.0, 1.0], + [-0.24621776, 0.6534611, 1.0], + [-0.5641967, 0.88916516, 1.0], + [-0.5208639, 1.0147351, 1.0], + [-0.17287369, 1.0311226, 1.0], + [0.0, 1.0, 1.0], + [1.6623358, 0.67162013, 1.0], + [-0.014613975, 0.90694416, 1.0], + [-0.7910066, 0.9813947, 1.0], + [-0.7020828, 0.9796464, 1.0], + [0.0, 1.0, 1.0], + [3.2134006, 0.70449984, 1.0], + [0.69320697, 0.9216747, 1.0], + [-0.55953246, 0.98736876, 1.0], + [-0.60563326, 0.9877621, 1.0], + [0.0, 1.0, 1.0], + [4.672629, 0.72619575, 1.0], + [2.541607, 0.90983087, 1.0], + [1.9858508, 0.9788352, 1.0], + [0.5622153, 1.0007318, 1.0], + [0.0, 1.0, 1.0], + [6.4891405, 0.7448458, 1.0], + [4.03939, 0.9308739, 1.0], + [3.3720996, 0.97321606, 1.0], + [1.2299676, 0.9903418, 1.0], + [0.0, 1.0, 1.0], + [7.157809, 0.77485734, 1.0], + [4.3740115, 0.9462917, 1.0], + [3.2057083, 0.98681796, 1.0], + [0.7071888, 0.997923, 1.0], + [0.0, 1.0, 1.0], + [7.228337, 0.79360247, 1.0], + [2.7674537, 0.9669471, 1.0], + [1.3489184, 1.003461, 1.0], + [0.16082476, 1.0005132, 1.0], + [0.0, 1.0, 1.0], + [6.9538918, 0.81647366, 1.0], + [3.1088212, 0.9820907, 1.0], + [1.4730394, 1.0125114, 1.0], + [0.15418816, 1.0018836, 1.0], + [0.0, 1.0, 1.0], + [7.6807427, 0.8534504, 1.0], + [3.718583, 0.99722385, 1.0], + [1.4714887, 1.0191232, 1.0], + [0.16044605, 1.0014175, 1.0], + [0.0, 1.0, 1.0], + [6.902218, 0.8794101, 1.0], + [2.855522, 0.99446875, 1.0], + [1.4610727, 1.0151895, 1.0], + [0.11758561, 1.0017471, 1.0], + [0.0, 1.0, 1.0], + [7.0196548, 0.9160186, 1.0], + [1.6999657, 1.0058115, 1.0], + [1.0062673, 1.0163534, 1.0], + [0.08308377, 1.0012561, 1.0], + [0.0, 1.0, 1.0], + [6.268169, 0.93658304, 1.0], + [1.035491, 0.99574167, 1.0], + [0.41364503, 1.0079995, 1.0], + [0.040540703, 1.0006597, 1.0], + [0.0, 1.0, 1.0], + [5.259043, 0.92028683, 1.0], + [0.23776771, 0.9797173, 1.0], + [0.07063755, 0.9973912, 1.0], + [0.066322915, 1.0000901, 1.0], + [0.0, 1.0, 1.0], + [6.786019, 0.91943693, 1.0], + [-0.12373619, 0.97899324, 1.0], + [-1.2265637, 0.9915, 1.0], + [-0.047353376, 0.9991457, 1.0], + [0.0, 1.0, 1.0], + [6.188807, 0.89457834, 1.0], + [-0.7516189, 0.9425688, 1.0], + [-1.1165174, 0.962824, 1.0], + [0.15711758, 0.99334264, 1.0], + [0.0, 1.0, 1.0], + [7.1094146, 0.89115375, 1.0], + [-0.14875633, 0.9282804, 1.0], + [-0.99777395, 0.94288254, 1.0], + [-0.13148743, 0.9870557, 1.0], + [0.0, 1.0, 1.0], + [6.441057, 0.90025866, 1.0], + [-0.48353556, 0.9186272, 1.0], + [-0.82087964, 0.9264453, 1.0], + [0.08318026, 0.9731093, 1.0], + [0.0, 1.0, 1.0], + [6.7111974, 0.91075236, 1.0], + [-0.36840874, 0.92250305, 1.0], + [-0.4315016, 0.9269158, 1.0], + [0.2359939, 0.96514857, 1.0], + [0.0, 1.0, 1.0], + [7.3763676, 0.92495906, 1.0], + [0.36888885, 0.9462593, 1.0], + [-1.5960634, 0.9405903, 1.0], + [-0.54044867, 0.9806632, 1.0], + [0.0, 1.0, 1.0], + [7.855471, 0.9369563, 1.0], + [1.1207947, 0.95559967, 1.0], + [-0.42048588, 0.9605789, 1.0], + [0.09410473, 0.9890609, 1.0], + [0.0, 1.0, 1.0], + [6.971675, 0.9428375, 1.0], + [1.4723355, 0.95907015, 1.0], + [-0.40449092, 0.9526683, 1.0], + [-0.034233358, 0.98458976, 1.0], + [0.0, 1.0, 1.0], + [6.2168164, 0.96651506, 1.0], + [0.45716965, 0.969795, 1.0], + [-1.276185, 0.95871323, 1.0], + [-0.46196827, 0.98386484, 1.0], + [0.0, 1.0, 1.0], + [1.5696334, 0.88579595, 1.0], + [-4.8797174, 0.8235008, 1.0], + [-5.7836246, 0.81873804, 1.0], + [-1.2262843, 0.9544453, 1.0], + [0.0, 1.0, 1.0], + [0.3305974, 0.86768496, 1.0], + [-4.0607495, 0.7667695, 1.0], + [-5.33198, 0.72661614, 1.0], + [-2.8205767, 0.83346885, 1.0], + [0.0, 1.0, 1.0], + [-0.10735662, 0.7961712, 1.0], + [-2.5164855, 0.746526, 1.0], + [-3.905786, 0.70082253, 1.0], + [-2.8722813, 0.7402977, 1.0], + [0.0, 1.0, 1.0], + [1.0955659, 0.7785365, 1.0], + [-0.020220347, 0.7376785, 1.0], + [-1.4722649, 0.72488576, 1.0], + [-1.4273844, 0.7533604, 1.0], + [0.0, 1.0, 1.0], + [3.1442819, 0.74805653, 1.0], + [2.5943675, 0.7122985, 1.0], + [1.4479681, 0.70843244, 1.0], + [-0.06824569, 0.74796575, 1.0], + [0.0, 1.0, 1.0], + [3.0809562, 0.7545235, 1.0], + [2.7876127, 0.7176543, 1.0], + [3.0311806, 0.6859141, 1.0], + [2.3895133, 0.7123276, 1.0], + [0.0, 1.0, 1.0], + [1.9816447, 0.78059196, 1.0], + [2.157276, 0.7259703, 1.0], + [2.2583017, 0.69609505, 1.0], + [2.6869667, 0.69099945, 1.0], + [0.0, 1.0, 1.0], + [1.1339029, 0.8082483, 1.0], + [0.9635001, 0.7438238, 1.0], + [1.7119975, 0.69262445, 1.0], + [2.8751085, 0.67264295, 1.0], + [0.0, 1.0, 1.0], + [0.7148004, 0.8236315, 1.0], + [0.15944664, 0.7895265, 1.0], + [0.8037349, 0.73090047, 1.0], + [2.2817705, 0.700999, 1.0], + [0.0, 1.0, 1.0], + [1.1721781, 0.823198, 1.0], + [0.42141455, 0.79766697, 1.0], + [1.731092, 0.72331715, 1.0], + [1.9070219, 0.70068294, 1.0], + [0.0, 1.0, 1.0], + [1.4472144, 0.82568, 1.0], + [0.7194572, 0.78889567, 1.0], + [1.2272654, 0.7160339, 1.0], + [1.3908615, 0.6934153, 1.0], + [0.0, 1.0, 1.0], + [0.9659478, 0.8547631, 1.0], + [0.23514794, 0.8048065, 1.0], + [0.6698278, 0.720812, 1.0], + [1.0716225, 0.6872872, 1.0], + [0.0, 1.0, 1.0], + [0.3646527, 0.8849565, 1.0], + [-0.07247575, 0.8116052, 1.0], + [0.0033967285, 0.72046185, 1.0], + [-0.12690859, 0.66787803, 1.0], + [0.0, 1.0, 1.0], + [0.14792149, 0.90996736, 1.0], + [-0.41231233, 0.8411673, 1.0], + [-0.4463926, 0.7333069, 1.0], + [-0.42133877, 0.6662905, 1.0], + [0.0, 1.0, 1.0], + [-0.5877023, 0.92537576, 1.0], + [-0.7669311, 0.85629356, 1.0], + [-0.6382856, 0.7815932, 1.0], + [-0.5130515, 0.72520864, 1.0], + [0.0, 1.0, 1.0], + [-0.8632642, 0.935545, 1.0], + [-0.6218721, 0.8610767, 1.0], + [-0.2218913, 0.7949077, 1.0], + [-0.026698561, 0.76374143, 1.0], + [0.0, 1.0, 1.0], + [-0.6670934, 0.95232236, 1.0], + [-0.013086532, 0.8753517, 1.0], + [0.3907465, 0.82373375, 1.0], + [0.6438301, 0.7755369, 1.0], + [0.0, 1.0, 1.0], + [-0.8246577, 0.96011007, 1.0], + [-0.022377672, 0.9073638, 1.0], + [0.5644851, 0.86494243, 1.0], + [1.0286213, 0.8235526, 1.0], + [0.0, 1.0, 1.0], + [-1.3516122, 0.97745264, 1.0], + [-0.5598576, 0.9293583, 1.0], + [0.04228649, 0.8763748, 1.0], + [-0.31584078, 0.8592634, 1.0], + [0.0, 1.0, 1.0], + [-1.9975094, 0.9925349, 1.0], + [-1.3282233, 0.9415561, 1.0], + [-0.52022403, 0.85210764, 1.0], + [-0.5763454, 0.8441975, 1.0], + [0.0, 1.0, 1.0], + [-3.0044324, 0.993117, 1.0], + [-2.32693, 0.9048576, 1.0], + [-1.0574396, 0.8305523, 1.0], + [-0.25919157, 0.86520964, 1.0], + [0.0, 1.0, 1.0], + [-2.8471406, 0.9966514, 1.0], + [-2.3174558, 0.8910772, 1.0], + [-1.6867507, 0.8372742, 1.0], + [-0.6674814, 0.9019048, 1.0], + [0.0, 1.0, 1.0], + [-2.7654781, 1.0002338, 1.0], + [-2.2775855, 0.92181945, 1.0], + [-1.6766036, 0.8592095, 1.0], + [-0.6071752, 0.91706604, 1.0], + [0.0, 1.0, 1.0], + [-3.2180955, 1.0019648, 1.0], + [-2.4868007, 0.9530428, 1.0], + [-0.8147237, 0.9659948, 1.0], + [-0.08990055, 0.99272156, 1.0], + [0.0, 1.0, 1.0], + [-2.6169603, 1.0069607, 1.0], + [-1.80826, 0.9822141, 1.0], + [-0.27318954, 0.99557036, 1.0], + [-0.0078226, 0.9997451, 1.0], + [0.0, 1.0, 1.0], + [-2.620088, 1.0105745, 1.0], + [-1.6558734, 0.99095327, 1.0], + [-0.34937736, 0.99785286, 1.0], + [-0.009377529, 0.9999541, 1.0], + [0.0, 1.0, 1.0], + [-1.9161261, 1.0054843, 1.0], + [-0.7893424, 0.9937573, 1.0], + [-0.21549447, 0.9983831, 1.0], + [-0.006033252, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-1.6860873, 1.000248, 1.0], + [-0.8517966, 0.99048835, 1.0], + [-0.1809431, 0.99742275, 1.0], + [-0.0035661473, 0.99995184, 1.0], + [0.0, 1.0, 1.0], + [-2.009457, 0.9998743, 1.0], + [-1.0168654, 0.9889097, 1.0], + [-0.14624618, 0.99738944, 1.0], + [-0.002028748, 0.99996185, 1.0], + [0.0, 1.0, 1.0], + [-1.343951, 0.9905693, 1.0], + [-0.7694108, 0.988736, 1.0], + [-0.13042787, 0.9973714, 1.0], + [-0.0019758502, 0.9999585, 1.0], + [0.0, 1.0, 1.0], + [-1.5010524, 0.9900646, 1.0], + [-0.6511199, 0.99298286, 1.0], + [-0.05297016, 0.9991895, 1.0], + [-0.0008825612, 0.99998635, 1.0], + [0.0, 1.0, 1.0], + [-1.6744328, 0.9952045, 1.0], + [-0.56495047, 0.9939041, 1.0], + [-0.072879314, 0.9991123, 1.0], + [-0.0022557385, 0.9999713, 1.0], + [0.0, 1.0, 1.0], + [-2.817171, 0.98676676, 1.0], + [-0.59550554, 0.9955451, 1.0], + [-0.10011909, 0.99888563, 1.0], + [-0.002785918, 0.9999608, 1.0], + [0.0, 1.0, 1.0], + [-4.859452, 0.9669093, 1.0], + [-0.5635375, 0.9964578, 1.0], + [-0.061867803, 0.99939847, 1.0], + [-0.001733142, 0.9999818, 1.0], + [0.0, 1.0, 1.0], + [-6.3632984, 0.9476491, 1.0], + [-0.9620065, 0.9895864, 1.0], + [-0.1275121, 0.9982268, 1.0], + [-0.0027327028, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-3.8316839, 0.9652471, 1.0], + [-1.5537338, 0.9825404, 1.0], + [-0.27127823, 0.99667627, 1.0], + [-0.0039444612, 0.9999499, 1.0], + [0.0, 1.0, 1.0], + [-2.9406507, 0.9717847, 1.0], + [-1.1054865, 0.98933583, 1.0], + [-0.15259248, 0.9985821, 1.0], + [-0.0023587716, 0.99997854, 1.0], + [0.0, 1.0, 1.0], + [-4.1605053, 0.9520869, 1.0], + [-2.1564174, 0.9791306, 1.0], + [-0.33962235, 0.9967707, 1.0], + [-0.005252303, 0.9999497, 1.0], + [0.0, 1.0, 1.0], + [-4.6772366, 0.9365187, 1.0], + [-0.94710606, 0.98695683, 1.0], + [-0.11579239, 0.9980016, 1.0], + [-0.002743927, 0.9999484, 1.0], + [0.0, 1.0, 1.0], + [-5.730075, 0.9172761, 1.0], + [-2.2775342, 0.96206695, 1.0], + [-0.38358852, 0.9927294, 1.0], + [-0.0075762877, 0.9998489, 1.0], + [0.0, 1.0, 1.0], + [-6.438858, 0.8943925, 1.0], + [-5.09395, 0.91621476, 1.0], + [-1.3278813, 0.9786671, 1.0], + [-0.026160287, 0.99958307, 1.0], + [0.0, 1.0, 1.0], + [-8.272365, 0.8478823, 1.0], + [-3.386477, 0.9320077, 1.0], + [-0.5339506, 0.9900594, 1.0], + [-0.008624411, 0.9998474, 1.0], + [0.0, 1.0, 1.0], + [-11.802094, 0.75678855, 1.0], + [-6.5117326, 0.85963863, 1.0], + [-1.7044662, 0.96734977, 1.0], + [-0.04367834, 0.9992392, 1.0], + [0.0, 1.0, 1.0], + [-15.306793, 0.6631, 1.0], + [-9.06385, 0.7773203, 1.0], + [-2.7547972, 0.9315435, 1.0], + [-0.10010718, 0.99756825, 1.0], + [0.0, 1.0, 1.0], + [-14.635688, 0.62598675, 1.0], + [-11.576207, 0.6989409, 1.0], + [-7.9337134, 0.8109356, 1.0], + [-0.6527108, 0.98375565, 1.0], + [0.0, 1.0, 1.0], + [-9.552264, 0.68059266, 1.0], + [-8.43511, 0.7298391, 1.0], + [-8.363759, 0.7697578, 1.0], + [-3.363505, 0.9041366, 1.0], + [0.0, 1.0, 1.0], + [-7.9862127, 0.70823747, 1.0], + [-6.549984, 0.7803926, 1.0], + [-6.411847, 0.8035285, 1.0], + [-5.4986544, 0.83412504, 1.0], + [0.0, 1.0, 1.0], + [-6.795965, 0.7171654, 1.0], + [-4.852691, 0.821311, 1.0], + [-4.071751, 0.8528317, 1.0], + [-2.6617806, 0.89110637, 1.0], + [0.0, 1.0, 1.0], + [-5.3123918, 0.72757715, 1.0], + [-3.218556, 0.8593564, 1.0], + [-1.1645839, 0.93264043, 1.0], + [0.039813682, 0.9652358, 1.0], + [0.0, 1.0, 1.0], + [-3.6778567, 0.6624857, 1.0], + [-1.7741417, 0.8541198, 1.0], + [-0.0029635795, 0.9721248, 1.0], + [0.40605798, 0.98661965, 1.0], + [0.0, 1.0, 1.0], + [-0.8761976, 0.6101054, 1.0], + [-0.49433082, 0.8107166, 1.0], + [-0.104019575, 0.86723596, 1.0], + [0.20899503, 0.890897, 1.0], + [0.0, 1.0, 1.0], + [2.861542, 0.67296326, 1.0], + [0.71851164, 0.8735976, 1.0], + [0.8827842, 0.8889713, 1.0], + [1.2020328, 0.8868588, 1.0], + [0.0, 1.0, 1.0], + [5.1083093, 0.72884905, 1.0], + [1.4813685, 0.8916017, 1.0], + [0.98923063, 0.90779537, 1.0], + [1.0078008, 0.9088085, 1.0], + [0.0, 1.0, 1.0], + [5.061599, 0.7781577, 1.0], + [6.068938, 0.85700047, 1.0], + [7.827683, 0.85164875, 1.0], + [3.5587494, 0.9304464, 1.0], + [0.0, 1.0, 1.0], + [5.0541573, 0.80283755, 1.0], + [5.1185517, 0.9080198, 1.0], + [6.060745, 0.91160166, 1.0], + [2.6937423, 0.9596598, 1.0], + [0.0, 1.0, 1.0], + [4.3760943, 0.8454239, 1.0], + [2.5560715, 0.9567565, 1.0], + [2.1207442, 0.9821871, 1.0], + [0.37868842, 0.99643296, 1.0], + [0.0, 1.0, 1.0], + [3.3878245, 0.8679573, 1.0], + [0.71156067, 0.96918815, 1.0], + [0.12126909, 0.99451196, 1.0], + [0.001029497, 0.9995172, 1.0], + [0.0, 1.0, 1.0], + [3.1282337, 0.8924943, 1.0], + [0.5896895, 0.9759098, 1.0], + [-0.11373672, 0.9960285, 1.0], + [-0.01838675, 0.9997154, 1.0], + [0.0, 1.0, 1.0], + [4.3400493, 0.90476185, 1.0], + [1.3310212, 0.9786638, 1.0], + [0.17455858, 0.99855655, 1.0], + [0.005317117, 0.9999772, 1.0], + [0.0, 1.0, 1.0], + [2.8491004, 0.93242216, 1.0], + [1.6193472, 0.9691039, 1.0], + [0.80294114, 0.99088013, 1.0], + [0.029686294, 0.9997005, 1.0], + [0.0, 1.0, 1.0], + [1.4401128, 0.9450957, 1.0], + [-0.16860908, 0.97805417, 1.0], + [0.08968142, 0.9966332, 1.0], + [0.0058796364, 0.9999138, 1.0], + [0.0, 1.0, 1.0], + [0.9945528, 0.91562444, 1.0], + [-1.0134145, 0.97015774, 1.0], + [-0.3692183, 0.9947299, 1.0], + [-0.007456935, 0.9999008, 1.0], + [0.0, 1.0, 1.0], + [0.055624425, 0.9145302, 1.0], + [-2.5425062, 0.9648608, 1.0], + [-1.0486484, 0.98947704, 1.0], + [-0.02597243, 0.99974424, 1.0], + [0.0, 1.0, 1.0], + [1.5285127, 0.9355288, 1.0], + [-2.3404818, 0.95899427, 1.0], + [-1.5345422, 0.98380965, 1.0], + [-0.061189, 0.9995191, 1.0], + [0.0, 1.0, 1.0], + [0.72061044, 0.92197156, 1.0], + [-5.383721, 0.90637255, 1.0], + [-3.6513355, 0.9478104, 1.0], + [-0.16841647, 0.9974061, 1.0], + [0.0, 1.0, 1.0], + [-0.028428238, 0.89694136, 1.0], + [-7.034104, 0.86039716, 1.0], + [-6.6405563, 0.88451445, 1.0], + [-0.60662454, 0.98856294, 1.0], + [0.0, 1.0, 1.0], + [-1.0993353, 0.8637165, 1.0], + [-7.684664, 0.8347192, 1.0], + [-6.927881, 0.8624654, 1.0], + [-0.7485936, 0.98060685, 1.0], + [0.0, 1.0, 1.0], + [-0.6461796, 0.892773, 1.0], + [-7.967004, 0.8294017, 1.0], + [-6.8060684, 0.8550583, 1.0], + [-0.8756036, 0.9734578, 1.0], + [0.0, 1.0, 1.0], + [0.017491894, 0.8894634, 1.0], + [-13.43903, 0.7625003, 1.0], + [-16.398203, 0.7407526, 1.0], + [-4.292457, 0.92504275, 1.0], + [0.0, 1.0, 1.0], + [1.3580081, 0.8969149, 1.0], + [-12.114263, 0.7745682, 1.0], + [-15.785243, 0.74346626, 1.0], + [-3.6444035, 0.9348789, 1.0], + [0.0, 1.0, 1.0], + [1.7327255, 0.87856543, 1.0], + [-6.4373803, 0.8177134, 1.0], + [-10.325781, 0.78358805, 1.0], + [-2.567262, 0.9422052, 1.0], + [0.0, 1.0, 1.0], + [1.7113199, 0.8661993, 1.0], + [-6.172294, 0.80224586, 1.0], + [-8.819174, 0.79800993, 1.0], + [-2.020759, 0.95133585, 1.0], + [0.0, 1.0, 1.0], + [-0.265015, 0.9794298, 1.0], + [-2.8535793, 0.9150202, 1.0], + [-2.305115, 0.93263686, 1.0], + [-0.16463748, 0.9945547, 1.0], + [0.0, 1.0, 1.0], + [-0.040856205, 0.9830346, 1.0], + [-1.0551267, 0.93554235, 1.0], + [-1.7591288, 0.8933596, 1.0], + [-0.34586504, 0.97479594, 1.0], + [0.0, 1.0, 1.0], + [0.04883148, 0.94569665, 1.0], + [-0.56813735, 0.9045709, 1.0], + [-1.5370053, 0.81085134, 1.0], + [-0.67263573, 0.8751091, 1.0], + [0.0, 1.0, 1.0], + [0.3425455, 0.9003105, 1.0], + [0.7451464, 0.8549796, 1.0], + [0.20236868, 0.8441968, 1.0], + [0.07073531, 0.86383015, 1.0], + [0.0, 1.0, 1.0], + [2.8022106, 0.80535203, 1.0], + [4.376988, 0.70769286, 1.0], + [4.8166633, 0.707951, 1.0], + [2.8132818, 0.8092864, 1.0], + [0.0, 1.0, 1.0], + [2.3843334, 0.85017675, 1.0], + [5.65875, 0.6995908, 1.0], + [8.184944, 0.62843776, 1.0], + [5.3796787, 0.7383771, 1.0], + [0.0, 1.0, 1.0], + [0.9017272, 0.9307022, 1.0], + [3.9063222, 0.765117, 1.0], + [6.974823, 0.69017345, 1.0], + [8.185112, 0.6926818, 1.0], + [0.0, 1.0, 1.0], + [0.34200728, 0.96810126, 1.0], + [2.172312, 0.84994847, 1.0], + [8.207728, 0.66688955, 1.0], + [10.739886, 0.62480503, 1.0], + [0.0, 1.0, 1.0], + [0.2027068, 0.9796112, 1.0], + [0.44695723, 0.9623203, 1.0], + [2.7991126, 0.8646282, 1.0], + [5.550562, 0.77532023, 1.0], + [0.0, 1.0, 1.0], + [0.27983877, 0.96956384, 1.0], + [0.464609, 0.9587286, 1.0], + [2.1685085, 0.8807419, 1.0], + [3.9939811, 0.8027935, 1.0], + [0.0, 1.0, 1.0], + [0.32165113, 0.96697015, 1.0], + [0.6435883, 0.93713695, 1.0], + [2.400229, 0.8268258, 1.0], + [3.7155714, 0.7548948, 1.0], + [0.0, 1.0, 1.0], + [0.13139115, 0.98465353, 1.0], + [0.26204187, 0.9602435, 1.0], + [1.2602012, 0.87366205, 1.0], + [2.4989963, 0.7911792, 1.0], + [0.0, 1.0, 1.0], + [0.04954044, 0.990862, 1.0], + [0.15550658, 0.9640937, 1.0], + [0.25354654, 0.9320879, 1.0], + [0.37589833, 0.8832426, 1.0], + [0.0, 1.0, 1.0], + [0.045748647, 0.99577636, 1.0], + [0.049044535, 0.9851228, 1.0], + [0.006159843, 0.9524085, 1.0], + [0.02588024, 0.8944849, 1.0], + [0.0, 1.0, 1.0], + [0.0030355332, 0.99710935, 1.0], + [-0.011863776, 0.9914036, 1.0], + [-0.054379225, 0.972176, 1.0], + [-0.039397053, 0.9642003, 1.0], + [0.0, 1.0, 1.0], + [-0.008150871, 0.9936041, 1.0], + [-0.037190154, 0.9887421, 1.0], + [-0.06706805, 0.96539193, 1.0], + [-0.0317106, 0.9736436, 1.0], + [0.0, 1.0, 1.0], + [-0.0109262755, 0.9985575, 1.0], + [-0.024314428, 0.99118114, 1.0], + [-0.047992434, 0.9725764, 1.0], + [-0.012975532, 0.9771226, 1.0], + [0.0, 1.0, 1.0], + [-0.011895402, 0.99871415, 1.0], + [-0.05900639, 0.9952044, 1.0], + [-0.07541064, 0.9911421, 1.0], + [-0.010232872, 0.99422073, 1.0], + [0.0, 1.0, 1.0], + [-0.014660832, 0.9972283, 1.0], + [-0.028494045, 0.9946739, 1.0], + [-0.04470135, 0.9915807, 1.0], + [-0.08405579, 0.9945791, 1.0], + [0.0, 1.0, 1.0], + [-0.033616997, 0.9969934, 1.0], + [-0.045168266, 0.993965, 1.0], + [-0.059918057, 0.98309493, 1.0], + [-0.062201336, 0.98883307, 1.0], + [0.0, 1.0, 1.0], + [-0.2381658, 0.99073696, 1.0], + [-0.47134387, 0.97534996, 1.0], + [-0.15076974, 0.978513, 1.0], + [-0.027898252, 0.9923376, 1.0], + [0.0, 1.0, 1.0], + [-0.094118536, 0.99575883, 1.0], + [-0.22728153, 0.9851976, 1.0], + [-0.14446811, 0.98079014, 1.0], + [-0.026381236, 0.9947049, 1.0], + [0.0, 1.0, 1.0], + [-0.027151769, 0.9987402, 1.0], + [-0.055821914, 0.9954352, 1.0], + [-0.07118323, 0.9875545, 1.0], + [-0.024681333, 0.9936937, 1.0], + [0.0, 1.0, 1.0], + [-0.057406988, 0.9985795, 1.0], + [-0.075127326, 0.99687934, 1.0], + [-0.012029578, 0.99905646, 1.0], + [-0.0011061644, 0.99975455, 1.0], + [0.0, 1.0, 1.0], + [-0.02290934, 0.99975824, 1.0], + [-0.025390672, 0.9995626, 1.0], + [-0.0026628294, 0.99994296, 1.0], + [-3.556243e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.017105369, 0.99992365, 1.0], + [-0.01626235, 0.9998567, 1.0], + [-0.0023494267, 0.9999825, 1.0], + [-3.9371847e-05, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.01157836, 0.999962, 1.0], + [-0.002812421, 0.99997854, 1.0], + [-0.0007163898, 0.9999961, 1.0], + [-1.7496699e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.014018655, 0.99996704, 1.0], + [-0.002332822, 0.9999732, 1.0], + [-0.0003527423, 0.99999386, 1.0], + [-5.771106e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.010408213, 0.99997354, 1.0], + [-0.0040600216, 0.99995077, 1.0], + [-0.0005498676, 0.999989, 1.0], + [-7.1796394e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.0023898028, 0.9999025, 1.0], + [-0.0021971867, 0.99995613, 1.0], + [-0.00040569165, 0.99999076, 1.0], + [-5.7337793e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.006176083, 0.99988633, 1.0], + [-0.0033466935, 0.99995404, 1.0], + [-0.00018771198, 0.99999684, 1.0], + [-1.5582656e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.0078279935, 0.99997926, 1.0], + [-0.0021727988, 0.99997437, 1.0], + [-8.390261e-05, 0.9999989, 1.0], + [-7.6212564e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.011409035, 0.9999373, 1.0], + [-0.0012485707, 0.99999255, 1.0], + [-5.873547e-05, 0.99999946, 1.0], + [-1.1412731e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.0320137, 0.9996883, 1.0], + [-0.0022234167, 0.99998236, 1.0], + [-4.2978718e-05, 0.99999964, 1.0], + [-8.9091026e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.060883675, 0.99925774, 1.0], + [-0.0041872207, 0.9999481, 1.0], + [-0.00016332162, 0.99999785, 1.0], + [-2.8002216e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.026953986, 0.999662, 1.0], + [-0.00503322, 0.9999406, 1.0], + [-0.0005006954, 0.9999944, 1.0], + [-6.1892397e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.019095117, 0.9997323, 1.0], + [-0.0048538097, 0.9999398, 1.0], + [-0.00037457192, 0.9999961, 1.0], + [-3.6742229e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.03751212, 0.9993693, 1.0], + [-0.010522507, 0.99987054, 1.0], + [-0.0009128494, 0.99999034, 1.0], + [-7.887352e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.05509656, 0.99895823, 1.0], + [-0.005975121, 0.99990004, 1.0], + [-0.00018607976, 0.9999967, 1.0], + [-2.4801245e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.06763289, 0.9990122, 1.0], + [-0.026763257, 0.99962497, 1.0], + [-0.0033132737, 0.9999459, 1.0], + [-5.9110884e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.06290056, 0.9987752, 1.0], + [-0.06590056, 0.9988833, 1.0], + [-0.016112784, 0.9997368, 1.0], + [-0.00030407612, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-0.06050088, 0.9985156, 1.0], + [-0.017029036, 0.99951345, 1.0], + [-0.0026492565, 0.9999436, 1.0], + [-4.364142e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-0.21668494, 0.99283016, 1.0], + [-0.19338678, 0.9937941, 1.0], + [-0.029703936, 0.99916244, 1.0], + [-0.00055253773, 0.9999868, 1.0], + [0.0, 1.0, 1.0], + [-1.0463094, 0.95176995, 1.0], + [-1.3973116, 0.9536363, 1.0], + [-0.50282496, 0.98618114, 1.0], + [-0.019256245, 0.9995079, 1.0], + [0.0, 1.0, 1.0], + [-3.5435355, 0.84623945, 1.0], + [-5.1535788, 0.84832674, 1.0], + [-2.824056, 0.9316529, 1.0], + [-0.15195742, 0.9963698, 1.0], + [0.0, 1.0, 1.0], + [-8.673897, 0.7131587, 1.0], + [-12.270074, 0.68430734, 1.0], + [-11.376253, 0.73719627, 1.0], + [-1.8281991, 0.95513356, 1.0], + [0.0, 1.0, 1.0], + [-12.039047, 0.6432662, 1.0], + [-13.724231, 0.6535759, 1.0], + [-14.0326, 0.6799867, 1.0], + [-9.946063, 0.780004, 1.0], + [0.0, 1.0, 1.0], + [-12.528839, 0.6221673, 1.0], + [-13.425968, 0.65002877, 1.0], + [-13.075199, 0.67871696, 1.0], + [-10.942493, 0.7442244, 1.0], + [0.0, 1.0, 1.0], + [-10.306666, 0.6389583, 1.0], + [-12.0551195, 0.66896164, 1.0], + [-9.823601, 0.7308576, 1.0], + [-5.281826, 0.83818936, 1.0], + [0.0, 1.0, 1.0], + [-5.3867707, 0.65209216, 1.0], + [-5.8347864, 0.71964204, 1.0], + [-3.59081, 0.8329669, 1.0], + [-1.4847257, 0.9156909, 1.0], + [0.0, 1.0, 1.0], + [-1.754784, 0.74886227, 1.0], + [-0.24797672, 0.74751395, 1.0], + [0.6299296, 0.7613501, 1.0], + [0.82416815, 0.8632212, 1.0], + [0.0, 1.0, 1.0], + [1.4033656, 0.9101428, 1.0], + [2.4547875, 0.81642723, 1.0], + [3.6370852, 0.787311, 1.0], + [3.6531272, 0.8031764, 1.0], + [0.0, 1.0, 1.0], + [1.9459922, 0.951957, 1.0], + [2.5553524, 0.8894862, 1.0], + [3.6345732, 0.81872904, 1.0], + [2.785436, 0.8552696, 1.0], + [0.0, 1.0, 1.0], + [0.679637, 0.9787555, 1.0], + [3.8709927, 0.9172711, 1.0], + [5.7068896, 0.8849159, 1.0], + [1.1118813, 0.9758798, 1.0], + [0.0, 1.0, 1.0], + [0.3420551, 0.98117673, 1.0], + [1.5517482, 0.967525, 1.0], + [2.653233, 0.95270234, 1.0], + [0.44305915, 0.9921006, 1.0], + [0.0, 1.0, 1.0], + [0.23155965, 0.9924181, 1.0], + [0.11397146, 0.99741286, 1.0], + [0.13187227, 0.9980809, 1.0], + [0.020392587, 0.9996969, 1.0], + [0.0, 1.0, 1.0], + [0.13352406, 0.99550617, 1.0], + [0.025518132, 0.99892914, 1.0], + [-0.0005152045, 0.99990207, 1.0], + [-0.000118601536, 0.99999446, 1.0], + [0.0, 1.0, 1.0], + [0.15706417, 0.99707514, 1.0], + [0.02578307, 0.9993154, 1.0], + [-0.00202729, 0.9999331, 1.0], + [-0.00021436776, 0.9999964, 1.0], + [0.0, 1.0, 1.0], + [0.3615966, 0.99366856, 1.0], + [0.087646805, 0.99867254, 1.0], + [0.002637883, 0.99996406, 1.0], + [3.6361645e-05, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [0.1291499, 0.9977887, 1.0], + [0.046300918, 0.99929285, 1.0], + [0.012817254, 0.9998544, 1.0], + [0.00041612334, 0.9999955, 1.0], + [0.0, 1.0, 1.0], + [0.011200566, 0.9983723, 1.0], + [-0.008214572, 0.99942863, 1.0], + [0.0021838315, 0.9999416, 1.0], + [0.000100621306, 0.9999986, 1.0], + [0.0, 1.0, 1.0], + [-0.060381968, 0.9930565, 1.0], + [-0.06464544, 0.9983384, 1.0], + [-0.007711393, 0.99990016, 1.0], + [-0.00011417703, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.11693042, 0.99224305, 1.0], + [-0.11525121, 0.9982498, 1.0], + [-0.027416473, 0.99979055, 1.0], + [-0.00054621015, 0.99999607, 1.0], + [0.0, 1.0, 1.0], + [0.014975694, 0.99728876, 1.0], + [-0.056130975, 0.9989796, 1.0], + [-0.0222607, 0.9997437, 1.0], + [-0.00058228534, 0.9999938, 1.0], + [0.0, 1.0, 1.0], + [0.05060913, 0.99668014, 1.0], + [-0.2700596, 0.99520206, 1.0], + [-0.14083444, 0.9978208, 1.0], + [-0.005215654, 0.9999189, 1.0], + [0.0, 1.0, 1.0], + [-0.05793456, 0.99310505, 1.0], + [-1.3339288, 0.97664934, 1.0], + [-1.0737243, 0.98243177, 1.0], + [-0.06070526, 0.9990094, 1.0], + [0.0, 1.0, 1.0], + [-0.22500885, 0.9885798, 1.0], + [-2.394239, 0.9580366, 1.0], + [-1.8970352, 0.9690168, 1.0], + [-0.109246016, 0.99819875, 1.0], + [0.0, 1.0, 1.0], + [-0.39491686, 0.98909, 1.0], + [-2.9813097, 0.9401541, 1.0], + [-2.3468347, 0.9552055, 1.0], + [-0.15643519, 0.99701655, 1.0], + [0.0, 1.0, 1.0], + [-1.8292645, 0.9594883, 1.0], + [-13.511781, 0.763661, 1.0], + [-14.536935, 0.7543218, 1.0], + [-2.2238204, 0.95809615, 1.0], + [0.0, 1.0, 1.0], + [-1.0053811, 0.97643167, 1.0], + [-12.578439, 0.77343625, 1.0], + [-15.1597395, 0.73498416, 1.0], + [-2.625323, 0.9481359, 1.0], + [0.0, 1.0, 1.0], + [-0.55154705, 0.9794314, 1.0], + [-6.9208384, 0.85034823, 1.0], + [-10.053682, 0.79368883, 1.0], + [-1.6742866, 0.96229243, 1.0], + [0.0, 1.0, 1.0], + [-0.69403476, 0.96975285, 1.0], + [-3.2779868, 0.9127137, 1.0], + [-4.176942, 0.9027185, 1.0], + [-0.5743538, 0.98639596, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 260068d1c..56cecdf39 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -32,7 +32,8 @@ use lightcraft_raw::{ /// 5: dark JPEG luminance pairs for the camera toe (#1307). /// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 7: the bundled NIKON Z 6_2 profile. -pub const LOOK_VERSION: u32 = 7; +/// 8: the bundled NIKON Z 8 and NIKON D5300 profiles. +pub const LOOK_VERSION: u32 = 8; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -2501,14 +2502,14 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } - /// The public Z 6II NEF (skipped without the corpus) takes its colour from the bundled Z 6II profile. It wasn't - /// one of the photos the profile was fitted to. - #[test] - fn corpus_z6ii_uses_the_bundled_profile() { + /// A public corpus raw of `model` (skipped without the corpus) takes its colour from that model's bundled profile: + /// its fit is accepted and uses exactly the profile's matrix and table. + fn assert_corpus_raw_uses_the_bundled_profile(file: &str, model: &str) { let path = std::env::var_os("LIGHTCRAFT_CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/nef-nikon-z6ii-lossless.nef"); + .join("raw") + .join(file); let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -2517,12 +2518,12 @@ mod tests { } Err(e) => panic!("{}: {e}", path.display()), }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "NIKON Z 6_2").expect("the Z 6II profile is built in"); + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the profile is built in"); let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get("NIKON Z 6_2").expect("Z 6II profile"); - assert_eq!(*used, bundled, "a local NIKON_Z_6_2 profile overrides the built-in one; remove it to run this test"); + let used = crate::camera_profiles::get(model).expect("a profile"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some("NIKON Z 6_2")); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); @@ -2534,6 +2535,18 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + /// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z6ii_uses_the_bundled_profile() { + assert_corpus_raw_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); + } + + /// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z8_uses_the_bundled_profile() { + assert_corpus_raw_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 41fc9ca2f..efbf3506a 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), + ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 5ca637774..106879fa2 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -113,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -132,6 +132,12 @@ For validation every fifth photo in sorted name order (59) was held back and a p The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture control with the Z 24-200, not used for fitting) is accepted with the profile and goes from 5.37 to 5.25. +### Nikon Z 8 + +Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023 and 2026, almost all Standard picture control, lossless 14-bit, with the NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the High Efficiency files can't be decoded and were skipped), 969,860 colour pairs, collected the same way as the Z 6II's with Nikon's distortion correction read. Holding back every fifth photo (55) and fitting the other four fifths, mean ΔE on the held-back photos went from 2.55 to 2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by 0.46. The public raw.pixls.us Z 8 file (`nef-nikon-z8-lossless.nef`, Z 14-24 f/2.8 S, not used for fitting) is accepted with the profile and stays about the same, 3.22 to 3.27. + +The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0e1a7bb49..c7b15805a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) and NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -817,6 +817,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 8 camera profile; status unchanged (🟡) | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 19edcfc06..9e6f5fea9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,8 +2,8 @@ ## 2026-10-14 -### Nikon Z 6II colour profile -- Raws from the Nikon Z 6II now start from a colour profile built from a few hundred Z 6II photos, the same way the +### Nikon Z 6II and Z 8 colour profiles +- Raws from the Nikon Z 6II and Z 8 now start from a colour profile built from a few hundred photos from each camera, the same way the Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo shows only a little of come out closer to the camera's JPEG. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 365cd8130..fce4e9355 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -652,6 +652,12 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", ), + ( + // Z 8, NIKKOR Z 14-24 f/2.8 S (not used to fit the bundled Z 8 profile) + "nef-nikon-z8-lossless.nef", + "https://raw.pixls.us/data/Nikon/Z%208/Nikon_Z8_raw_14_bit_lossless_compression.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", From 975b08c334bb6c4482c4bc1416144df5646678e0 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 10:57:50 -0500 Subject: [PATCH 162/315] LR-PROF-CAMERACOLOR: Add a bundled Nikon Z 6II camera profile Fitted with lightcraft-cli calibrate to 296 of my own Z 6II photos. --- README.md | 2 +- README.zh-hans.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/NIKON_Z_6_2.json | 1819 +++++++++++++++++++++++ crates/engine/src/camera_preview.rs | 36 +- crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 10 +- docs/parity-checklist.md | 3 +- docs/whats-new.md | 5 + xtask/src/main.rs | 6 + 10 files changed, 1880 insertions(+), 5 deletions(-) create mode 100644 assets/camera-profiles/NIKON_Z_6_2.json diff --git a/README.md b/README.md index f041111a1..3e93b41ee 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4 and Nikon Z 6II profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 0a7feb32c..5183d40a8 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4 和尼康 Z 6II 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..a5e1716d7 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -14,6 +14,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | +| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/camera-profiles/NIKON_Z_6_2.json b/assets/camera-profiles/NIKON_Z_6_2.json new file mode 100644 index 000000000..b00e045c1 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_6_2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 6_2", + "files": 296, + "samples": 1099207, + "matrix": [ + [1.182910144251291, -0.29205257488744785, 0.10076600427689364], + [-0.06715969465291372, 1.185204734495203, -0.11541627485450512], + [-0.04243207284624334, -0.8237200432609093, 1.8732041320081247] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [6.2229915, 1.153762, 1.0], + [3.9428349, 1.1697679, 1.0], + [2.9512422, 1.1150318, 1.0], + [1.4521159, 1.0443524, 1.0], + [0.0, 1.0, 1.0], + [6.067863, 1.1530151, 1.0], + [2.674163, 1.1492246, 1.0], + [1.6906267, 1.0755203, 1.0], + [1.0559876, 1.0193381, 1.0], + [0.0, 1.0, 1.0], + [6.406609, 1.1438, 1.0], + [2.0468237, 1.126483, 1.0], + [0.6318166, 1.0328915, 1.0], + [0.38942862, 0.9951044, 1.0], + [0.0, 1.0, 1.0], + [5.6572795, 1.1365504, 1.0], + [1.088494, 1.1316147, 1.0], + [-0.7465848, 1.038763, 1.0], + [-1.7078602, 0.9750448, 1.0], + [0.0, 1.0, 1.0], + [5.6649947, 1.1077542, 1.0], + [0.8204385, 1.1192347, 1.0], + [-1.5888842, 1.0491564, 1.0], + [-3.0090246, 0.96298206, 1.0], + [0.0, 1.0, 1.0], + [5.256699, 1.0831563, 1.0], + [0.6007586, 1.0977919, 1.0], + [-2.570202, 1.0551977, 1.0], + [-5.4068832, 0.9654456, 1.0], + [0.0, 1.0, 1.0], + [4.539097, 1.0591519, 1.0], + [-0.10774251, 1.0788635, 1.0], + [-4.641281, 1.0305448, 1.0], + [-8.283271, 0.93709576, 1.0], + [0.0, 1.0, 1.0], + [4.142809, 1.0373175, 1.0], + [-0.8308222, 1.0613977, 1.0], + [-5.655344, 1.0214658, 1.0], + [-9.607277, 0.93629843, 1.0], + [0.0, 1.0, 1.0], + [3.8500762, 1.0362741, 1.0], + [-1.0345856, 1.0639124, 1.0], + [-5.4233723, 1.0284288, 1.0], + [-9.0873, 0.9531294, 1.0], + [0.0, 1.0, 1.0], + [4.0558853, 1.0385702, 1.0], + [-0.52777964, 1.0754012, 1.0], + [-4.6981053, 1.0426258, 1.0], + [-7.390084, 0.97722876, 1.0], + [0.0, 1.0, 1.0], + [4.439531, 1.0652789, 1.0], + [0.86604506, 1.0861824, 1.0], + [-2.8576968, 1.0505832, 1.0], + [-5.8478637, 0.9746698, 1.0], + [0.0, 1.0, 1.0], + [4.3485656, 1.0967733, 1.0], + [1.9174945, 1.1038177, 1.0], + [0.058721844, 1.0518842, 1.0], + [-1.5929213, 0.9253342, 1.0], + [0.0, 1.0, 1.0], + [3.6591005, 1.1211772, 1.0], + [2.2590137, 1.1025198, 1.0], + [1.1305307, 1.0139761, 1.0], + [-0.1372095, 0.87818533, 1.0], + [0.0, 1.0, 1.0], + [2.6356182, 1.1266937, 1.0], + [1.7848063, 1.0862155, 1.0], + [1.2136673, 0.99129325, 1.0], + [0.49023673, 0.8714401, 1.0], + [0.0, 1.0, 1.0], + [1.4648303, 1.118881, 1.0], + [0.9687011, 1.0753107, 1.0], + [0.7960804, 0.99398947, 1.0], + [0.7395835, 0.8858062, 1.0], + [0.0, 1.0, 1.0], + [0.44883335, 1.1299157, 1.0], + [0.61828953, 1.0732124, 1.0], + [0.9684744, 0.99130034, 1.0], + [1.4130936, 0.9186135, 1.0], + [0.0, 1.0, 1.0], + [0.40853867, 1.140463, 1.0], + [0.99736977, 1.0603918, 1.0], + [1.5362628, 0.9824557, 1.0], + [2.16631, 0.9292637, 1.0], + [0.0, 1.0, 1.0], + [0.20395991, 1.1486486, 1.0], + [0.8272985, 1.0560744, 1.0], + [1.4990557, 0.9908201, 1.0], + [2.295185, 0.93907094, 1.0], + [0.0, 1.0, 1.0], + [-0.57153416, 1.1716017, 1.0], + [0.24325375, 1.0848265, 1.0], + [1.1403633, 1.0200775, 1.0], + [2.1166058, 0.95587236, 1.0], + [0.0, 1.0, 1.0], + [-1.7980459, 1.2015567, 1.0], + [-0.9087, 1.1328977, 1.0], + [0.12065623, 1.0612612, 1.0], + [1.1421617, 0.9739673, 1.0], + [0.0, 1.0, 1.0], + [-3.3530312, 1.227411, 1.0], + [-2.557493, 1.1682405, 1.0], + [-1.6862918, 1.0950687, 1.0], + [-0.6257788, 0.9961956, 1.0], + [0.0, 1.0, 1.0], + [-5.1431966, 1.2525787, 1.0], + [-4.3500066, 1.1942875, 1.0], + [-3.3942263, 1.1264504, 1.0], + [-0.860235, 1.0129987, 1.0], + [0.0, 1.0, 1.0], + [-7.172523, 1.2782786, 1.0], + [-6.2652287, 1.2184532, 1.0], + [-5.2175336, 1.1654094, 1.0], + [-0.90600926, 1.02157, 1.0], + [0.0, 1.0, 1.0], + [-9.301277, 1.3098416, 1.0], + [-8.319625, 1.254189, 1.0], + [-6.29361, 1.1807519, 1.0], + [-0.2820965, 1.0070943, 1.0], + [0.0, 1.0, 1.0], + [-11.833535, 1.3462554, 1.0], + [-10.534334, 1.2848289, 1.0], + [-5.5140915, 1.1255784, 1.0], + [-0.11413354, 1.0005571, 1.0], + [0.0, 1.0, 1.0], + [-13.837044, 1.3168585, 1.0], + [-11.592036, 1.241745, 1.0], + [-3.8057983, 1.0537387, 1.0], + [-0.12250334, 0.9994377, 1.0], + [0.0, 1.0, 1.0], + [-15.302334, 1.2547938, 1.0], + [-12.304744, 1.171094, 1.0], + [-2.6331367, 1.0254496, 1.0], + [-0.02462376, 1.0000972, 1.0], + [0.0, 1.0, 1.0], + [-16.3649, 1.2033144, 1.0], + [-13.840853, 1.121017, 1.0], + [-4.7440042, 1.016522, 1.0], + [-0.09164992, 1.0001389, 1.0], + [0.0, 1.0, 1.0], + [-17.23536, 1.1622376, 1.0], + [-14.924817, 1.0792886, 1.0], + [-6.874241, 1.0002234, 1.0], + [-0.19945428, 0.9995216, 1.0], + [0.0, 1.0, 1.0], + [-17.24167, 1.1120819, 1.0], + [-13.978684, 1.0264746, 1.0], + [-5.8763328, 0.98856574, 1.0], + [-0.13876384, 0.999489, 1.0], + [0.0, 1.0, 1.0], + [-18.172258, 1.0878747, 1.0], + [-15.457481, 1.0080547, 1.0], + [-6.565444, 0.9856226, 1.0], + [-0.13294418, 0.9996134, 1.0], + [0.0, 1.0, 1.0], + [-16.83038, 1.0693645, 1.0], + [-15.625549, 0.9914287, 1.0], + [-7.72421, 0.9804178, 1.0], + [-0.15811352, 0.9995089, 1.0], + [0.0, 1.0, 1.0], + [-16.11028, 1.0584325, 1.0], + [-14.346652, 0.99943095, 1.0], + [-6.310089, 0.9858199, 1.0], + [-0.109850876, 0.9996651, 1.0], + [0.0, 1.0, 1.0], + [-14.929288, 1.0361999, 1.0], + [-12.50551, 0.9834405, 1.0], + [-5.2874904, 0.98251873, 1.0], + [-0.0921156, 0.99959683, 1.0], + [0.0, 1.0, 1.0], + [-14.727734, 1.0360364, 1.0], + [-12.03307, 0.9717191, 1.0], + [-5.2304645, 0.97766334, 1.0], + [-0.07621693, 0.9996011, 1.0], + [0.0, 1.0, 1.0], + [-15.998532, 1.0374138, 1.0], + [-13.555987, 0.9710816, 1.0], + [-6.071474, 0.97758615, 1.0], + [-0.088387944, 0.9996019, 1.0], + [0.0, 1.0, 1.0], + [-16.750172, 1.027443, 1.0], + [-14.493484, 0.9749124, 1.0], + [-5.9644837, 0.9815559, 1.0], + [-0.09535428, 0.9994739, 1.0], + [0.0, 1.0, 1.0], + [-17.231142, 0.9994733, 1.0], + [-14.346948, 0.95981574, 1.0], + [-6.037715, 0.9591468, 1.0], + [-0.38386485, 0.9952976, 1.0], + [0.0, 1.0, 1.0], + [-17.424784, 0.9555782, 1.0], + [-14.198873, 0.9357824, 1.0], + [-6.6123714, 0.94408447, 1.0], + [-0.4532846, 0.9941679, 1.0], + [0.0, 1.0, 1.0], + [-17.279493, 0.90192854, 1.0], + [-13.564478, 0.90127003, 1.0], + [-6.147515, 0.93834627, 1.0], + [-0.2323653, 0.9971496, 1.0], + [0.0, 1.0, 1.0], + [-17.093803, 0.85399765, 1.0], + [-12.756323, 0.87821823, 1.0], + [-6.1378803, 0.92416245, 1.0], + [-0.26953444, 0.9962813, 1.0], + [0.0, 1.0, 1.0], + [-16.701775, 0.8148734, 1.0], + [-11.737119, 0.86213005, 1.0], + [-4.978892, 0.925974, 1.0], + [-0.2315494, 0.9961441, 1.0], + [0.0, 1.0, 1.0], + [-16.116898, 0.79390544, 1.0], + [-11.100497, 0.8544334, 1.0], + [-3.051835, 0.9431366, 1.0], + [-0.095544234, 0.99742615, 1.0], + [0.0, 1.0, 1.0], + [-15.288991, 0.78051233, 1.0], + [-9.944976, 0.8618912, 1.0], + [-2.3829138, 0.9570897, 1.0], + [-0.033786688, 0.9981832, 1.0], + [0.0, 1.0, 1.0], + [-12.088607, 0.799739, 1.0], + [-6.9067097, 0.9043021, 1.0], + [-2.2045915, 0.98223805, 1.0], + [-0.04008953, 0.9986963, 1.0], + [0.0, 1.0, 1.0], + [-8.372293, 0.8433635, 1.0], + [-3.43054, 0.96173584, 1.0], + [-0.43066922, 1.0183202, 1.0], + [0.19982383, 1.0013425, 1.0], + [0.0, 1.0, 1.0], + [-7.0979204, 0.87468654, 1.0], + [-2.605678, 0.9790638, 1.0], + [0.7438621, 1.0240322, 1.0], + [0.90940267, 1.007664, 1.0], + [0.0, 1.0, 1.0], + [-7.001, 0.881765, 1.0], + [-1.34574, 1.0225254, 1.0], + [1.5278748, 1.0623524, 1.0], + [1.7761368, 1.0346187, 1.0], + [0.0, 1.0, 1.0], + [-6.164434, 0.8987546, 1.0], + [-0.2458219, 1.0717543, 1.0], + [1.9966408, 1.1075406, 1.0], + [2.03719, 1.0744351, 1.0], + [0.0, 1.0, 1.0], + [-5.634933, 0.86535674, 1.0], + [-1.0753672, 1.0728083, 1.0], + [1.0631428, 1.1154276, 1.0], + [0.6964589, 1.0430381, 1.0], + [0.0, 1.0, 1.0], + [-2.4912696, 0.92214453, 1.0], + [-0.93249446, 1.0711535, 1.0], + [-0.41212073, 1.0973893, 1.0], + [-0.1942435, 1.0235159, 1.0], + [0.0, 1.0, 1.0], + [-0.8377413, 1.0034848, 1.0], + [-0.45683056, 1.0996051, 1.0], + [-0.7544672, 1.0989084, 1.0], + [-0.3452393, 1.0226208, 1.0], + [0.0, 1.0, 1.0], + [-0.1365077, 1.0087785, 1.0], + [0.0327875, 1.0931748, 1.0], + [0.03409552, 1.0605692, 1.0], + [-0.08136579, 1.0068369, 1.0], + [0.0, 1.0, 1.0], + [1.215217, 0.95414764, 1.0], + [1.6130612, 1.0335056, 1.0], + [0.07605914, 1.0273331, 1.0], + [-0.20885852, 1.003798, 1.0], + [0.0, 1.0, 1.0], + [2.3572938, 0.92316496, 1.0], + [2.6421683, 1.0025958, 1.0], + [0.9645612, 1.0110464, 1.0], + [0.10840102, 1.0010912, 1.0], + [0.0, 1.0, 1.0], + [3.770162, 0.91984165, 1.0], + [3.5076504, 0.99966085, 1.0], + [1.6117474, 1.0087022, 1.0], + [0.47452623, 0.99906003, 1.0], + [0.0, 1.0, 1.0], + [3.279187, 0.9259939, 1.0], + [2.1051095, 1.0003723, 1.0], + [0.7558728, 1.0085037, 1.0], + [0.4204648, 0.999157, 1.0], + [0.0, 1.0, 1.0], + [3.1316261, 0.9648295, 1.0], + [1.2332041, 1.0157939, 1.0], + [0.23060545, 1.0078584, 1.0], + [0.014259016, 1.0006881, 1.0], + [0.0, 1.0, 1.0], + [3.3989656, 0.99194896, 1.0], + [2.2900262, 1.03556, 1.0], + [1.1375499, 1.013088, 1.0], + [0.113812156, 1.0003917, 1.0], + [0.0, 1.0, 1.0], + [4.0414777, 1.0378546, 1.0], + [2.3057628, 1.1105787, 1.0], + [1.6360228, 1.0446037, 1.0], + [0.24664895, 1.0011342, 1.0], + [0.0, 1.0, 1.0], + [4.806446, 1.0598252, 1.0], + [2.9564526, 1.1106843, 1.0], + [0.9111372, 1.0506293, 1.0], + [-0.017489238, 1.0016158, 1.0], + [0.0, 1.0, 1.0], + [4.3138623, 1.0589161, 1.0], + [2.0492713, 1.1007633, 1.0], + [1.2071747, 1.0373629, 1.0], + [0.13990282, 1.0014288, 1.0], + [0.0, 1.0, 1.0], + [4.150446, 1.0142206, 1.0], + [2.3013182, 1.0552701, 1.0], + [1.7414474, 1.0204091, 1.0], + [0.2009622, 1.0014528, 1.0], + [0.0, 1.0, 1.0], + [5.0740504, 0.9837976, 1.0], + [2.0655751, 1.0382036, 1.0], + [0.7234968, 1.018726, 1.0], + [-0.1094017, 1.0020746, 1.0], + [0.0, 1.0, 1.0], + [5.1731286, 0.99559367, 1.0], + [2.5818305, 1.019053, 1.0], + [1.2261839, 1.0031092, 1.0], + [0.17011318, 1.0003996, 1.0], + [0.0, 1.0, 1.0], + [4.2959146, 1.0069213, 1.0], + [1.4468756, 1.0213524, 1.0], + [0.6366564, 1.0068634, 1.0], + [0.06898734, 1.0005128, 1.0], + [0.0, 1.0, 1.0], + [6.0099535, 1.005978, 1.0], + [2.4273057, 1.0169591, 1.0], + [-0.13818303, 1.0067599, 1.0], + [-0.0033633148, 1.0006784, 1.0], + [0.0, 1.0, 1.0], + [7.5230107, 1.0331137, 1.0], + [4.7096148, 1.0529838, 1.0], + [1.6526409, 1.0215665, 1.0], + [0.09062616, 1.0016879, 1.0], + [0.0, 1.0, 1.0], + [8.177528, 1.0500778, 1.0], + [5.78432, 1.0727233, 1.0], + [4.1867056, 1.0556285, 1.0], + [0.946918, 1.0117143, 1.0], + [0.0, 1.0, 1.0], + [7.787752, 1.0514582, 1.0], + [6.581295, 1.0929804, 1.0], + [5.1642475, 1.0660969, 1.0], + [1.3973913, 1.0133713, 1.0], + [0.0, 1.0, 1.0], + [7.352549, 1.0832101, 1.0], + [6.488305, 1.1071424, 1.0], + [4.7150044, 1.0691316, 1.0], + [0.9782577, 1.0079756, 1.0], + [0.0, 1.0, 1.0], + [7.44746, 1.1423436, 1.0], + [5.2333174, 1.1424847, 1.0], + [4.2587075, 1.0928718, 1.0], + [1.1760778, 1.0190315, 1.0], + [0.0, 1.0, 1.0], + [4.7819715, 1.0929513, 1.0], + [3.038743, 1.1225566, 1.0], + [3.0403032, 1.0947937, 1.0], + [2.7168245, 1.0584737, 1.0], + [0.0, 1.0, 1.0], + [4.2442985, 1.0740716, 1.0], + [1.8085717, 1.092445, 1.0], + [1.6521245, 1.0550505, 1.0], + [1.2793007, 1.015843, 1.0], + [0.0, 1.0, 1.0], + [3.8161194, 1.048953, 1.0], + [1.0271565, 1.0645669, 1.0], + [0.37108362, 1.0149966, 1.0], + [0.16207772, 0.98473, 1.0], + [0.0, 1.0, 1.0], + [2.9961936, 1.0367951, 1.0], + [0.3735622, 1.0575669, 1.0], + [-1.3138187, 0.9924495, 1.0], + [-2.2027087, 0.9511259, 1.0], + [0.0, 1.0, 1.0], + [3.2774894, 1.0240017, 1.0], + [0.48561475, 1.0381898, 1.0], + [-1.8494679, 0.98786247, 1.0], + [-3.2887905, 0.9338637, 1.0], + [0.0, 1.0, 1.0], + [3.4378223, 1.0108881, 1.0], + [0.52244306, 1.0189863, 1.0], + [-1.8643115, 0.99323344, 1.0], + [-5.1015615, 0.92938703, 1.0], + [0.0, 1.0, 1.0], + [3.1067812, 0.9985392, 1.0], + [-0.12131784, 1.0145019, 1.0], + [-4.1442766, 0.986398, 1.0], + [-8.339385, 0.9153509, 1.0], + [0.0, 1.0, 1.0], + [2.4298422, 0.96142745, 1.0], + [-1.1118385, 1.0061582, 1.0], + [-5.695069, 0.9836191, 1.0], + [-9.741408, 0.91440827, 1.0], + [0.0, 1.0, 1.0], + [2.01661, 0.9458497, 1.0], + [-1.662954, 0.999966, 1.0], + [-5.8223696, 0.9850643, 1.0], + [-9.232703, 0.9267809, 1.0], + [0.0, 1.0, 1.0], + [2.2962167, 0.9605364, 1.0], + [-1.1373878, 1.0126402, 1.0], + [-4.8479958, 0.99816114, 1.0], + [-7.4450336, 0.9406932, 1.0], + [0.0, 1.0, 1.0], + [3.036456, 0.99115914, 1.0], + [0.17963961, 1.0285513, 1.0], + [-3.3088403, 1.0056642, 1.0], + [-6.273956, 0.93629354, 1.0], + [0.0, 1.0, 1.0], + [3.0664618, 1.026951, 1.0], + [1.2165159, 1.0476551, 1.0], + [-0.42700893, 0.9974062, 1.0], + [-1.4580182, 0.8679089, 1.0], + [0.0, 1.0, 1.0], + [2.7013292, 1.0606134, 1.0], + [1.5602528, 1.0500852, 1.0], + [0.58767015, 0.96606606, 1.0], + [-0.12461443, 0.83817023, 1.0], + [0.0, 1.0, 1.0], + [1.75421, 1.0628642, 1.0], + [1.077012, 1.0321119, 1.0], + [0.81344783, 0.9464788, 1.0], + [0.4725721, 0.8391105, 1.0], + [0.0, 1.0, 1.0], + [0.6471407, 1.0532293, 1.0], + [0.40457213, 1.0171094, 1.0], + [0.61659175, 0.9494374, 1.0], + [0.7804061, 0.8600364, 1.0], + [0.0, 1.0, 1.0], + [-0.15095921, 1.0584109, 1.0], + [0.24909112, 0.999217, 1.0], + [0.7718789, 0.9383637, 1.0], + [1.2283664, 0.8916653, 1.0], + [0.0, 1.0, 1.0], + [-0.23281652, 1.0636709, 1.0], + [0.5478948, 0.98645896, 1.0], + [1.1050255, 0.9356548, 1.0], + [1.721518, 0.89987594, 1.0], + [0.0, 1.0, 1.0], + [-0.45712063, 1.0652657, 1.0], + [0.3815263, 0.98859835, 1.0], + [1.0404433, 0.94592875, 1.0], + [1.8172855, 0.90899503, 1.0], + [0.0, 1.0, 1.0], + [-1.1051946, 1.0864792, 1.0], + [-0.06119209, 1.0147051, 1.0], + [0.7248705, 0.9713246, 1.0], + [1.5626872, 0.92918205, 1.0], + [0.0, 1.0, 1.0], + [-2.237749, 1.127092, 1.0], + [-1.070423, 1.0662243, 1.0], + [-0.104194425, 1.0130713, 1.0], + [0.804536, 0.9474472, 1.0], + [0.0, 1.0, 1.0], + [-3.6953864, 1.1598293, 1.0], + [-2.686826, 1.106682, 1.0], + [-1.7835299, 1.0456588, 1.0], + [-0.8053292, 0.9597566, 1.0], + [0.0, 1.0, 1.0], + [-5.2643313, 1.1904461, 1.0], + [-4.4016128, 1.1447512, 1.0], + [-3.6761749, 1.0970792, 1.0], + [-2.1196303, 0.9963984, 1.0], + [0.0, 1.0, 1.0], + [-7.199227, 1.2223662, 1.0], + [-6.308516, 1.1835574, 1.0], + [-5.5572577, 1.15013, 1.0], + [-2.1184824, 1.0233642, 1.0], + [0.0, 1.0, 1.0], + [-9.357493, 1.2634833, 1.0], + [-8.469821, 1.2300229, 1.0], + [-7.221032, 1.1844004, 1.0], + [-0.7896582, 1.0104114, 1.0], + [0.0, 1.0, 1.0], + [-11.875645, 1.3014976, 1.0], + [-10.859666, 1.2672151, 1.0], + [-7.7761583, 1.1516356, 1.0], + [-0.47165853, 0.9992604, 1.0], + [0.0, 1.0, 1.0], + [-14.108668, 1.2699456, 1.0], + [-12.638117, 1.2220073, 1.0], + [-7.2701273, 1.0625613, 1.0], + [-0.6548264, 0.99727935, 1.0], + [0.0, 1.0, 1.0], + [-15.724136, 1.204452, 1.0], + [-13.639737, 1.1411229, 1.0], + [-5.4015455, 1.0294274, 1.0], + [-0.093046986, 0.99997, 1.0], + [0.0, 1.0, 1.0], + [-16.673388, 1.1604823, 1.0], + [-15.056745, 1.0991567, 1.0], + [-6.999325, 1.0093077, 1.0], + [-0.15357149, 0.99979734, 1.0], + [0.0, 1.0, 1.0], + [-17.081741, 1.1213652, 1.0], + [-15.57136, 1.0569661, 1.0], + [-8.844844, 0.9832494, 1.0], + [-0.32678962, 0.9983225, 1.0], + [0.0, 1.0, 1.0], + [-17.33926, 1.0855446, 1.0], + [-15.313985, 1.0192273, 1.0], + [-8.119184, 0.9764423, 1.0], + [-0.23862769, 0.9987188, 1.0], + [0.0, 1.0, 1.0], + [-18.433193, 1.0620961, 1.0], + [-16.90102, 0.9942711, 1.0], + [-9.617896, 0.9711072, 1.0], + [-0.2552796, 0.99904865, 1.0], + [0.0, 1.0, 1.0], + [-17.276731, 1.0457346, 1.0], + [-16.739477, 0.9782363, 1.0], + [-11.156491, 0.96119857, 1.0], + [-0.34167433, 0.9984216, 1.0], + [0.0, 1.0, 1.0], + [-17.084091, 1.0438286, 1.0], + [-15.816032, 0.984013, 1.0], + [-9.323119, 0.9641214, 1.0], + [-0.2387637, 0.9986463, 1.0], + [0.0, 1.0, 1.0], + [-15.596954, 1.0198183, 1.0], + [-13.655594, 0.9686297, 1.0], + [-7.797318, 0.955375, 1.0], + [-0.22518365, 0.9980864, 1.0], + [0.0, 1.0, 1.0], + [-15.301703, 1.0141279, 1.0], + [-12.989882, 0.93955874, 1.0], + [-7.9458184, 0.9391862, 1.0], + [-0.2038498, 0.9980101, 1.0], + [0.0, 1.0, 1.0], + [-16.792349, 1.0134387, 1.0], + [-14.75936, 0.9455252, 1.0], + [-8.760609, 0.9422921, 1.0], + [-0.30783197, 0.9958334, 1.0], + [0.0, 1.0, 1.0], + [-17.005386, 1.0050753, 1.0], + [-15.469527, 0.9674358, 1.0], + [-8.49741, 0.95576, 1.0], + [-0.41260514, 0.9953345, 1.0], + [0.0, 1.0, 1.0], + [-17.273808, 0.9787281, 1.0], + [-15.69969, 0.942322, 1.0], + [-12.234762, 0.87553686, 1.0], + [-2.3212836, 0.9701262, 1.0], + [0.0, 1.0, 1.0], + [-17.132599, 0.9134147, 1.0], + [-15.41619, 0.8991917, 1.0], + [-12.281813, 0.8682994, 1.0], + [-2.1571953, 0.970933, 1.0], + [0.0, 1.0, 1.0], + [-16.799068, 0.85166913, 1.0], + [-14.772821, 0.8631338, 1.0], + [-9.500277, 0.8921043, 1.0], + [-0.7092388, 0.98975027, 1.0], + [0.0, 1.0, 1.0], + [-16.442728, 0.8058451, 1.0], + [-13.375286, 0.8466388, 1.0], + [-8.537738, 0.8891218, 1.0], + [-0.5375653, 0.99202126, 1.0], + [0.0, 1.0, 1.0], + [-15.213923, 0.78132874, 1.0], + [-11.185756, 0.8387474, 1.0], + [-7.113949, 0.8830028, 1.0], + [-0.51550055, 0.98982215, 1.0], + [0.0, 1.0, 1.0], + [-14.168373, 0.7644356, 1.0], + [-10.289349, 0.830659, 1.0], + [-4.6552444, 0.8868689, 1.0], + [-0.362867, 0.9811054, 1.0], + [0.0, 1.0, 1.0], + [-12.229682, 0.75506926, 1.0], + [-8.255229, 0.8390729, 1.0], + [-3.1724656, 0.88942176, 1.0], + [-0.36211884, 0.96008885, 1.0], + [0.0, 1.0, 1.0], + [-8.780003, 0.78203535, 1.0], + [-4.8128314, 0.88308793, 1.0], + [-1.7214035, 0.9332362, 1.0], + [0.07604427, 0.9700093, 1.0], + [0.0, 1.0, 1.0], + [-5.1902246, 0.84770036, 1.0], + [-2.0365455, 0.94785786, 1.0], + [0.075043306, 0.98688334, 1.0], + [1.348575, 0.9891258, 1.0], + [0.0, 1.0, 1.0], + [-3.9187067, 0.8880616, 1.0], + [-0.8643225, 0.98719597, 1.0], + [1.677873, 1.0176314, 1.0], + [3.0216281, 1.0148586, 1.0], + [0.0, 1.0, 1.0], + [-3.5886059, 0.8924353, 1.0], + [0.30670318, 1.0308015, 1.0], + [2.7104156, 1.0678495, 1.0], + [3.735997, 1.0629262, 1.0], + [0.0, 1.0, 1.0], + [-3.084609, 0.91431457, 1.0], + [1.0545353, 1.082462, 1.0], + [2.8006148, 1.1123718, 1.0], + [3.2798169, 1.1044176, 1.0], + [0.0, 1.0, 1.0], + [-3.0868382, 0.88934803, 1.0], + [0.24395227, 1.0737534, 1.0], + [1.8516512, 1.1117501, 1.0], + [1.8256167, 1.0846347, 1.0], + [0.0, 1.0, 1.0], + [-1.303041, 0.91892815, 1.0], + [-0.3339426, 1.0717436, 1.0], + [-0.5027457, 1.1226003, 1.0], + [-0.55369043, 1.0714196, 1.0], + [0.0, 1.0, 1.0], + [0.009014121, 0.99058026, 1.0], + [-0.30335096, 1.1036884, 1.0], + [-1.4451755, 1.1425995, 1.0], + [-1.269934, 1.0913438, 1.0], + [0.0, 1.0, 1.0], + [0.61342883, 0.9911708, 1.0], + [-0.14296348, 1.1055524, 1.0], + [-1.336663, 1.1335871, 1.0], + [-1.1314107, 1.0589578, 1.0], + [0.0, 1.0, 1.0], + [1.7491869, 0.94608873, 1.0], + [1.4192203, 1.0620985, 1.0], + [-0.24427313, 1.079302, 1.0], + [-1.2224454, 1.0283191, 1.0], + [0.0, 1.0, 1.0], + [3.2000449, 0.9136502, 1.0], + [3.1475732, 1.0253253, 1.0], + [2.1547606, 1.0377923, 1.0], + [0.30383298, 1.0079229, 1.0], + [0.0, 1.0, 1.0], + [4.7772436, 0.9144093, 1.0], + [4.162533, 1.047513, 1.0], + [4.2758074, 1.0457861, 1.0], + [3.105151, 0.9908633, 1.0], + [0.0, 1.0, 1.0], + [3.676512, 0.9309974, 1.0], + [2.7957842, 1.0558958, 1.0], + [3.4883666, 1.0355694, 1.0], + [3.137294, 0.9896551, 1.0], + [0.0, 1.0, 1.0], + [3.122498, 0.9589433, 1.0], + [1.543551, 1.0560359, 1.0], + [1.3497539, 1.0383536, 1.0], + [0.11099989, 1.0035237, 1.0], + [0.0, 1.0, 1.0], + [3.580221, 0.9780591, 1.0], + [1.9945487, 1.0655055, 1.0], + [2.4221385, 1.0402309, 1.0], + [0.4563281, 1.0015888, 1.0], + [0.0, 1.0, 1.0], + [4.7698708, 1.0119603, 1.0], + [2.4278777, 1.1139587, 1.0], + [3.0077763, 1.075531, 1.0], + [0.89499116, 1.0035012, 1.0], + [0.0, 1.0, 1.0], + [5.788685, 1.0530056, 1.0], + [2.6037643, 1.1413025, 1.0], + [1.4215078, 1.0968238, 1.0], + [0.20484339, 1.0055182, 1.0], + [0.0, 1.0, 1.0], + [5.1022477, 1.0453085, 1.0], + [2.2648408, 1.1255327, 1.0], + [3.5084808, 1.0749761, 1.0], + [0.8860151, 1.0050836, 1.0], + [0.0, 1.0, 1.0], + [4.840788, 0.99767727, 1.0], + [2.867805, 1.0597396, 1.0], + [5.0557113, 1.041923, 1.0], + [1.229561, 1.0051966, 1.0], + [0.0, 1.0, 1.0], + [7.0307097, 0.97708684, 1.0], + [4.806399, 1.047505, 1.0], + [1.4926684, 1.0392994, 1.0], + [-0.316868, 1.0058483, 1.0], + [0.0, 1.0, 1.0], + [6.4268966, 0.97928965, 1.0], + [4.44971, 1.0286714, 1.0], + [3.9176347, 1.0229295, 1.0], + [0.89289904, 1.0034719, 1.0], + [0.0, 1.0, 1.0], + [5.0285277, 0.9728169, 1.0], + [2.574475, 1.0172137, 1.0], + [3.1116145, 1.0253292, 1.0], + [0.8804246, 1.0046561, 1.0], + [0.0, 1.0, 1.0], + [5.9326806, 0.9673203, 1.0], + [3.0191274, 0.9986364, 1.0], + [2.0088856, 1.0064441, 1.0], + [0.8739533, 1.0035771, 1.0], + [0.0, 1.0, 1.0], + [6.88084, 0.9650284, 1.0], + [3.5343342, 0.9909499, 1.0], + [2.52684, 0.99866265, 1.0], + [1.0010276, 1.0015429, 1.0], + [0.0, 1.0, 1.0], + [6.6674595, 0.9901648, 1.0], + [3.5274854, 1.0219513, 1.0], + [4.0995092, 1.0413644, 1.0], + [2.9521806, 1.0259401, 1.0], + [0.0, 1.0, 1.0], + [7.1322083, 1.0172622, 1.0], + [4.796819, 1.0498525, 1.0], + [4.7010384, 1.0518665, 1.0], + [3.3101153, 1.0278659, 1.0], + [0.0, 1.0, 1.0], + [6.23181, 1.0341, 1.0], + [4.575992, 1.0661216, 1.0], + [4.1811743, 1.0555745, 1.0], + [2.9168804, 1.0209843, 1.0], + [0.0, 1.0, 1.0], + [5.773051, 1.0802647, 1.0], + [3.930077, 1.1002141, 1.0], + [3.9242337, 1.0825543, 1.0], + [3.3296824, 1.0521122, 1.0], + [0.0, 1.0, 1.0], + [2.4754193, 0.95264477, 1.0], + [1.6237425, 0.9976076, 1.0], + [1.6940635, 1.0124187, 1.0], + [1.4032439, 1.0027901, 1.0], + [0.0, 1.0, 1.0], + [1.8328916, 0.9358476, 1.0], + [0.5054257, 0.9516976, 1.0], + [0.82207125, 0.96641874, 1.0], + [0.867466, 0.96557885, 1.0], + [0.0, 1.0, 1.0], + [1.107456, 0.9178314, 1.0], + [-0.11612688, 0.9198909, 1.0], + [-0.13824676, 0.9118656, 1.0], + [-0.0028314982, 0.9240737, 1.0], + [0.0, 1.0, 1.0], + [1.2662044, 0.9126919, 1.0], + [0.20858723, 0.91308266, 1.0], + [-0.74156207, 0.8892692, 1.0], + [-0.96805483, 0.8813321, 1.0], + [0.0, 1.0, 1.0], + [1.5846848, 0.8996693, 1.0], + [0.3940221, 0.9022535, 1.0], + [-1.0160103, 0.8844732, 1.0], + [-1.9547969, 0.869879, 1.0], + [0.0, 1.0, 1.0], + [1.8889635, 0.8861786, 1.0], + [0.46730384, 0.8902896, 1.0], + [-0.8314566, 0.8740593, 1.0], + [-3.0556507, 0.85314214, 1.0], + [0.0, 1.0, 1.0], + [1.786228, 0.87517726, 1.0], + [-0.25766176, 0.8843216, 1.0], + [-2.6286218, 0.87064177, 1.0], + [-6.709825, 0.8475933, 1.0], + [0.0, 1.0, 1.0], + [1.1630952, 0.8485678, 1.0], + [-1.315835, 0.88361984, 1.0], + [-4.564759, 0.8761655, 1.0], + [-7.6052637, 0.84420526, 1.0], + [0.0, 1.0, 1.0], + [0.65808207, 0.8359365, 1.0], + [-1.8502634, 0.871772, 1.0], + [-4.9014773, 0.88665104, 1.0], + [-6.9419703, 0.85948473, 1.0], + [0.0, 1.0, 1.0], + [0.98892176, 0.85553217, 1.0], + [-1.2973236, 0.88800645, 1.0], + [-3.9453318, 0.8810778, 1.0], + [-5.0194716, 0.83465356, 1.0], + [0.0, 1.0, 1.0], + [1.4077076, 0.8883648, 1.0], + [-0.20262001, 0.89855146, 1.0], + [-2.0743167, 0.87382066, 1.0], + [-2.846246, 0.8157335, 1.0], + [0.0, 1.0, 1.0], + [1.6395962, 0.92741174, 1.0], + [0.7942348, 0.92847836, 1.0], + [-0.10936606, 0.878342, 1.0], + [-0.28163967, 0.78697026, 1.0], + [0.0, 1.0, 1.0], + [1.9163837, 0.9670593, 1.0], + [1.1606812, 0.94956505, 1.0], + [0.48494318, 0.86900467, 1.0], + [0.48800853, 0.77839893, 1.0], + [0.0, 1.0, 1.0], + [0.9139721, 0.9643932, 1.0], + [0.70910484, 0.9468291, 1.0], + [0.70515186, 0.87377256, 1.0], + [0.7435633, 0.7895271, 1.0], + [0.0, 1.0, 1.0], + [-0.021806521, 0.9712936, 1.0], + [0.09517821, 0.94683015, 1.0], + [0.48132607, 0.8953816, 1.0], + [0.7116937, 0.82242763, 1.0], + [0.0, 1.0, 1.0], + [-0.79750377, 0.9793866, 1.0], + [-0.08199019, 0.933896, 1.0], + [0.54560524, 0.8938053, 1.0], + [0.91736573, 0.8542098, 1.0], + [0.0, 1.0, 1.0], + [-0.93426365, 0.9845968, 1.0], + [0.15814182, 0.92910755, 1.0], + [0.72163683, 0.89656097, 1.0], + [1.2182864, 0.86374855, 1.0], + [0.0, 1.0, 1.0], + [-1.0705345, 0.98736614, 1.0], + [-0.011547462, 0.9383716, 1.0], + [0.6337991, 0.90952563, 1.0], + [1.2993928, 0.87798977, 1.0], + [0.0, 1.0, 1.0], + [-1.6194162, 1.0018165, 1.0], + [-0.49097732, 0.9606046, 1.0], + [0.2521988, 0.9307922, 1.0], + [0.9645536, 0.8885482, 1.0], + [0.0, 1.0, 1.0], + [-2.80128, 1.0367984, 1.0], + [-1.5576428, 0.9923525, 1.0], + [-0.66330856, 0.9425935, 1.0], + [0.1666689, 0.83448035, 1.0], + [0.0, 1.0, 1.0], + [-4.1835513, 1.0688453, 1.0], + [-3.0496554, 1.0094827, 1.0], + [-2.0194643, 0.9350747, 1.0], + [-0.84885937, 0.81810623, 1.0], + [0.0, 1.0, 1.0], + [-5.472717, 1.096382, 1.0], + [-4.410626, 1.0342624, 1.0], + [-3.3867917, 0.9620032, 1.0], + [-1.1987106, 0.92667925, 1.0], + [0.0, 1.0, 1.0], + [-7.2029138, 1.1229113, 1.0], + [-5.953019, 1.0709114, 1.0], + [-3.932608, 1.0097591, 1.0], + [-0.7465921, 0.9776096, 1.0], + [0.0, 1.0, 1.0], + [-8.683279, 1.1355549, 1.0], + [-7.128312, 1.0855685, 1.0], + [-3.1864233, 1.005719, 1.0], + [-0.26363567, 0.99284154, 1.0], + [0.0, 1.0, 1.0], + [-10.163454, 1.1477863, 1.0], + [-7.8144236, 1.0853112, 1.0], + [-1.8801234, 1.0063028, 1.0], + [-0.08024162, 0.9992318, 1.0], + [0.0, 1.0, 1.0], + [-12.115101, 1.1146804, 1.0], + [-8.96621, 1.0459248, 1.0], + [-1.6161847, 0.9987317, 1.0], + [-0.071704574, 0.99979985, 1.0], + [0.0, 1.0, 1.0], + [-13.706088, 1.0729254, 1.0], + [-8.66254, 1.008506, 1.0], + [-1.0726118, 0.99519026, 1.0], + [-0.013879563, 0.999924, 1.0], + [0.0, 1.0, 1.0], + [-13.639081, 1.0313063, 1.0], + [-8.5867815, 0.9870387, 1.0], + [-1.1318198, 0.9935133, 1.0], + [-0.014718597, 0.99989676, 1.0], + [0.0, 1.0, 1.0], + [-13.590664, 1.0171928, 1.0], + [-7.5184603, 0.98305726, 1.0], + [-0.9713564, 0.9937742, 1.0], + [-0.017022038, 0.9998613, 1.0], + [0.0, 1.0, 1.0], + [-13.854863, 1.0014843, 1.0], + [-7.721969, 0.9806506, 1.0], + [-0.77561605, 0.99587494, 1.0], + [-0.009547515, 0.9999292, 1.0], + [0.0, 1.0, 1.0], + [-14.060616, 0.98336315, 1.0], + [-8.691673, 0.94924486, 1.0], + [-1.2617085, 0.9861989, 1.0], + [-0.017596014, 0.99978256, 1.0], + [0.0, 1.0, 1.0], + [-12.874084, 0.9567539, 1.0], + [-9.545912, 0.9260754, 1.0], + [-2.5305426, 0.97063804, 1.0], + [-0.05151436, 0.99930036, 1.0], + [0.0, 1.0, 1.0], + [-13.672559, 0.9840259, 1.0], + [-8.942783, 0.9562326, 1.0], + [-1.7533314, 0.983495, 1.0], + [-0.031896364, 0.99963516, 1.0], + [0.0, 1.0, 1.0], + [-12.389929, 0.9684237, 1.0], + [-7.423202, 0.9658616, 1.0], + [-1.1646813, 0.9892395, 1.0], + [-0.019982396, 0.9997543, 1.0], + [0.0, 1.0, 1.0], + [-11.921755, 0.9305603, 1.0], + [-8.221304, 0.9161024, 1.0], + [-2.065746, 0.9699598, 1.0], + [-0.05210479, 0.999025, 1.0], + [0.0, 1.0, 1.0], + [-12.236582, 0.92396444, 1.0], + [-8.276435, 0.9221221, 1.0], + [-3.3240492, 0.9396533, 1.0], + [-0.477805, 0.9889453, 1.0], + [0.0, 1.0, 1.0], + [-11.884224, 0.9080763, 1.0], + [-6.9323835, 0.9483917, 1.0], + [-2.8353944, 0.9600339, 1.0], + [-0.33724767, 0.9937283, 1.0], + [0.0, 1.0, 1.0], + [-13.285868, 0.8697298, 1.0], + [-7.293664, 0.9262688, 1.0], + [-2.6733654, 0.9643648, 1.0], + [-0.293086, 0.9961857, 1.0], + [0.0, 1.0, 1.0], + [-14.295206, 0.8048045, 1.0], + [-9.883155, 0.8632042, 1.0], + [-3.227856, 0.95656085, 1.0], + [-0.23363292, 0.9969017, 1.0], + [0.0, 1.0, 1.0], + [-14.586395, 0.75223583, 1.0], + [-11.311147, 0.81547827, 1.0], + [-2.238798, 0.96711767, 1.0], + [-0.07408935, 0.998945, 1.0], + [0.0, 1.0, 1.0], + [-14.474079, 0.7245708, 1.0], + [-11.533241, 0.7828741, 1.0], + [-2.736401, 0.9435411, 1.0], + [-0.044651125, 0.9990903, 1.0], + [0.0, 1.0, 1.0], + [-13.674288, 0.71236974, 1.0], + [-9.81334, 0.7972127, 1.0], + [-3.45957, 0.9197005, 1.0], + [-0.07574147, 0.9975253, 1.0], + [0.0, 1.0, 1.0], + [-12.7195, 0.69227666, 1.0], + [-8.596455, 0.7779914, 1.0], + [-2.3802116, 0.8463873, 1.0], + [-0.35825908, 0.95749724, 1.0], + [0.0, 1.0, 1.0], + [-10.18446, 0.6873084, 1.0], + [-6.0542045, 0.7841001, 1.0], + [-2.1212237, 0.8281544, 1.0], + [-0.7646297, 0.9053229, 1.0], + [0.0, 1.0, 1.0], + [-6.1229663, 0.74606425, 1.0], + [-3.4445412, 0.8351193, 1.0], + [-1.6135985, 0.87599313, 1.0], + [-0.2056776, 0.9368623, 1.0], + [0.0, 1.0, 1.0], + [-3.7523043, 0.8158876, 1.0], + [-1.8315514, 0.894766, 1.0], + [-0.27596512, 0.93012476, 1.0], + [1.0396978, 0.9575369, 1.0], + [0.0, 1.0, 1.0], + [-2.7688127, 0.8445321, 1.0], + [-0.50951934, 0.9420834, 1.0], + [1.4284167, 0.9780485, 1.0], + [2.5512972, 0.9817148, 1.0], + [0.0, 1.0, 1.0], + [-2.1246834, 0.8524671, 1.0], + [0.29334772, 0.96627426, 1.0], + [2.560665, 1.0186502, 1.0], + [3.3895814, 1.0229484, 1.0], + [0.0, 1.0, 1.0], + [-1.3665168, 0.8436846, 1.0], + [1.2660046, 1.012508, 1.0], + [2.7319925, 1.0737249, 1.0], + [3.0757403, 1.0803311, 1.0], + [0.0, 1.0, 1.0], + [-0.5887472, 0.81296784, 1.0], + [0.699834, 0.98561674, 1.0], + [1.4461241, 1.0658891, 1.0], + [1.3923068, 1.0570195, 1.0], + [0.0, 1.0, 1.0], + [1.096663, 0.8274317, 1.0], + [0.40805075, 0.98812795, 1.0], + [-0.045402966, 1.0472325, 1.0], + [-0.06040587, 1.0157863, 1.0], + [0.0, 1.0, 1.0], + [1.9790232, 0.87913215, 1.0], + [0.37345043, 1.0132964, 1.0], + [-0.79287934, 1.0823095, 1.0], + [-0.34175304, 1.0432074, 1.0], + [0.0, 1.0, 1.0], + [2.5627797, 0.87573963, 1.0], + [0.6304921, 1.0118407, 1.0], + [-0.9373828, 1.0851029, 1.0], + [-0.46959534, 1.031097, 1.0], + [0.0, 1.0, 1.0], + [3.7361374, 0.8472772, 1.0], + [1.9630458, 0.98144615, 1.0], + [-0.21733184, 1.0450604, 1.0], + [-0.49014735, 1.0128133, 1.0], + [0.0, 1.0, 1.0], + [4.3971963, 0.8324688, 1.0], + [2.660751, 0.9585303, 1.0], + [0.16532291, 1.0232745, 1.0], + [-0.14636081, 1.0036846, 1.0], + [0.0, 1.0, 1.0], + [5.0258904, 0.8367818, 1.0], + [2.8924615, 0.9749669, 1.0], + [1.7615348, 1.0238429, 1.0], + [0.81233835, 0.9984574, 1.0], + [0.0, 1.0, 1.0], + [5.2452407, 0.85815537, 1.0], + [2.6645274, 0.9761913, 1.0], + [1.4570304, 1.0139804, 1.0], + [1.0984119, 0.997529, 1.0], + [0.0, 1.0, 1.0], + [4.93689, 0.8569606, 1.0], + [3.1328692, 0.9457437, 1.0], + [0.033365987, 1.0082009, 1.0], + [-0.05564574, 1.0016743, 1.0], + [0.0, 1.0, 1.0], + [5.0655975, 0.8770879, 1.0], + [2.174103, 0.9875497, 1.0], + [0.2545323, 1.0086097, 1.0], + [0.022092488, 1.000235, 1.0], + [0.0, 1.0, 1.0], + [4.4488134, 0.9094009, 1.0], + [1.7526729, 1.0061622, 1.0], + [0.42274538, 1.0103294, 1.0], + [0.051791485, 1.0002624, 1.0], + [0.0, 1.0, 1.0], + [5.1548405, 0.95060486, 1.0], + [1.8016412, 1.0232372, 1.0], + [0.20820145, 1.0119417, 1.0], + [0.027733212, 1.0003631, 1.0], + [0.0, 1.0, 1.0], + [5.4379053, 0.94287974, 1.0], + [1.6865833, 1.0055557, 1.0], + [0.5884739, 1.0089288, 1.0], + [0.10115034, 1.0003462, 1.0], + [0.0, 1.0, 1.0], + [3.8398051, 0.90459824, 1.0], + [1.1598605, 0.9777082, 1.0], + [0.77112013, 1.0046481, 1.0], + [0.15015621, 1.0003301, 1.0], + [0.0, 1.0, 1.0], + [5.6665335, 0.91051537, 1.0], + [3.33516, 0.98394734, 1.0], + [0.32077682, 1.0035998, 1.0], + [-0.005901165, 1.00032, 1.0], + [0.0, 1.0, 1.0], + [4.3764243, 0.9138666, 1.0], + [1.7997692, 0.97097856, 1.0], + [0.87327296, 0.9996552, 1.0], + [0.14200021, 1.0003701, 1.0], + [0.0, 1.0, 1.0], + [2.8900363, 0.8881567, 1.0], + [-0.016681926, 0.9412745, 1.0], + [1.2008874, 0.9925353, 1.0], + [0.2819057, 1.0004404, 1.0], + [0.0, 1.0, 1.0], + [3.0338037, 0.8888064, 1.0], + [0.49603212, 0.92353123, 1.0], + [0.344872, 0.9546143, 1.0], + [0.23514952, 0.99573493, 1.0], + [0.0, 1.0, 1.0], + [3.2889633, 0.8941952, 1.0], + [0.12481757, 0.90704453, 1.0], + [-0.89585435, 0.91424674, 1.0], + [-0.24134572, 0.9783999, 1.0], + [0.0, 1.0, 1.0], + [2.7199328, 0.9107902, 1.0], + [0.096942216, 0.9292534, 1.0], + [-0.3109405, 0.9336825, 1.0], + [-0.33917436, 0.96199673, 1.0], + [0.0, 1.0, 1.0], + [3.5014708, 0.9283932, 1.0], + [1.2909425, 0.95882636, 1.0], + [0.26298514, 0.95131576, 1.0], + [-0.69164455, 0.9573067, 1.0], + [0.0, 1.0, 1.0], + [3.5789986, 0.93125904, 1.0], + [2.0657613, 0.9823736, 1.0], + [1.2339572, 0.9823017, 1.0], + [0.035981927, 0.9774156, 1.0], + [0.0, 1.0, 1.0], + [3.2489352, 0.947897, 1.0], + [2.3317747, 1.0011578, 1.0], + [1.9685928, 1.009779, 1.0], + [1.1930741, 1.0035145, 1.0], + [0.0, 1.0, 1.0], + [0.41370514, 0.86071473, 1.0], + [-0.63482434, 0.8487081, 1.0], + [-0.96838796, 0.8932299, 1.0], + [-0.16537139, 0.98455006, 1.0], + [0.0, 1.0, 1.0], + [0.7274317, 0.8435787, 1.0], + [-0.7413707, 0.8246945, 1.0], + [-0.6780908, 0.84962696, 1.0], + [-0.03611998, 0.9278284, 1.0], + [0.0, 1.0, 1.0], + [-0.025755988, 0.7956887, 1.0], + [-0.7080098, 0.7706736, 1.0], + [-0.00667306, 0.80417067, 1.0], + [1.2645549, 0.8267063, 1.0], + [0.0, 1.0, 1.0], + [1.844674, 0.77885765, 1.0], + [1.358191, 0.74993426, 1.0], + [1.8842225, 0.771222, 1.0], + [3.6828177, 0.75069827, 1.0], + [0.0, 1.0, 1.0], + [2.147642, 0.7875774, 1.0], + [1.6485577, 0.7667687, 1.0], + [2.232393, 0.75781333, 1.0], + [3.8045251, 0.73986065, 1.0], + [0.0, 1.0, 1.0], + [2.1638684, 0.78218484, 1.0], + [1.5419188, 0.7789207, 1.0], + [1.5175326, 0.76315284, 1.0], + [2.9210594, 0.7496939, 1.0], + [0.0, 1.0, 1.0], + [1.6404829, 0.76603454, 1.0], + [0.3848709, 0.76939934, 1.0], + [-0.5066958, 0.7645504, 1.0], + [0.97468865, 0.73737776, 1.0], + [0.0, 1.0, 1.0], + [0.65317655, 0.7593511, 1.0], + [-0.66478103, 0.7523295, 1.0], + [0.07674445, 0.7192325, 1.0], + [0.11743816, 0.7180049, 1.0], + [0.0, 1.0, 1.0], + [-0.07553896, 0.75588036, 1.0], + [-0.7599607, 0.7307774, 1.0], + [0.7937273, 0.702063, 1.0], + [1.210313, 0.71042067, 1.0], + [0.0, 1.0, 1.0], + [0.6146166, 0.7519787, 1.0], + [-0.11158234, 0.7340125, 1.0], + [1.8024358, 0.6884776, 1.0], + [2.2258494, 0.6841371, 1.0], + [0.0, 1.0, 1.0], + [0.7284711, 0.81019914, 1.0], + [0.0031727236, 0.7846696, 1.0], + [2.4180965, 0.6961533, 1.0], + [3.2767165, 0.68659955, 1.0], + [0.0, 1.0, 1.0], + [0.7535696, 0.863907, 1.0], + [0.36523274, 0.8374792, 1.0], + [1.2830526, 0.791074, 1.0], + [1.2069559, 0.8067631, 1.0], + [0.0, 1.0, 1.0], + [1.3411092, 0.88778365, 1.0], + [0.7838614, 0.86403745, 1.0], + [1.0240868, 0.81532073, 1.0], + [0.9583133, 0.7903267, 1.0], + [0.0, 1.0, 1.0], + [-0.12238092, 0.85272485, 1.0], + [0.07477853, 0.8615265, 1.0], + [0.6215121, 0.80922437, 1.0], + [0.8200295, 0.7752551, 1.0], + [0.0, 1.0, 1.0], + [-0.82122755, 0.87185943, 1.0], + [-0.2457037, 0.8873393, 1.0], + [0.26640493, 0.85581523, 1.0], + [0.45366037, 0.81068766, 1.0], + [0.0, 1.0, 1.0], + [-1.3295536, 0.9104388, 1.0], + [-0.5824124, 0.87757504, 1.0], + [0.006645972, 0.8408192, 1.0], + [0.28378436, 0.8216209, 1.0], + [0.0, 1.0, 1.0], + [-1.7040519, 0.9045666, 1.0], + [-0.47549373, 0.86758405, 1.0], + [0.053788297, 0.83981687, 1.0], + [0.40476194, 0.81218266, 1.0], + [0.0, 1.0, 1.0], + [-1.5469458, 0.9090261, 1.0], + [-0.5149614, 0.88305897, 1.0], + [0.04461902, 0.8536314, 1.0], + [0.47533098, 0.8176997, 1.0], + [0.0, 1.0, 1.0], + [-1.9928781, 0.9177581, 1.0], + [-1.1592609, 0.8999771, 1.0], + [-0.47069812, 0.8662861, 1.0], + [0.24946758, 0.8074272, 1.0], + [0.0, 1.0, 1.0], + [-2.8105757, 0.9419143, 1.0], + [-1.9706584, 0.9134377, 1.0], + [-1.0593511, 0.8243537, 1.0], + [-0.37524036, 0.77119154, 1.0], + [0.0, 1.0, 1.0], + [-3.1471803, 0.9753915, 1.0], + [-2.3176653, 0.91906554, 1.0], + [-1.3088802, 0.86219937, 1.0], + [-0.58739233, 0.86786795, 1.0], + [0.0, 1.0, 1.0], + [-2.94802, 0.97012186, 1.0], + [-2.1835663, 0.95009077, 1.0], + [-1.0128062, 0.9544606, 1.0], + [-0.15606245, 0.98380494, 1.0], + [0.0, 1.0, 1.0], + [-3.345666, 0.98280185, 1.0], + [-1.4991432, 0.95262855, 1.0], + [-0.4551724, 0.9883049, 1.0], + [-0.03270482, 0.9987078, 1.0], + [0.0, 1.0, 1.0], + [-3.8461266, 0.9927924, 1.0], + [-1.276193, 0.98380685, 1.0], + [-0.1276723, 0.99596465, 1.0], + [-0.006702845, 0.9997529, 1.0], + [0.0, 1.0, 1.0], + [-2.713865, 0.98152876, 1.0], + [-0.75546634, 0.9879916, 1.0], + [-0.049746733, 0.9991844, 1.0], + [-0.0007843447, 0.9999856, 1.0], + [0.0, 1.0, 1.0], + [-3.2784758, 0.98056763, 1.0], + [-1.0423217, 0.98997813, 1.0], + [-0.050068997, 0.9994019, 1.0], + [-0.00034191704, 0.9999971, 1.0], + [0.0, 1.0, 1.0], + [-2.9297116, 0.96755266, 1.0], + [-0.5981317, 0.9923545, 1.0], + [-0.017654736, 0.999798, 1.0], + [-9.46762e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.1329167, 0.9680987, 1.0], + [-0.42209783, 0.9959619, 1.0], + [-0.012668612, 0.9998711, 1.0], + [-8.0989346e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.681753, 0.96301955, 1.0], + [-0.42579386, 0.9962166, 1.0], + [-0.00814088, 0.9999096, 1.0], + [-4.4159344e-05, 0.9999995, 1.0], + [0.0, 1.0, 1.0], + [-3.2585168, 0.9649381, 1.0], + [-0.30428812, 0.9962269, 1.0], + [-0.005310869, 0.999927, 1.0], + [-2.5505675e-05, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-2.2021565, 0.9749354, 1.0], + [-0.3944129, 0.9904369, 1.0], + [-0.025527034, 0.9992587, 1.0], + [-0.00025198492, 0.9999939, 1.0], + [0.0, 1.0, 1.0], + [-1.929304, 0.971067, 1.0], + [-0.45261085, 0.99142736, 1.0], + [-0.05011053, 0.9990594, 1.0], + [-0.0008144145, 0.99998575, 1.0], + [0.0, 1.0, 1.0], + [-2.7514565, 0.9707775, 1.0], + [-0.364937, 0.9964542, 1.0], + [-0.020018931, 0.9997449, 1.0], + [-0.00028382998, 0.9999959, 1.0], + [0.0, 1.0, 1.0], + [-2.7956183, 0.9632996, 1.0], + [-0.42287976, 0.9969505, 1.0], + [-0.013087578, 0.9999144, 1.0], + [-8.082379e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-2.4113169, 0.9547617, 1.0], + [-0.33804947, 0.9945204, 1.0], + [-0.026301317, 0.9995303, 1.0], + [-0.0012762488, 0.99997115, 1.0], + [0.0, 1.0, 1.0], + [-2.6816075, 0.95678437, 1.0], + [-0.44680336, 0.99332184, 1.0], + [-0.13794503, 0.99693584, 1.0], + [-0.024087088, 0.9994248, 1.0], + [0.0, 1.0, 1.0], + [-3.1641667, 0.9557707, 1.0], + [-0.37171954, 0.99482054, 1.0], + [-0.076554835, 0.99849296, 1.0], + [-0.012065996, 0.9997304, 1.0], + [0.0, 1.0, 1.0], + [-5.1045394, 0.91970074, 1.0], + [-0.6387288, 0.9893497, 1.0], + [-0.016694926, 0.9997221, 1.0], + [-0.0008751128, 0.99998766, 1.0], + [0.0, 1.0, 1.0], + [-6.9304733, 0.85944414, 1.0], + [-1.2397609, 0.9733297, 1.0], + [-0.039723754, 0.99928564, 1.0], + [-0.0009279736, 0.9999874, 1.0], + [0.0, 1.0, 1.0], + [-8.17317, 0.7962485, 1.0], + [-2.5968554, 0.9279736, 1.0], + [-0.06445126, 0.9980414, 1.0], + [-0.00037242688, 0.99999243, 1.0], + [0.0, 1.0, 1.0], + [-10.704245, 0.7364805, 1.0], + [-5.6246877, 0.8459256, 1.0], + [-0.26169717, 0.99058115, 1.0], + [-0.0014548219, 0.9999466, 1.0], + [0.0, 1.0, 1.0], + [-10.675443, 0.68334174, 1.0], + [-4.872382, 0.8478102, 1.0], + [-0.4881562, 0.98677355, 1.0], + [-0.009578875, 0.99971837, 1.0], + [0.0, 1.0, 1.0], + [-10.985089, 0.61614233, 1.0], + [-6.222056, 0.75582373, 1.0], + [-1.2719929, 0.92699444, 1.0], + [-0.07923371, 0.99300826, 1.0], + [0.0, 1.0, 1.0], + [-9.835739, 0.6035176, 1.0], + [-6.378802, 0.7114187, 1.0], + [-2.099836, 0.85796636, 1.0], + [-0.28848997, 0.9751244, 1.0], + [0.0, 1.0, 1.0], + [-6.6503634, 0.67487407, 1.0], + [-5.500912, 0.7487064, 1.0], + [-4.2971406, 0.8258655, 1.0], + [-0.6571109, 0.96886605, 1.0], + [0.0, 1.0, 1.0], + [-4.7830634, 0.73914504, 1.0], + [-4.141989, 0.79455334, 1.0], + [-5.006245, 0.8116664, 1.0], + [-1.3780211, 0.94300467, 1.0], + [0.0, 1.0, 1.0], + [-3.9277482, 0.75783825, 1.0], + [-2.659795, 0.83125865, 1.0], + [-2.5174627, 0.85422933, 1.0], + [-1.3893734, 0.9027223, 1.0], + [0.0, 1.0, 1.0], + [-2.7880778, 0.79374677, 1.0], + [-1.5678242, 0.8652207, 1.0], + [0.2077058, 0.92604816, 1.0], + [0.5667539, 0.94809216, 1.0], + [0.0, 1.0, 1.0], + [-1.7917917, 0.7941517, 1.0], + [-0.65741813, 0.895106, 1.0], + [0.75534123, 0.9829742, 1.0], + [0.4768409, 0.98621637, 1.0], + [0.0, 1.0, 1.0], + [-1.1052668, 0.73317593, 1.0], + [-0.6049394, 0.8575412, 1.0], + [-0.15478949, 0.99293196, 1.0], + [-0.25519162, 0.99671155, 1.0], + [0.0, 1.0, 1.0], + [1.3859553, 0.7583256, 1.0], + [0.3529042, 0.9013461, 1.0], + [-0.09280269, 0.99014723, 1.0], + [-0.19439623, 0.9756913, 1.0], + [0.0, 1.0, 1.0], + [2.662148, 0.78581506, 1.0], + [1.3001701, 0.8985178, 1.0], + [0.46722722, 0.9815502, 1.0], + [0.23440441, 0.98131305, 1.0], + [0.0, 1.0, 1.0], + [2.7134244, 0.7862562, 1.0], + [1.8330814, 0.8773475, 1.0], + [0.75808954, 0.9856246, 1.0], + [0.15871423, 0.9977693, 1.0], + [0.0, 1.0, 1.0], + [3.707434, 0.78545547, 1.0], + [2.0999856, 0.88736033, 1.0], + [0.21350783, 0.9968301, 1.0], + [0.0005532017, 1.0001154, 1.0], + [0.0, 1.0, 1.0], + [4.2265773, 0.7976756, 1.0], + [2.41513, 0.9033128, 1.0], + [0.098965846, 0.99737674, 1.0], + [-0.0019273323, 1.0000342, 1.0], + [0.0, 1.0, 1.0], + [3.586104, 0.8242546, 1.0], + [1.1944336, 0.94521993, 1.0], + [0.018398829, 0.999946, 1.0], + [0.0061938893, 1.00001, 1.0], + [0.0, 1.0, 1.0], + [3.3312273, 0.85412824, 1.0], + [0.8018384, 0.96504784, 1.0], + [-0.038536284, 1.0002738, 1.0], + [0.0041213674, 1.0000454, 1.0], + [0.0, 1.0, 1.0], + [3.5602465, 0.86023563, 1.0], + [0.8536984, 0.97106135, 1.0], + [-0.06325424, 1.0002065, 1.0], + [-0.009787895, 1.0001173, 1.0], + [0.0, 1.0, 1.0], + [3.319309, 0.88970965, 1.0], + [0.4574435, 0.98576313, 1.0], + [0.0016855623, 0.9999831, 1.0], + [-0.000406475, 1.0000062, 1.0], + [0.0, 1.0, 1.0], + [2.0391257, 0.92719066, 1.0], + [-0.11533654, 0.9924606, 1.0], + [-0.022894483, 1.0000801, 1.0], + [-3.8161572e-05, 1.0000013, 1.0], + [0.0, 1.0, 1.0], + [2.221341, 0.9522132, 1.0], + [0.16617404, 0.99605083, 1.0], + [-0.0031928865, 0.999991, 1.0], + [1.2969534e-05, 1.0000004, 1.0], + [0.0, 1.0, 1.0], + [2.2095737, 0.94623184, 1.0], + [0.2084119, 0.9924171, 1.0], + [-0.0017147859, 0.999864, 1.0], + [0.00015271064, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [1.7305284, 0.92747825, 1.0], + [0.26084626, 0.99035066, 1.0], + [0.002197185, 0.9998796, 1.0], + [0.0002519184, 1.0, 1.0], + [0.0, 1.0, 1.0], + [1.83777, 0.9303522, 1.0], + [0.49865678, 0.99259496, 1.0], + [0.010380797, 0.99993306, 1.0], + [1.543077e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.6540057, 0.93718725, 1.0], + [-0.056169994, 0.98687667, 1.0], + [-0.009431662, 0.9991736, 1.0], + [0.00055399764, 0.9999875, 1.0], + [0.0, 1.0, 1.0], + [1.219968, 0.9170335, 1.0], + [-1.04431, 0.9552193, 1.0], + [-0.7334884, 0.9844318, 1.0], + [-0.067632034, 0.9988193, 1.0], + [0.0, 1.0, 1.0], + [1.3329756, 0.9103736, 1.0], + [-4.453365, 0.87807673, 1.0], + [-7.3110423, 0.86146367, 1.0], + [-1.0087034, 0.9803414, 1.0], + [0.0, 1.0, 1.0], + [0.96455824, 0.8981043, 1.0], + [-5.200631, 0.8460088, 1.0], + [-11.297041, 0.7854038, 1.0], + [-2.6635964, 0.9481161, 1.0], + [0.0, 1.0, 1.0], + [0.67527205, 0.9010675, 1.0], + [-6.0442433, 0.8281746, 1.0], + [-12.542688, 0.7473031, 1.0], + [-5.324665, 0.8921913, 1.0], + [0.0, 1.0, 1.0], + [1.2026789, 0.89397043, 1.0], + [-4.445598, 0.84913737, 1.0], + [-11.919539, 0.74464583, 1.0], + [-6.795066, 0.85686606, 1.0], + [0.0, 1.0, 1.0], + [0.6662235, 0.8867006, 1.0], + [-3.937991, 0.83842826, 1.0], + [-13.422844, 0.71948135, 1.0], + [-8.835538, 0.8229743, 1.0], + [0.0, 1.0, 1.0], + [0.7172698, 0.8660123, 1.0], + [-0.94286054, 0.8551379, 1.0], + [-5.286918, 0.83392274, 1.0], + [-2.4993267, 0.9417325, 1.0], + [0.0, 1.0, 1.0], + [0.0017951994, 0.9914302, 1.0], + [-0.06927332, 0.9921463, 1.0], + [-0.057490297, 0.9959994, 1.0], + [-0.008928111, 0.99963653, 1.0], + [0.0, 1.0, 1.0], + [-0.041254885, 0.98150927, 1.0], + [-0.1306019, 0.97009796, 1.0], + [-0.048590906, 0.9860053, 1.0], + [0.0030063766, 0.99852145, 1.0], + [0.0, 1.0, 1.0], + [-0.112703465, 0.95625734, 1.0], + [-0.2643623, 0.9157722, 1.0], + [0.17177379, 0.9519122, 1.0], + [0.89937246, 0.95642823, 1.0], + [0.0, 1.0, 1.0], + [0.29111728, 0.94092214, 1.0], + [0.3868302, 0.90032035, 1.0], + [3.1862643, 0.84815085, 1.0], + [6.1715746, 0.7603379, 1.0], + [0.0, 1.0, 1.0], + [0.538952, 0.9446369, 1.0], + [0.83218, 0.90219474, 1.0], + [3.4132833, 0.844821, 1.0], + [6.4263897, 0.7552901, 1.0], + [0.0, 1.0, 1.0], + [0.90486366, 0.9366256, 1.0], + [1.3550385, 0.9041044, 1.0], + [4.7555075, 0.83666027, 1.0], + [9.834919, 0.7355414, 1.0], + [0.0, 1.0, 1.0], + [0.749637, 0.92996854, 1.0], + [1.2060188, 0.9036133, 1.0], + [10.243389, 0.71855605, 1.0], + [15.236945, 0.6319401, 1.0], + [0.0, 1.0, 1.0], + [0.38263044, 0.9178865, 1.0], + [1.6851017, 0.8404756, 1.0], + [6.062546, 0.71273714, 1.0], + [7.5150943, 0.7055973, 1.0], + [0.0, 1.0, 1.0], + [0.2073275, 0.8944352, 1.0], + [1.694259, 0.7952898, 1.0], + [4.963602, 0.71330947, 1.0], + [4.4856033, 0.76623136, 1.0], + [0.0, 1.0, 1.0], + [0.42934892, 0.91428727, 1.0], + [1.7023742, 0.81033534, 1.0], + [4.653338, 0.67880553, 1.0], + [4.1607633, 0.7217924, 1.0], + [0.0, 1.0, 1.0], + [0.7662586, 0.92551315, 1.0], + [1.281609, 0.8594126, 1.0], + [4.2112913, 0.7075525, 1.0], + [3.2996652, 0.7721644, 1.0], + [0.0, 1.0, 1.0], + [0.36315376, 0.96427274, 1.0], + [0.17142291, 0.9671226, 1.0], + [0.3704626, 0.96229166, 1.0], + [0.16843563, 0.98521745, 1.0], + [0.0, 1.0, 1.0], + [0.14203277, 0.980844, 1.0], + [0.08888949, 0.98635364, 1.0], + [0.08142525, 0.99080217, 1.0], + [0.04689812, 0.99329925, 1.0], + [0.0, 1.0, 1.0], + [-0.041995242, 0.97444767, 1.0], + [-0.015486327, 0.9832361, 1.0], + [0.025345497, 0.9886648, 1.0], + [0.061056096, 0.9832061, 1.0], + [0.0, 1.0, 1.0], + [-0.06349996, 0.9847081, 1.0], + [-0.010158759, 0.993606, 1.0], + [0.0070079323, 0.9965746, 1.0], + [0.012292187, 0.9953988, 1.0], + [0.0, 1.0, 1.0], + [-0.02714656, 0.99495846, 1.0], + [-0.0047707465, 0.995372, 1.0], + [-0.011022978, 0.9921308, 1.0], + [-0.0004924384, 0.99648327, 1.0], + [0.0, 1.0, 1.0], + [-0.065880194, 0.99450415, 1.0], + [-0.041692242, 0.99075663, 1.0], + [-0.022810183, 0.9813757, 1.0], + [0.0066275946, 0.9910071, 1.0], + [0.0, 1.0, 1.0], + [-0.051800754, 0.9933403, 1.0], + [-0.031969808, 0.9909193, 1.0], + [-0.0138383275, 0.98399764, 1.0], + [0.0023257341, 0.99193186, 1.0], + [0.0, 1.0, 1.0], + [-0.064492755, 0.99565995, 1.0], + [-0.056850635, 0.993179, 1.0], + [-0.03568528, 0.9925555, 1.0], + [-0.006500367, 0.9941837, 1.0], + [0.0, 1.0, 1.0], + [-0.076604635, 0.9975212, 1.0], + [-0.04835303, 0.99697053, 1.0], + [-0.05276575, 0.9912819, 1.0], + [-0.048627503, 0.98763776, 1.0], + [0.0, 1.0, 1.0], + [-0.07185075, 0.99944353, 1.0], + [-0.049465895, 0.99844307, 1.0], + [-0.03390795, 0.9961937, 1.0], + [-0.01572451, 0.99718726, 1.0], + [0.0, 1.0, 1.0], + [-0.06696204, 0.99883705, 1.0], + [-0.059485506, 0.99909484, 1.0], + [-0.020033833, 0.99961084, 1.0], + [-0.001215999, 0.9999084, 1.0], + [0.0, 1.0, 1.0], + [-0.06329192, 0.9984661, 1.0], + [-0.030843416, 0.99811184, 1.0], + [-0.010421576, 0.99985075, 1.0], + [-0.000277232, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.09967728, 0.9996282, 1.0], + [-0.024422867, 0.9996509, 1.0], + [-0.0013948235, 0.9999416, 1.0], + [-1.7647946e-05, 0.99999917, 1.0], + [0.0, 1.0, 1.0], + [-0.061887965, 0.9996889, 1.0], + [-0.0055153067, 0.9999299, 1.0], + [-0.00014356381, 0.9999968, 1.0], + [-7.3728444e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.06313205, 0.9991679, 1.0], + [-0.0105344625, 0.99976623, 1.0], + [-0.00022694842, 0.99999404, 1.0], + [-6.2425016e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.07309622, 0.998452, 1.0], + [-0.010137941, 0.99966383, 1.0], + [-0.00015650428, 0.999994, 1.0], + [-2.0530616e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.12139271, 0.99886787, 1.0], + [-0.006217619, 0.9999299, 1.0], + [-2.767876e-05, 0.9999996, 1.0], + [-2.8060315e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11297469, 0.9986093, 1.0], + [-0.005666369, 0.99993217, 1.0], + [-2.3441991e-05, 0.99999976, 1.0], + [-1.6374731e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11892667, 0.9984859, 1.0], + [-0.0054892004, 0.9999281, 1.0], + [-1.8891958e-05, 0.9999997, 1.0], + [-1.42802365e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.063609995, 0.9991952, 1.0], + [-0.003612312, 0.9999425, 1.0], + [-5.6865858e-05, 0.99999845, 1.0], + [-2.8125933e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.03687637, 0.99945694, 1.0], + [-0.0022629017, 0.99996173, 1.0], + [-4.7338475e-05, 0.9999989, 1.0], + [-3.8873702e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.066277154, 0.9989287, 1.0], + [-0.0041808677, 0.9999447, 1.0], + [-2.6149986e-05, 0.9999997, 1.0], + [-7.8076916e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18022063, 0.99584043, 1.0], + [-0.009360226, 0.99979895, 1.0], + [-4.573966e-05, 0.99999934, 1.0], + [-4.968432e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.057497833, 0.9985912, 1.0], + [-0.0030033966, 0.99992764, 1.0], + [-2.1283115e-05, 0.9999996, 1.0], + [-7.399287e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.02698668, 0.99940234, 1.0], + [-0.0018560152, 0.9999619, 1.0], + [-0.00011015628, 0.9999977, 1.0], + [-1.6312291e-05, 0.9999996, 1.0], + [0.0, 1.0, 1.0], + [-0.020353582, 0.99962854, 1.0], + [-0.0012829889, 0.99997663, 1.0], + [-4.823795e-05, 0.999999, 1.0], + [-7.425042e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.046696484, 0.9990762, 1.0], + [-0.0027994127, 0.999945, 1.0], + [-1.47203e-05, 0.9999997, 1.0], + [-1.4775334e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11455002, 0.9970088, 1.0], + [-0.008082145, 0.9997811, 1.0], + [-5.997455e-05, 0.9999983, 1.0], + [-2.153083e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.20616151, 0.9932033, 1.0], + [-0.026683627, 0.9989487, 1.0], + [-0.0006210741, 0.9999699, 1.0], + [-1.9223974e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.42821977, 0.98413795, 1.0], + [-0.09130513, 0.9955624, 1.0], + [-0.0039294222, 0.99976504, 1.0], + [-1.938189e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.737228, 0.9656451, 1.0], + [-0.11107813, 0.99402, 1.0], + [-0.005023841, 0.9997979, 1.0], + [-7.540838e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-2.0423653, 0.89530706, 1.0], + [-0.39499432, 0.9775829, 1.0], + [-0.021451676, 0.9990429, 1.0], + [-0.00062327104, 0.9999673, 1.0], + [0.0, 1.0, 1.0], + [-4.7882614, 0.7621558, 1.0], + [-1.4943185, 0.919121, 1.0], + [-0.26310703, 0.9902079, 1.0], + [-0.026021129, 0.9991246, 1.0], + [0.0, 1.0, 1.0], + [-5.967163, 0.70525557, 1.0], + [-6.2708335, 0.7825963, 1.0], + [-3.0123074, 0.9135249, 1.0], + [-0.16591884, 0.99503696, 1.0], + [0.0, 1.0, 1.0], + [-6.747682, 0.693125, 1.0], + [-11.015906, 0.6835952, 1.0], + [-10.588246, 0.7426725, 1.0], + [-1.4011235, 0.96407425, 1.0], + [0.0, 1.0, 1.0], + [-6.8046284, 0.6869158, 1.0], + [-12.226663, 0.67317563, 1.0], + [-14.401511, 0.68768495, 1.0], + [-5.656612, 0.8701306, 1.0], + [0.0, 1.0, 1.0], + [-5.2980156, 0.7554591, 1.0], + [-8.451796, 0.7457393, 1.0], + [-10.891963, 0.76741225, 1.0], + [-4.9737005, 0.8988167, 1.0], + [0.0, 1.0, 1.0], + [-3.7862368, 0.7663066, 1.0], + [-6.968383, 0.74598277, 1.0], + [-7.1698184, 0.82501894, 1.0], + [-3.5572019, 0.9294954, 1.0], + [0.0, 1.0, 1.0], + [-2.0710254, 0.80158305, 1.0], + [-2.644557, 0.82318443, 1.0], + [-2.829198, 0.9057661, 1.0], + [-3.1149986, 0.90927655, 1.0], + [0.0, 1.0, 1.0], + [0.5783251, 0.8887635, 1.0], + [0.14086528, 0.92088306, 1.0], + [-1.3454764, 0.91390127, 1.0], + [-2.1663988, 0.89477795, 1.0], + [0.0, 1.0, 1.0], + [0.90967906, 0.9097722, 1.0], + [0.40966874, 0.96211725, 1.0], + [0.012563121, 0.9758351, 1.0], + [-0.12836997, 0.9668173, 1.0], + [0.0, 1.0, 1.0], + [0.7635332, 0.9177408, 1.0], + [0.26205018, 0.9728322, 1.0], + [0.017903045, 0.99900806, 1.0], + [0.0027794365, 0.9998005, 1.0], + [0.0, 1.0, 1.0], + [0.70256805, 0.9528801, 1.0], + [0.12026947, 0.989671, 1.0], + [0.0014969448, 0.9997942, 1.0], + [4.7565347e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.43385658, 0.97707945, 1.0], + [0.08405786, 0.99513173, 1.0], + [0.001577933, 0.99989617, 1.0], + [2.1989558e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.17016695, 0.990982, 1.0], + [0.020933557, 0.99893415, 1.0], + [0.00020579397, 0.99998814, 1.0], + [1.8093911e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.118497156, 0.9939799, 1.0], + [0.009560065, 0.99949676, 1.0], + [-6.1264276e-05, 0.99999744, 1.0], + [-7.5626076e-06, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.18632784, 0.9930626, 1.0], + [0.016471203, 0.9994041, 1.0], + [1.850781e-05, 0.99999624, 1.0], + [-1.3992527e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.12490073, 0.99491405, 1.0], + [0.009683974, 0.9996138, 1.0], + [5.5344124e-05, 0.9999977, 1.0], + [-6.122279e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.046315443, 0.9974161, 1.0], + [0.0011095641, 0.99975616, 1.0], + [-9.514568e-05, 0.9999985, 1.0], + [-4.2969023e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.06989265, 0.99835086, 1.0], + [0.0046474864, 0.9999081, 1.0], + [8.086032e-06, 0.99999964, 1.0], + [-6.4283086e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.05445273, 0.9984405, 1.0], + [0.0056540556, 0.9999072, 1.0], + [3.1231557e-05, 0.99999934, 1.0], + [-1.4891012e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.049770087, 0.9989236, 1.0], + [0.0066248314, 0.9999419, 1.0], + [5.813844e-05, 0.99999964, 1.0], + [3.4580864e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.011926558, 0.9986787, 1.0], + [0.0030024045, 0.99992526, 1.0], + [5.3523254e-05, 0.9999996, 1.0], + [7.105971e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.0051469407, 0.99917525, 1.0], + [-0.0015877569, 0.99988997, 1.0], + [-0.0019601185, 0.99996835, 1.0], + [-0.00019134225, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [-0.0026346056, 0.9979655, 1.0], + [-0.6399264, 0.9890465, 1.0], + [-1.0591722, 0.9829664, 1.0], + [-0.1191338, 0.9980818, 1.0], + [0.0, 1.0, 1.0], + [-0.33427265, 0.9909866, 1.0], + [-6.1553082, 0.8892826, 1.0], + [-8.770668, 0.85096085, 1.0], + [-1.2606072, 0.9774506, 1.0], + [0.0, 1.0, 1.0], + [-0.47517955, 0.9840748, 1.0], + [-8.44668, 0.82929236, 1.0], + [-13.877107, 0.7396451, 1.0], + [-3.6484365, 0.9244635, 1.0], + [0.0, 1.0, 1.0], + [-0.47713858, 0.9821221, 1.0], + [-11.744733, 0.7621532, 1.0], + [-19.645943, 0.6432617, 1.0], + [-8.809313, 0.82319057, 1.0], + [0.0, 1.0, 1.0], + [-0.22001219, 0.98877585, 1.0], + [-9.559413, 0.79853284, 1.0], + [-19.126335, 0.6495684, 1.0], + [-10.234527, 0.7984638, 1.0], + [0.0, 1.0, 1.0], + [-0.15887243, 0.9931774, 1.0], + [-6.930633, 0.85245496, 1.0], + [-16.76443, 0.69196665, 1.0], + [-9.0214, 0.82428676, 1.0], + [0.0, 1.0, 1.0], + [-0.016224792, 0.9942058, 1.0], + [-0.8251402, 0.9754959, 1.0], + [-3.2789445, 0.9271614, 1.0], + [-1.1487821, 0.97528994, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index a958b9666..260068d1c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -31,7 +31,8 @@ use lightcraft_raw::{ /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). /// 6: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. -pub const LOOK_VERSION: u32 = 6; +/// 7: the bundled NIKON Z 6_2 profile. +pub const LOOK_VERSION: u32 = 7; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -2500,6 +2501,39 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + /// The public Z 6II NEF (skipped without the corpus) takes its colour from the bundled Z 6II profile. It wasn't + /// one of the photos the profile was fitted to. + #[test] + fn corpus_z6ii_uses_the_bundled_profile() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-z6ii-lossless.nef"); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + return; + } + Err(e) => panic!("{}: {e}", path.display()), + }; + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "NIKON Z 6_2").expect("the Z 6II profile is built in"); + let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); + let used = crate::camera_profiles::get("NIKON Z 6_2").expect("Z 6II profile"); + assert_eq!(*used, bundled, "a local NIKON_Z_6_2 profile overrides the built-in one; remove it to run this test"); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(raw.metadata.model.as_deref(), Some("NIKON Z 6_2")); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); + let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); + for (row, want) in look.matrix.0.iter().zip(expected.0) { + for (got, want) in row.iter().zip(want) { + assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); + } + } + assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 49dfad408..41fc9ca2f 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -27,6 +27,7 @@ const MAX_FILE: u64 = 4 << 20; pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index a12cfc326..5ca637774 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -113,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -124,6 +124,14 @@ Without a profile, 123 of the 131 photos accept their own fit; the other 8 open The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. +### Nikon Z 6II + +Fitted from 296 usable photos of 299 Z 6II NEFs (one photographer, 2024 to 2026, Standard and Auto picture controls, lossless 14-bit, with the NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ), 1,099,207 colour pairs. The pairs were collected with Nikon's in-file distortion correction read and the sensor proxy following it (see "Lens-corrected camera JPEGs" above), since the camera corrects every JPEG from these lenses. Measured as above: rendered at 1200 px with `lightcraft-cli render`, mean CIE76 ΔE against each file's embedded JPEG at 200 px. + +For validation every fifth photo in sorted name order (59) was held back and a profile fitted to the other 240 (237 usable). On the held-back photos mean ΔE went from 2.73 with per-file fitting to 2.54 with the profile (median 2.49 to 2.21), 43 closer and 8 farther, the largest by 0.64. With the final profile across all 299 photos it goes from 2.68 to 2.53, but that's in-sample. The few that get noticeably worse are ISO 25600 night shots (up to +2.5), where the camera JPEG's noise reduction changes the colour a lot and a per-photo fit does better. + +The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture control with the Z 24-200, not used for fitting) is accepted with the profile and goes from 5.37 to 5.25. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 46e126f6b..0e1a7bb49 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -816,6 +816,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index cd0fd0935..19edcfc06 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Nikon Z 6II colour profile +- Raws from the Nikon Z 6II now start from a colour profile built from a few hundred Z 6II photos, the same way the + Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo + shows only a little of come out closer to the camera's JPEG. + ### Closer camera colour for lens-corrected raws - Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c392d0890..365cd8130 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -646,6 +646,12 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0040.NEF", "83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15", ), + ( + // Z 6II, NIKKOR Z 24-200 at 70 mm, Vivid picture control (not used to fit the bundled Z 6II profile) + "nef-nikon-z6ii-lossless.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", From ead24aded9ae4bfae95a392a28f3be23abe994b1 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 11:29:27 -0500 Subject: [PATCH 163/315] LR-PROF-CAMERACOLOR: Add a bundled Nikon D5300 camera profile Fitted with lightcraft-cli calibrate to 264 of my own D5300 photos. --- README.md | 2 +- README.zh-hans.md | 2 +- assets/ATTRIBUTION.md | 1 + assets/camera-profiles/NIKON_D5300.json | 1819 +++++++++++++++++++++++ crates/engine/src/camera_profiles.rs | 1 + docs/camera-preview-colour.md | 6 +- docs/parity-checklist.md | 3 +- docs/whats-new.md | 4 +- 8 files changed, 1832 insertions(+), 6 deletions(-) create mode 100644 assets/camera-profiles/NIKON_D5300.json diff --git a/README.md b/README.md index c16ded23c..324a3b226 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II and Z 8 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); diff --git a/README.zh-hans.md b/README.zh-hans.md index 0bc8239d6..6371f9012 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II 和 Z 8 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index d3fbc4d7b..2592c5501 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -16,6 +16,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | | `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | diff --git a/assets/camera-profiles/NIKON_D5300.json b/assets/camera-profiles/NIKON_D5300.json new file mode 100644 index 000000000..5ab1f062e --- /dev/null +++ b/assets/camera-profiles/NIKON_D5300.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON D5300", + "files": 264, + "samples": 964687, + "matrix": [ + [1.1916187747366802, -0.24231291824981582, 0.06983947565237258], + [-0.07690142035770187, 1.1730235959883188, -0.10538788116290476], + [0.03036949208278017, -0.9047958593485248, 1.8955506437912675] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [-0.27515984, 1.1516873, 1.0], + [1.6639282, 1.1351492, 1.0], + [3.788241, 1.0706222, 1.0], + [2.7755148, 1.0334021, 1.0], + [0.0, 1.0, 1.0], + [0.14424162, 1.1334472, 1.0], + [1.3097183, 1.1570547, 1.0], + [2.2948222, 1.0653765, 1.0], + [2.48696, 1.0126696, 1.0], + [0.0, 1.0, 1.0], + [0.39059362, 1.108461, 1.0], + [0.91269153, 1.1615747, 1.0], + [1.2193394, 1.1302495, 1.0], + [1.564095, 1.0433257, 1.0], + [0.0, 1.0, 1.0], + [0.37582052, 1.0874242, 1.0], + [0.37180975, 1.12975, 1.0], + [0.08854759, 1.119602, 1.0], + [0.12284424, 1.0544983, 1.0], + [0.0, 1.0, 1.0], + [0.41708267, 1.0888891, 1.0], + [0.06426376, 1.1006688, 1.0], + [-0.8100427, 1.0653179, 1.0], + [-1.3206435, 1.0191233, 1.0], + [0.0, 1.0, 1.0], + [0.13243157, 1.080272, 1.0], + [-0.96863246, 1.0961237, 1.0], + [-2.4892373, 1.0579139, 1.0], + [-3.6368277, 1.0045699, 1.0], + [0.0, 1.0, 1.0], + [-0.5086707, 1.0768719, 1.0], + [-2.4455884, 1.090032, 1.0], + [-4.392341, 1.0430846, 1.0], + [-5.70884, 0.984795, 1.0], + [0.0, 1.0, 1.0], + [-0.9826923, 1.0976027, 1.0], + [-3.1522026, 1.0984454, 1.0], + [-5.0928793, 1.0361301, 1.0], + [-6.190662, 0.9671035, 1.0], + [0.0, 1.0, 1.0], + [-0.83517295, 1.1328652, 1.0], + [-2.3040426, 1.1309464, 1.0], + [-3.782865, 1.0633757, 1.0], + [-5.345632, 0.9701844, 1.0], + [0.0, 1.0, 1.0], + [0.5699928, 1.1778497, 1.0], + [-0.5092877, 1.1737088, 1.0], + [-1.6260748, 1.0975113, 1.0], + [-3.6590147, 0.97431755, 1.0], + [0.0, 1.0, 1.0], + [1.965812, 1.2052466, 1.0], + [1.2205318, 1.1937522, 1.0], + [0.32865468, 1.1070911, 1.0], + [-0.5527478, 0.9777932, 1.0], + [0.0, 1.0, 1.0], + [2.1100607, 1.198459, 1.0], + [2.1056213, 1.1429148, 1.0], + [1.3356481, 1.0316085, 1.0], + [0.5096412, 0.93477476, 1.0], + [0.0, 1.0, 1.0], + [1.4636977, 1.2118869, 1.0], + [1.7222096, 1.1241792, 1.0], + [0.98221314, 1.0024475, 1.0], + [0.21372011, 0.9131384, 1.0], + [0.0, 1.0, 1.0], + [0.5342778, 1.1797847, 1.0], + [0.957812, 1.0956764, 1.0], + [0.90817916, 1.0003859, 1.0], + [0.27506226, 0.91949815, 1.0], + [0.0, 1.0, 1.0], + [0.18069139, 1.1459695, 1.0], + [1.0440427, 1.0779425, 1.0], + [1.4630631, 0.9941303, 1.0], + [1.2396606, 0.87395155, 1.0], + [0.0, 1.0, 1.0], + [0.18410955, 1.0979248, 1.0], + [1.3498847, 1.0526143, 1.0], + [2.2518015, 0.97271824, 1.0], + [2.5790663, 0.85005623, 1.0], + [0.0, 1.0, 1.0], + [0.86806947, 1.0538092, 1.0], + [2.255343, 1.0105029, 1.0], + [3.711173, 0.92738235, 1.0], + [4.5281615, 0.85241896, 1.0], + [0.0, 1.0, 1.0], + [1.1754395, 1.0296917, 1.0], + [2.5134487, 0.98583174, 1.0], + [3.7772336, 0.90998256, 1.0], + [4.45152, 0.85479385, 1.0], + [0.0, 1.0, 1.0], + [1.1445321, 1.0435532, 1.0], + [2.0396032, 0.9995336, 1.0], + [3.0725362, 0.9361685, 1.0], + [3.631579, 0.8885794, 1.0], + [0.0, 1.0, 1.0], + [0.7827189, 1.0589092, 1.0], + [1.0505862, 1.0227683, 1.0], + [1.7957299, 0.98822904, 1.0], + [1.967721, 0.9724715, 1.0], + [0.0, 1.0, 1.0], + [-0.29322907, 1.06687, 1.0], + [-0.08487194, 1.0363688, 1.0], + [0.55795294, 1.0033514, 1.0], + [0.38443246, 0.9958227, 1.0], + [0.0, 1.0, 1.0], + [-0.20798732, 1.0558138, 1.0], + [-0.22798188, 1.0087736, 1.0], + [-0.27102852, 0.97902, 1.0], + [-0.030924844, 0.9963477, 1.0], + [0.0, 1.0, 1.0], + [-0.6630197, 1.0661411, 1.0], + [-1.1126446, 1.0020812, 1.0], + [-0.8216712, 0.96949637, 1.0], + [-0.05875481, 0.99697804, 1.0], + [0.0, 1.0, 1.0], + [-1.306994, 1.0804613, 1.0], + [-1.9421868, 1.0075339, 1.0], + [-0.4435876, 0.9711658, 1.0], + [-0.009687971, 0.9980309, 1.0], + [0.0, 1.0, 1.0], + [-1.1269542, 1.0775944, 1.0], + [-1.3976283, 1.0217266, 1.0], + [-0.24913609, 0.98865783, 1.0], + [-0.0031247279, 0.9994072, 1.0], + [0.0, 1.0, 1.0], + [-1.375694, 1.0512048, 1.0], + [-0.75242716, 1.0082058, 1.0], + [-0.06595289, 0.99760807, 1.0], + [-0.0031345324, 0.99984926, 1.0], + [0.0, 1.0, 1.0], + [-1.7031127, 1.0458598, 1.0], + [-1.1684496, 1.0118368, 1.0], + [-0.1479137, 0.9961412, 1.0], + [-0.0055244397, 0.9997797, 1.0], + [0.0, 1.0, 1.0], + [-1.7284406, 1.0431077, 1.0], + [-0.73593587, 1.002617, 1.0], + [0.02080173, 0.9911072, 1.0], + [0.00041606076, 0.9996808, 1.0], + [0.0, 1.0, 1.0], + [-1.2268276, 1.0388371, 1.0], + [-1.0188804, 1.0028651, 1.0], + [-0.10369495, 0.9951023, 1.0], + [-0.00074021873, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [-0.4187577, 1.0261325, 1.0], + [-1.5957482, 0.9980721, 1.0], + [-0.1547406, 0.99747145, 1.0], + [0.006172743, 0.99982816, 1.0], + [0.0, 1.0, 1.0], + [0.17795917, 1.0130396, 1.0], + [-0.6837107, 0.98732287, 1.0], + [-0.06094904, 0.9973522, 1.0], + [0.004337359, 0.99988484, 1.0], + [0.0, 1.0, 1.0], + [2.0028615, 1.0350925, 1.0], + [0.68680644, 1.000726, 1.0], + [0.019622738, 0.99780047, 1.0], + [0.0016065001, 0.999882, 1.0], + [0.0, 1.0, 1.0], + [2.0603294, 1.019478, 1.0], + [0.38180757, 0.983154, 1.0], + [-0.23340763, 0.98130405, 1.0], + [-0.0110910535, 0.9989576, 1.0], + [0.0, 1.0, 1.0], + [2.5435147, 1.0271243, 1.0], + [0.99150276, 0.989704, 1.0], + [-0.28383088, 0.98094606, 1.0], + [-0.020072438, 0.9989133, 1.0], + [0.0, 1.0, 1.0], + [3.08294, 1.0426484, 1.0], + [1.818317, 1.0104465, 1.0], + [0.061887734, 0.99672973, 1.0], + [-0.003478195, 0.99980235, 1.0], + [0.0, 1.0, 1.0], + [3.420556, 1.0468477, 1.0], + [1.3910332, 0.9856526, 1.0], + [0.014045477, 0.992967, 1.0], + [-0.0012650526, 0.99988115, 1.0], + [0.0, 1.0, 1.0], + [1.0608708, 1.0270871, 1.0], + [-0.91338277, 0.92517686, 1.0], + [-0.4462344, 0.9653749, 1.0], + [-0.00868847, 0.99921674, 1.0], + [0.0, 1.0, 1.0], + [-0.41799447, 1.0205152, 1.0], + [-1.6170602, 0.9293957, 1.0], + [-0.6444141, 0.96566117, 1.0], + [-0.013291724, 0.99908036, 1.0], + [0.0, 1.0, 1.0], + [0.30684704, 1.0309951, 1.0], + [-0.32407844, 0.9751206, 1.0], + [-0.2714902, 0.9828883, 1.0], + [-0.00732241, 0.9995556, 1.0], + [0.0, 1.0, 1.0], + [0.23834689, 1.0267385, 1.0], + [0.17927068, 0.98710644, 1.0], + [0.18505995, 0.988422, 1.0], + [0.007564359, 0.99952435, 1.0], + [0.0, 1.0, 1.0], + [0.9417192, 1.0186211, 1.0], + [0.4331768, 0.98655975, 1.0], + [0.5120104, 0.973346, 1.0], + [0.046577834, 0.9975678, 1.0], + [0.0, 1.0, 1.0], + [0.37069434, 1.015258, 1.0], + [-0.33970982, 1.0002911, 1.0], + [0.21809609, 0.9738671, 1.0], + [0.05885403, 0.996412, 1.0], + [0.0, 1.0, 1.0], + [0.3410674, 1.0226996, 1.0], + [-0.46905938, 1.0094384, 1.0], + [1.1913799, 0.9839222, 1.0], + [0.27729467, 0.9954679, 1.0], + [0.0, 1.0, 1.0], + [1.8399196, 1.0310917, 1.0], + [1.3589114, 1.0043445, 1.0], + [3.2229822, 0.9866625, 1.0], + [0.79395044, 0.9953453, 1.0], + [0.0, 1.0, 1.0], + [3.6512873, 1.0852343, 1.0], + [3.6366863, 1.0575762, 1.0], + [4.409545, 1.0124124, 1.0], + [1.2255954, 0.9980612, 1.0], + [0.0, 1.0, 1.0], + [3.5640118, 1.103434, 1.0], + [3.857803, 1.0989252, 1.0], + [5.013495, 1.0628691, 1.0], + [2.1981053, 1.0128543, 1.0], + [0.0, 1.0, 1.0], + [3.750485, 1.1216393, 1.0], + [4.4771504, 1.1242076, 1.0], + [5.554026, 1.098979, 1.0], + [4.0766697, 1.0487705, 1.0], + [0.0, 1.0, 1.0], + [3.716854, 1.1420878, 1.0], + [3.9434807, 1.149656, 1.0], + [4.698269, 1.1275396, 1.0], + [3.355159, 1.0637993, 1.0], + [0.0, 1.0, 1.0], + [3.1019642, 1.152162, 1.0], + [3.018406, 1.1701936, 1.0], + [3.5190942, 1.1440179, 1.0], + [1.6282337, 1.0440116, 1.0], + [0.0, 1.0, 1.0], + [3.0533535, 1.1430386, 1.0], + [2.6682806, 1.1609282, 1.0], + [1.6297065, 1.1209588, 1.0], + [0.3274113, 1.0186641, 1.0], + [0.0, 1.0, 1.0], + [2.7143104, 1.1457591, 1.0], + [2.010889, 1.1753439, 1.0], + [1.1698594, 1.1809243, 1.0], + [0.26954132, 1.0447038, 1.0], + [0.0, 1.0, 1.0], + [1.9450912, 1.1625692, 1.0], + [1.4925617, 1.1576607, 1.0], + [0.73646855, 1.1099164, 1.0], + [0.041240156, 1.0114015, 1.0], + [0.0, 1.0, 1.0], + [1.4923553, 1.1628035, 1.0], + [0.9318059, 1.1155237, 1.0], + [-0.08695841, 1.0332942, 1.0], + [-0.048176043, 1.0041637, 1.0], + [0.0, 1.0, 1.0], + [1.084317, 1.1418072, 1.0], + [0.077919334, 1.1205045, 1.0], + [-0.37862262, 1.0519394, 1.0], + [-0.020748373, 1.0024971, 1.0], + [0.0, 1.0, 1.0], + [0.70962113, 1.1346613, 1.0], + [0.040982615, 1.1304044, 1.0], + [-0.59360516, 1.0895824, 1.0], + [-0.044442892, 1.0063348, 1.0], + [0.0, 1.0, 1.0], + [0.044502545, 1.1331831, 1.0], + [-0.31845695, 1.1328877, 1.0], + [-0.70814407, 1.0888221, 1.0], + [-0.053025763, 1.0063571, 1.0], + [0.0, 1.0, 1.0], + [0.102819525, 1.0983768, 1.0], + [-0.41320392, 1.0724556, 1.0], + [-0.34162834, 1.0306761, 1.0], + [-0.015480887, 1.0013198, 1.0], + [0.0, 1.0, 1.0], + [-0.45333293, 1.1152055, 1.0], + [-0.36607388, 1.0825105, 1.0], + [-0.061023094, 1.0260044, 1.0], + [-0.00076291023, 1.0008935, 1.0], + [0.0, 1.0, 1.0], + [-1.5181267, 1.1252426, 1.0], + [-0.7772274, 1.0954831, 1.0], + [0.07648249, 1.0305958, 1.0], + [0.007827844, 1.0011276, 1.0], + [0.0, 1.0, 1.0], + [-2.7173598, 1.1436788, 1.0], + [-1.1496428, 1.0798888, 1.0], + [0.13708575, 1.0121282, 1.0], + [0.0056166523, 1.0002311, 1.0], + [0.0, 1.0, 1.0], + [-2.8031726, 1.1485914, 1.0], + [-1.1996536, 1.065685, 1.0], + [-0.12146866, 1.0105418, 1.0], + [-0.001331464, 1.000203, 1.0], + [0.0, 1.0, 1.0], + [-3.7381613, 1.1670295, 1.0], + [-2.4761944, 1.0762824, 1.0], + [-0.30495355, 1.010074, 1.0], + [-0.003095148, 1.0001698, 1.0], + [0.0, 1.0, 1.0], + [-2.7574484, 1.1367188, 1.0], + [-1.0683595, 1.0591327, 1.0], + [0.3032825, 1.0109986, 1.0], + [0.0240107, 1.0003924, 1.0], + [0.0, 1.0, 1.0], + [-4.4444375, 1.1478848, 1.0], + [-2.10396, 1.077343, 1.0], + [0.39878115, 1.0108056, 1.0], + [0.04858238, 1.0006601, 1.0], + [0.0, 1.0, 1.0], + [-3.6460211, 1.1262141, 1.0], + [-0.94227576, 1.0764561, 1.0], + [0.46847248, 1.0121009, 1.0], + [0.056671463, 1.0005428, 1.0], + [0.0, 1.0, 1.0], + [-3.941849, 1.1401461, 1.0], + [-1.4121957, 1.0967879, 1.0], + [0.3250404, 1.0172433, 1.0], + [0.05624255, 1.0007424, 1.0], + [0.0, 1.0, 1.0], + [-3.3003352, 1.1373972, 1.0], + [-1.2239021, 1.0964172, 1.0], + [0.4203376, 1.0171689, 1.0], + [0.097768836, 1.0010349, 1.0], + [0.0, 1.0, 1.0], + [-1.3678734, 1.1021602, 1.0], + [0.18997674, 1.0791068, 1.0], + [1.9053582, 1.0272077, 1.0], + [0.36325264, 1.0027457, 1.0], + [0.0, 1.0, 1.0], + [-1.888127, 1.1030134, 1.0], + [0.9222494, 1.0793839, 1.0], + [4.407062, 1.0317812, 1.0], + [1.2517463, 1.0053726, 1.0], + [0.0, 1.0, 1.0], + [-0.87627596, 1.1100941, 1.0], + [2.1658633, 1.0810852, 1.0], + [6.2091784, 1.0344337, 1.0], + [2.7591643, 1.0083429, 1.0], + [0.0, 1.0, 1.0], + [0.58831537, 1.1441597, 1.0], + [3.26025, 1.1017427, 1.0], + [5.9591794, 1.0396717, 1.0], + [2.5558283, 1.009373, 1.0], + [0.0, 1.0, 1.0], + [0.6157835, 1.1522069, 1.0], + [2.7376304, 1.1033771, 1.0], + [5.0446105, 1.0609956, 1.0], + [2.545954, 1.0267637, 1.0], + [0.0, 1.0, 1.0], + [2.567323, 1.050905, 1.0], + [3.219447, 1.086401, 1.0], + [5.309476, 1.0623287, 1.0], + [5.3261275, 1.0507418, 1.0], + [0.0, 1.0, 1.0], + [2.4884005, 1.039593, 1.0], + [2.2780461, 1.0956311, 1.0], + [3.2725804, 1.0421693, 1.0], + [3.57421, 1.0101761, 1.0], + [0.0, 1.0, 1.0], + [2.709007, 1.016824, 1.0], + [1.9036032, 1.1006229, 1.0], + [1.8250041, 1.09863, 1.0], + [2.2753541, 1.0252498, 1.0], + [0.0, 1.0, 1.0], + [2.8476596, 1.0087867, 1.0], + [1.692206, 1.0733916, 1.0], + [0.98378277, 1.0820075, 1.0], + [0.71567744, 1.0277224, 1.0], + [0.0, 1.0, 1.0], + [2.7584476, 1.0095731, 1.0], + [1.4141955, 1.0379248, 1.0], + [0.30707088, 1.0278721, 1.0], + [-0.48991957, 0.99570155, 1.0], + [0.0, 1.0, 1.0], + [2.3251288, 1.0099978, 1.0], + [0.40751106, 1.034089, 1.0], + [-1.266682, 1.0152951, 1.0], + [-2.8792267, 0.97016555, 1.0], + [0.0, 1.0, 1.0], + [1.676751, 1.0196159, 1.0], + [-1.0043784, 1.0388186, 1.0], + [-3.3858006, 1.0105125, 1.0], + [-5.2836957, 0.95619494, 1.0], + [0.0, 1.0, 1.0], + [1.4230912, 1.0371325, 1.0], + [-1.6468483, 1.0507761, 1.0], + [-4.205406, 1.0050639, 1.0], + [-5.696899, 0.9382988, 1.0], + [0.0, 1.0, 1.0], + [2.215277, 1.0757174, 1.0], + [-0.13039589, 1.0800881, 1.0], + [-2.5378978, 1.0185709, 1.0], + [-4.5910883, 0.941271, 1.0], + [0.0, 1.0, 1.0], + [3.7552316, 1.1195593, 1.0], + [1.9935799, 1.1178633, 1.0], + [-0.1950342, 1.0336235, 1.0], + [-2.9394403, 0.9406914, 1.0], + [0.0, 1.0, 1.0], + [4.6019835, 1.1451467, 1.0], + [3.4129214, 1.1365347, 1.0], + [2.2300456, 1.0304257, 1.0], + [0.6699211, 0.8971019, 1.0], + [0.0, 1.0, 1.0], + [4.556115, 1.1341692, 1.0], + [3.8200648, 1.0857233, 1.0], + [3.1856856, 0.96298456, 1.0], + [2.2841954, 0.8578174, 1.0], + [0.0, 1.0, 1.0], + [3.4975002, 1.1417533, 1.0], + [2.8865607, 1.0605808, 1.0], + [2.4713562, 0.93972397, 1.0], + [1.982328, 0.83509517, 1.0], + [0.0, 1.0, 1.0], + [2.0893435, 1.1080577, 1.0], + [1.7293227, 1.0274619, 1.0], + [1.6507962, 0.9354451, 1.0], + [1.3905607, 0.8366557, 1.0], + [0.0, 1.0, 1.0], + [1.6415445, 1.0806029, 1.0], + [1.506959, 1.0187534, 1.0], + [1.6388204, 0.9282385, 1.0], + [1.8973097, 0.82975906, 1.0], + [0.0, 1.0, 1.0], + [1.7991877, 1.0542749, 1.0], + [2.0936208, 1.0012176, 1.0], + [2.6603212, 0.9100169, 1.0], + [3.5249443, 0.80724543, 1.0], + [0.0, 1.0, 1.0], + [2.1576753, 1.0242636, 1.0], + [2.7907417, 0.9638929, 1.0], + [3.652071, 0.87338793, 1.0], + [4.5204115, 0.78994405, 1.0], + [0.0, 1.0, 1.0], + [2.1243021, 1.008548, 1.0], + [2.7552125, 0.9425203, 1.0], + [3.522211, 0.8644462, 1.0], + [4.1264706, 0.8124369, 1.0], + [0.0, 1.0, 1.0], + [1.8834304, 1.0225105, 1.0], + [2.3353438, 0.9708129, 1.0], + [3.0901859, 0.9099647, 1.0], + [3.7211735, 0.86348975, 1.0], + [0.0, 1.0, 1.0], + [1.4581822, 1.0330464, 1.0], + [1.5594033, 0.99513304, 1.0], + [2.0912547, 0.96644175, 1.0], + [2.5819116, 0.94535977, 1.0], + [0.0, 1.0, 1.0], + [0.60412645, 1.0405735, 1.0], + [0.5539686, 1.0038283, 1.0], + [0.8588483, 0.9796771, 1.0], + [0.7651864, 0.98082924, 1.0], + [0.0, 1.0, 1.0], + [0.19108707, 1.0352545, 1.0], + [-0.16049986, 0.99251735, 1.0], + [-0.5080386, 0.96194357, 1.0], + [-0.33787724, 0.98708415, 1.0], + [0.0, 1.0, 1.0], + [-0.24877733, 1.0316597, 1.0], + [-0.70576507, 0.97597045, 1.0], + [-0.8016234, 0.9442628, 1.0], + [-0.11087948, 0.9908073, 1.0], + [0.0, 1.0, 1.0], + [-0.8790754, 1.0514538, 1.0], + [-1.4108989, 0.9916971, 1.0], + [-1.0544907, 0.9481936, 1.0], + [-0.10507189, 0.99393654, 1.0], + [0.0, 1.0, 1.0], + [-1.3867714, 1.0623717, 1.0], + [-2.1656637, 1.0158321, 1.0], + [-1.1665387, 0.9772894, 1.0], + [-0.049957793, 0.99827534, 1.0], + [0.0, 1.0, 1.0], + [-2.4962778, 1.047387, 1.0], + [-3.1633992, 1.0073104, 1.0], + [-1.4553053, 0.95946413, 1.0], + [-0.13852488, 0.99207044, 1.0], + [0.0, 1.0, 1.0], + [-3.1582537, 1.039661, 1.0], + [-3.3594494, 0.99911463, 1.0], + [-1.4694877, 0.9412946, 1.0], + [-0.15061137, 0.9906891, 1.0], + [0.0, 1.0, 1.0], + [-3.7993233, 1.040663, 1.0], + [-3.003692, 0.9947964, 1.0], + [-0.8132878, 0.94909513, 1.0], + [-0.09164102, 0.99424165, 1.0], + [0.0, 1.0, 1.0], + [-3.1300888, 1.0289713, 1.0], + [-2.916435, 0.98529565, 1.0], + [-0.3949024, 0.9686243, 1.0], + [0.057513624, 0.99605733, 1.0], + [0.0, 1.0, 1.0], + [-2.870443, 1.0128809, 1.0], + [-3.22342, 0.97333175, 1.0], + [1.0390259, 0.9529574, 1.0], + [0.3107253, 0.99131966, 1.0], + [0.0, 1.0, 1.0], + [-1.9573697, 1.0006245, 1.0], + [-1.8365934, 0.9674953, 1.0], + [0.7572013, 0.9682651, 1.0], + [0.17691553, 0.9942757, 1.0], + [0.0, 1.0, 1.0], + [-0.28490174, 1.0081447, 1.0], + [-0.29309836, 0.97951233, 1.0], + [0.18393767, 0.98060596, 1.0], + [0.007778787, 0.997274, 1.0], + [0.0, 1.0, 1.0], + [0.2726132, 0.99976957, 1.0], + [-0.4088234, 0.95960116, 1.0], + [0.0077163163, 0.9512176, 1.0], + [0.019507347, 0.9959901, 1.0], + [0.0, 1.0, 1.0], + [0.7798813, 0.9985067, 1.0], + [0.76253676, 0.9651048, 1.0], + [-0.1965283, 0.95622796, 1.0], + [-0.024494128, 0.99734217, 1.0], + [0.0, 1.0, 1.0], + [1.2395806, 1.0156081, 1.0], + [1.9043974, 0.98719865, 1.0], + [0.2547382, 0.9865172, 1.0], + [-0.0017040195, 0.99939626, 1.0], + [0.0, 1.0, 1.0], + [2.3556528, 1.0269231, 1.0], + [1.6541926, 0.94798446, 1.0], + [0.19142385, 0.96310043, 1.0], + [0.00077345694, 0.99907255, 1.0], + [0.0, 1.0, 1.0], + [0.8628551, 1.0025055, 1.0], + [-0.8925193, 0.86057365, 1.0], + [-0.89507776, 0.8785938, 1.0], + [-0.02676659, 0.9942501, 1.0], + [0.0, 1.0, 1.0], + [-0.69604295, 0.9841022, 1.0], + [-1.5614027, 0.86573595, 1.0], + [-1.0682615, 0.8759204, 1.0], + [-0.053552028, 0.992165, 1.0], + [0.0, 1.0, 1.0], + [-1.3843973, 0.9732926, 1.0], + [-1.6747307, 0.9024153, 1.0], + [-1.1190381, 0.90966296, 1.0], + [-0.0430858, 0.995535, 1.0], + [0.0, 1.0, 1.0], + [-1.4726397, 0.9728779, 1.0], + [-0.48264512, 0.9410372, 1.0], + [0.31483912, 0.93758917, 1.0], + [0.029700607, 0.99565494, 1.0], + [0.0, 1.0, 1.0], + [-1.5699832, 0.9680764, 1.0], + [-0.5216335, 0.9524717, 1.0], + [1.1201954, 0.9122498, 1.0], + [0.2576845, 0.9783837, 1.0], + [0.0, 1.0, 1.0], + [-1.9922228, 0.9569479, 1.0], + [-1.1839337, 0.9620515, 1.0], + [0.49700478, 0.90824383, 1.0], + [0.2757639, 0.9656649, 1.0], + [0.0, 1.0, 1.0], + [-1.8267179, 0.97236925, 1.0], + [-1.3503194, 0.98332787, 1.0], + [-0.21534178, 0.9398797, 1.0], + [0.3592183, 0.9531863, 1.0], + [0.0, 1.0, 1.0], + [-1.3845943, 0.9576693, 1.0], + [-0.88324434, 0.95311546, 1.0], + [1.0021961, 0.92629516, 1.0], + [1.4048795, 0.9467104, 1.0], + [0.0, 1.0, 1.0], + [0.21868584, 0.9868099, 1.0], + [1.2409965, 0.98529285, 1.0], + [3.478748, 0.96618646, 1.0], + [3.547536, 0.9636579, 1.0], + [0.0, 1.0, 1.0], + [0.72944003, 1.011865, 1.0], + [2.2030432, 1.0459692, 1.0], + [4.3416624, 1.0307989, 1.0], + [4.5565796, 1.0013582, 1.0], + [0.0, 1.0, 1.0], + [0.7054098, 1.0304452, 1.0], + [2.7193584, 1.0797284, 1.0], + [4.6252937, 1.0782566, 1.0], + [5.346714, 1.0554028, 1.0], + [0.0, 1.0, 1.0], + [0.8206882, 1.0563322, 1.0], + [2.66612, 1.105401, 1.0], + [4.257176, 1.1014127, 1.0], + [5.333192, 1.0896508, 1.0], + [0.0, 1.0, 1.0], + [0.6858709, 1.0573044, 1.0], + [2.1161485, 1.1302112, 1.0], + [3.9404676, 1.1377732, 1.0], + [4.928983, 1.1188648, 1.0], + [0.0, 1.0, 1.0], + [0.4658943, 1.0244572, 1.0], + [1.4814435, 1.1276039, 1.0], + [2.0604527, 1.1611818, 1.0], + [1.7115017, 1.0927407, 1.0], + [0.0, 1.0, 1.0], + [0.66739404, 1.0136315, 1.0], + [1.41378, 1.1620725, 1.0], + [1.2858282, 1.2084693, 1.0], + [0.8847436, 1.1387153, 1.0], + [0.0, 1.0, 1.0], + [0.22088455, 1.0020697, 1.0], + [0.8503165, 1.1110431, 1.0], + [0.8370134, 1.1636014, 1.0], + [0.16764726, 1.0484313, 1.0], + [0.0, 1.0, 1.0], + [-0.032334913, 0.99697274, 1.0], + [-0.12844399, 1.0419574, 1.0], + [-0.9318103, 1.0669361, 1.0], + [-0.2286045, 1.0165362, 1.0], + [0.0, 1.0, 1.0], + [-0.1669066, 0.99338514, 1.0], + [-0.8307293, 1.0627798, 1.0], + [-1.2913208, 1.0905485, 1.0], + [-0.08585524, 1.0079323, 1.0], + [0.0, 1.0, 1.0], + [0.017506301, 0.9974502, 1.0], + [-0.4714752, 1.0923178, 1.0], + [-1.2507538, 1.1427293, 1.0], + [-0.15650667, 1.0198009, 1.0], + [0.0, 1.0, 1.0], + [0.5337322, 0.98601866, 1.0], + [0.4784788, 1.0887345, 1.0], + [-0.95893174, 1.1386591, 1.0], + [-0.15236506, 1.0194373, 1.0], + [0.0, 1.0, 1.0], + [0.78718185, 0.9737713, 1.0], + [0.8975688, 1.024816, 1.0], + [-0.2945547, 1.0546055, 1.0], + [-0.03156037, 1.0033555, 1.0], + [0.0, 1.0, 1.0], + [0.24685815, 0.98656416, 1.0], + [0.55160105, 1.0425034, 1.0], + [-0.11143197, 1.0422429, 1.0], + [-0.003670774, 1.0018142, 1.0], + [0.0, 1.0, 1.0], + [-0.77873033, 1.0003996, 1.0], + [0.19153872, 1.0427284, 1.0], + [0.37716967, 1.041646, 1.0], + [0.016879126, 1.0023743, 1.0], + [0.0, 1.0, 1.0], + [-1.7304914, 1.0388753, 1.0], + [-0.7251529, 1.0577725, 1.0], + [0.34579277, 1.0164489, 1.0], + [0.009596101, 1.0004158, 1.0], + [0.0, 1.0, 1.0], + [-1.6364738, 1.067764, 1.0], + [-0.9167525, 1.0778942, 1.0], + [0.103218675, 1.0217229, 1.0], + [0.0034776113, 1.0004051, 1.0], + [0.0, 1.0, 1.0], + [-1.7374398, 1.0553571, 1.0], + [-0.8997327, 1.072175, 1.0], + [0.018124623, 1.0228853, 1.0], + [0.0031013442, 1.0004023, 1.0], + [0.0, 1.0, 1.0], + [-1.9461782, 1.0329045, 1.0], + [-0.9284798, 1.0640512, 1.0], + [0.5232761, 1.0302181, 1.0], + [0.0713816, 1.0012505, 1.0], + [0.0, 1.0, 1.0], + [-1.7830942, 1.0167092, 1.0], + [-1.0461223, 1.0536364, 1.0], + [0.71468955, 1.031544, 1.0], + [0.1528099, 1.0023446, 1.0], + [0.0, 1.0, 1.0], + [-0.901455, 1.0192658, 1.0], + [0.3789207, 1.0608308, 1.0], + [1.4712181, 1.0287849, 1.0], + [0.17150511, 1.0013422, 1.0], + [0.0, 1.0, 1.0], + [-1.4228317, 1.0136043, 1.0], + [-0.4306419, 1.0521969, 1.0], + [0.79203665, 1.0235791, 1.0], + [0.17930758, 1.0008605, 1.0], + [0.0, 1.0, 1.0], + [-0.89692557, 1.0078237, 1.0], + [0.22381781, 1.0376579, 1.0], + [1.2225298, 1.0229918, 1.0], + [0.30199468, 1.0024537, 1.0], + [0.0, 1.0, 1.0], + [0.96971756, 0.998565, 1.0], + [2.4854388, 1.0386328, 1.0], + [5.093825, 1.0409368, 1.0], + [1.5859087, 1.0088303, 1.0], + [0.0, 1.0, 1.0], + [2.0574582, 0.99701333, 1.0], + [3.9885697, 1.0335468, 1.0], + [7.3134866, 1.02023, 1.0], + [4.876025, 1.0077791, 1.0], + [0.0, 1.0, 1.0], + [2.9773273, 1.0016919, 1.0], + [4.8199377, 1.0256822, 1.0], + [7.568321, 1.0072774, 1.0], + [6.5177317, 0.99610305, 1.0], + [0.0, 1.0, 1.0], + [3.3437927, 1.0140332, 1.0], + [4.7947435, 1.036412, 1.0], + [7.550965, 1.0280272, 1.0], + [6.326854, 1.0115026, 1.0], + [0.0, 1.0, 1.0], + [2.9055889, 1.0224042, 1.0], + [3.8671165, 1.0454612, 1.0], + [6.3752146, 1.0593462, 1.0], + [5.871097, 1.0545919, 1.0], + [0.0, 1.0, 1.0], + [4.4711437, 0.89240575, 1.0], + [3.8707209, 0.975318, 1.0], + [5.54684, 1.0295455, 1.0], + [5.4792643, 1.0330564, 1.0], + [0.0, 1.0, 1.0], + [4.5822687, 0.86561245, 1.0], + [3.369101, 0.9498307, 1.0], + [4.0309362, 0.99107826, 1.0], + [4.1218996, 0.97818476, 1.0], + [0.0, 1.0, 1.0], + [4.7380633, 0.856774, 1.0], + [3.2887201, 0.9503168, 1.0], + [2.9449494, 1.0068572, 1.0], + [3.3715508, 0.97625136, 1.0], + [0.0, 1.0, 1.0], + [4.9660296, 0.8707871, 1.0], + [3.211986, 0.9548858, 1.0], + [1.9611124, 1.0027668, 1.0], + [1.4650759, 0.9794454, 1.0], + [0.0, 1.0, 1.0], + [4.919991, 0.8776082, 1.0], + [2.96986, 0.92894495, 1.0], + [1.43205, 0.9510039, 1.0], + [0.48539585, 0.9417541, 1.0], + [0.0, 1.0, 1.0], + [4.378387, 0.883634, 1.0], + [2.1083007, 0.919261, 1.0], + [0.5318521, 0.9173686, 1.0], + [-0.7582033, 0.896065, 1.0], + [0.0, 1.0, 1.0], + [3.8246558, 0.8962875, 1.0], + [0.9034894, 0.928397, 1.0], + [-1.1259035, 0.92528087, 1.0], + [-3.3147051, 0.8988185, 1.0], + [0.0, 1.0, 1.0], + [4.153484, 0.91500145, 1.0], + [0.48695508, 0.940206, 1.0], + [-2.1672661, 0.9259778, 1.0], + [-3.7963305, 0.88363916, 1.0], + [0.0, 1.0, 1.0], + [5.7889557, 0.9511915, 1.0], + [2.4093935, 0.9637889, 1.0], + [-0.28017357, 0.9176229, 1.0], + [-1.7246062, 0.86704445, 1.0], + [0.0, 1.0, 1.0], + [6.9804792, 1.0035709, 1.0], + [4.4882298, 0.9964915, 1.0], + [2.0359013, 0.9096808, 1.0], + [0.44025534, 0.8560936, 1.0], + [0.0, 1.0, 1.0], + [7.1492724, 1.0114399, 1.0], + [5.4943357, 1.0019382, 1.0], + [4.05172, 0.8888965, 1.0], + [2.2645717, 0.7902095, 1.0], + [0.0, 1.0, 1.0], + [6.758939, 0.99153984, 1.0], + [5.431977, 0.95972574, 1.0], + [4.6545696, 0.84344786, 1.0], + [3.4948368, 0.77209806, 1.0], + [0.0, 1.0, 1.0], + [5.388168, 0.986505, 1.0], + [4.070344, 0.92283, 1.0], + [3.5232341, 0.83252263, 1.0], + [3.0174634, 0.7662801, 1.0], + [0.0, 1.0, 1.0], + [3.7689843, 0.96089333, 1.0], + [2.5116751, 0.8785204, 1.0], + [2.3376532, 0.8223458, 1.0], + [2.414712, 0.7608956, 1.0], + [0.0, 1.0, 1.0], + [3.2908578, 0.9508312, 1.0], + [2.2224586, 0.88289684, 1.0], + [2.228365, 0.8166788, 1.0], + [2.6414301, 0.7484262, 1.0], + [0.0, 1.0, 1.0], + [3.3916478, 0.93619686, 1.0], + [2.823667, 0.87227386, 1.0], + [3.0609424, 0.79408795, 1.0], + [3.626718, 0.72579795, 1.0], + [0.0, 1.0, 1.0], + [3.1597862, 0.9192608, 1.0], + [3.008894, 0.85837466, 1.0], + [3.3871465, 0.79258764, 1.0], + [3.9049165, 0.7342933, 1.0], + [0.0, 1.0, 1.0], + [2.9172287, 0.9121524, 1.0], + [2.6648562, 0.85023427, 1.0], + [2.947294, 0.8032925, 1.0], + [3.3702457, 0.76370627, 1.0], + [0.0, 1.0, 1.0], + [2.5077455, 0.9239658, 1.0], + [2.4199598, 0.87722534, 1.0], + [2.618067, 0.84640473, 1.0], + [2.995166, 0.8136889, 1.0], + [0.0, 1.0, 1.0], + [1.9437534, 0.9129008, 1.0], + [1.9712167, 0.87672514, 1.0], + [2.1036656, 0.8706977, 1.0], + [2.0733418, 0.87078476, 1.0], + [0.0, 1.0, 1.0], + [1.4246463, 0.91300803, 1.0], + [1.1752675, 0.8862555, 1.0], + [0.9968104, 0.90140474, 1.0], + [0.23840506, 0.97830564, 1.0], + [0.0, 1.0, 1.0], + [0.67706054, 0.9196341, 1.0], + [0.109584466, 0.9024811, 1.0], + [-0.27386528, 0.9419742, 1.0], + [-0.059076753, 0.9978306, 1.0], + [0.0, 1.0, 1.0], + [0.07224172, 0.93229127, 1.0], + [-0.34362468, 0.914439, 1.0], + [-0.17629111, 0.96382296, 1.0], + [-0.011366743, 0.9988268, 1.0], + [0.0, 1.0, 1.0], + [-0.81656486, 0.955494, 1.0], + [-1.0913409, 0.94884336, 1.0], + [-0.44080573, 0.982739, 1.0], + [-0.030075505, 0.9989954, 1.0], + [0.0, 1.0, 1.0], + [-1.3378361, 0.9686959, 1.0], + [-1.5182222, 0.96549654, 1.0], + [-0.4606443, 0.9918533, 1.0], + [-0.019811057, 0.9994661, 1.0], + [0.0, 1.0, 1.0], + [-2.243623, 0.95517683, 1.0], + [-2.0646274, 0.93799746, 1.0], + [-0.90104544, 0.95197743, 1.0], + [-0.10999995, 0.99141395, 1.0], + [0.0, 1.0, 1.0], + [-2.8076265, 0.9409817, 1.0], + [-2.058747, 0.9154531, 1.0], + [-1.5804225, 0.88738924, 1.0], + [-0.23936853, 0.9801497, 1.0], + [0.0, 1.0, 1.0], + [-3.9064386, 0.95459986, 1.0], + [-2.3988228, 0.896239, 1.0], + [-1.7098186, 0.8685506, 1.0], + [-0.25361952, 0.9790636, 1.0], + [0.0, 1.0, 1.0], + [-4.2093053, 0.92354167, 1.0], + [-2.2733746, 0.9118433, 1.0], + [0.16433056, 0.9224389, 1.0], + [0.32686478, 0.98159236, 1.0], + [0.0, 1.0, 1.0], + [-4.749688, 0.9219642, 1.0], + [-1.7706362, 0.91556644, 1.0], + [1.4995979, 0.9179761, 1.0], + [0.58829814, 0.9714674, 1.0], + [0.0, 1.0, 1.0], + [-3.9969661, 0.9133036, 1.0], + [-1.1233542, 0.9314691, 1.0], + [0.91808987, 0.9250387, 1.0], + [0.36256704, 0.96524554, 1.0], + [0.0, 1.0, 1.0], + [-2.4866922, 0.91120887, 1.0], + [-0.59065604, 0.9415378, 1.0], + [-0.78037834, 0.9368204, 1.0], + [-0.3913863, 0.9704481, 1.0], + [0.0, 1.0, 1.0], + [-2.3026705, 0.90438557, 1.0], + [-0.8718288, 0.94404954, 1.0], + [-0.15336794, 0.977961, 1.0], + [-0.09629151, 0.9925991, 1.0], + [0.0, 1.0, 1.0], + [-1.9070312, 0.90280366, 1.0], + [-0.55553955, 0.935856, 1.0], + [0.25413927, 0.988254, 1.0], + [0.009861473, 0.9995575, 1.0], + [0.0, 1.0, 1.0], + [-1.9049702, 0.9080516, 1.0], + [-0.266718, 0.9349882, 1.0], + [0.13215524, 0.9933759, 1.0], + [0.0041504498, 0.99984777, 1.0], + [0.0, 1.0, 1.0], + [-0.8701549, 0.90498465, 1.0], + [1.0092131, 0.8918811, 1.0], + [0.3660149, 0.9696158, 1.0], + [0.0074352697, 0.9991519, 1.0], + [0.0, 1.0, 1.0], + [-1.0172404, 0.8942851, 1.0], + [0.6829563, 0.8349779, 1.0], + [0.48213816, 0.91718537, 1.0], + [0.016518895, 0.99731606, 1.0], + [0.0, 1.0, 1.0], + [-1.7463626, 0.8773616, 1.0], + [-0.36728278, 0.80656576, 1.0], + [-0.05267751, 0.8861593, 1.0], + [-0.0062499205, 0.99655586, 1.0], + [0.0, 1.0, 1.0], + [-2.645922, 0.85724914, 1.0], + [-1.6585516, 0.8101157, 1.0], + [-0.77995527, 0.8782376, 1.0], + [-0.014658492, 0.99659693, 1.0], + [0.0, 1.0, 1.0], + [-3.6578364, 0.8668074, 1.0], + [-2.0196826, 0.8535868, 1.0], + [-0.37624604, 0.921359, 1.0], + [0.00017735251, 0.99736357, 1.0], + [0.0, 1.0, 1.0], + [-4.0841975, 0.8596372, 1.0], + [-2.4710217, 0.8740145, 1.0], + [-0.1209378, 0.920483, 1.0], + [0.045910586, 0.99135536, 1.0], + [0.0, 1.0, 1.0], + [-4.430148, 0.85118824, 1.0], + [-2.7032092, 0.8731887, 1.0], + [-0.7946189, 0.8594394, 1.0], + [-0.09909849, 0.9567725, 1.0], + [0.0, 1.0, 1.0], + [-4.8029838, 0.8495463, 1.0], + [-2.6373093, 0.8836435, 1.0], + [-1.0274059, 0.83784634, 1.0], + [-0.34142062, 0.8863734, 1.0], + [0.0, 1.0, 1.0], + [-5.0252833, 0.8179336, 1.0], + [-3.0253406, 0.8344459, 1.0], + [-1.3676816, 0.83761764, 1.0], + [0.08377362, 0.8903176, 1.0], + [0.0, 1.0, 1.0], + [-3.5889053, 0.8333059, 1.0], + [-1.7982966, 0.8581034, 1.0], + [-0.067006715, 0.87093455, 1.0], + [1.3765932, 0.9137696, 1.0], + [0.0, 1.0, 1.0], + [-2.6394725, 0.8462031, 1.0], + [-0.5374585, 0.88883096, 1.0], + [1.3434483, 0.91526973, 1.0], + [2.196534, 0.948764, 1.0], + [0.0, 1.0, 1.0], + [-1.8830673, 0.91449887, 1.0], + [0.3436676, 0.948412, 1.0], + [2.4071665, 0.9718761, 1.0], + [3.2197835, 0.9905023, 1.0], + [0.0, 1.0, 1.0], + [-2.1540444, 0.9479222, 1.0], + [0.77035385, 0.9906655, 1.0], + [3.5416546, 1.0201747, 1.0], + [4.4329867, 1.036617, 1.0], + [0.0, 1.0, 1.0], + [-2.6539636, 0.8664964, 1.0], + [0.31695533, 0.9838804, 1.0], + [3.5114965, 1.0515474, 1.0], + [4.3682404, 1.0739245, 1.0], + [0.0, 1.0, 1.0], + [-2.889564, 0.7832875, 1.0], + [-0.47233972, 0.94827306, 1.0], + [1.9158661, 1.0925105, 1.0], + [1.7773509, 1.0825669, 1.0], + [0.0, 1.0, 1.0], + [-2.359935, 0.7418968, 1.0], + [0.13793384, 0.9809765, 1.0], + [1.3180828, 1.1471351, 1.0], + [0.7645996, 1.0599663, 1.0], + [0.0, 1.0, 1.0], + [-2.2418065, 0.7301813, 1.0], + [0.35360616, 0.91816384, 1.0], + [0.9692918, 1.0641508, 1.0], + [0.07664488, 1.006114, 1.0], + [0.0, 1.0, 1.0], + [-2.0775743, 0.7516581, 1.0], + [-0.124465264, 0.8922322, 1.0], + [0.04122935, 0.9999263, 1.0], + [-0.016318215, 1.0011346, 1.0], + [0.0, 1.0, 1.0], + [-1.3793565, 0.7586109, 1.0], + [0.04257845, 0.8988754, 1.0], + [-0.082526416, 0.98730445, 1.0], + [-0.0054138596, 0.9999747, 1.0], + [0.0, 1.0, 1.0], + [-0.505196, 0.7803437, 1.0], + [0.40779415, 0.90821487, 1.0], + [-0.1287433, 1.0026283, 1.0], + [-0.011499701, 1.0009819, 1.0], + [0.0, 1.0, 1.0], + [-0.058046483, 0.78796697, 1.0], + [0.93765765, 0.894442, 1.0], + [0.043037504, 1.004575, 1.0], + [-0.008396565, 1.0010221, 1.0], + [0.0, 1.0, 1.0], + [-0.043535788, 0.8018015, 1.0], + [0.80214524, 0.9042569, 1.0], + [0.15616187, 0.9971092, 1.0], + [-0.00031897958, 1.0001391, 1.0], + [0.0, 1.0, 1.0], + [-0.13094118, 0.81996274, 1.0], + [-0.057957847, 0.9010969, 1.0], + [-0.15578985, 0.9838379, 1.0], + [-0.0028997594, 0.9998916, 1.0], + [0.0, 1.0, 1.0], + [-0.24140343, 0.8421498, 1.0], + [-0.33087662, 0.8944223, 1.0], + [-0.11538279, 0.96010375, 1.0], + [-0.0023777992, 0.99934727, 1.0], + [0.0, 1.0, 1.0], + [-0.6291469, 0.8663977, 1.0], + [-0.93443865, 0.8860551, 1.0], + [-0.52833366, 0.9360109, 1.0], + [-0.013429848, 0.99847513, 1.0], + [0.0, 1.0, 1.0], + [-0.90612704, 0.8846548, 1.0], + [-1.0758501, 0.9156333, 1.0], + [-0.31288663, 0.95635, 1.0], + [-0.0065308507, 0.9988607, 1.0], + [0.0, 1.0, 1.0], + [-0.8505977, 0.8680036, 1.0], + [-0.6228383, 0.9419045, 1.0], + [0.008568136, 0.99068564, 1.0], + [0.00062457524, 0.9997916, 1.0], + [0.0, 1.0, 1.0], + [-1.3649141, 0.8661397, 1.0], + [-1.1351817, 0.9570879, 1.0], + [-0.4343645, 0.99677765, 1.0], + [-0.007229383, 0.9998938, 1.0], + [0.0, 1.0, 1.0], + [-0.3357523, 0.86801755, 1.0], + [-0.53919494, 0.9580416, 1.0], + [-0.3332547, 1.0017875, 1.0], + [0.0020524773, 1.0001712, 1.0], + [0.0, 1.0, 1.0], + [1.0389299, 0.87367016, 1.0], + [1.5910575, 0.95255834, 1.0], + [1.0409293, 0.9986148, 1.0], + [0.06169785, 0.99989563, 1.0], + [0.0, 1.0, 1.0], + [0.69167626, 0.864867, 1.0], + [0.517416, 0.9371735, 1.0], + [0.7297484, 0.9740146, 1.0], + [0.14100057, 0.9978946, 1.0], + [0.0, 1.0, 1.0], + [1.1966501, 0.8564259, 1.0], + [0.7971627, 0.9295377, 1.0], + [0.98976326, 0.9878759, 1.0], + [0.10663256, 0.9997394, 1.0], + [0.0, 1.0, 1.0], + [2.9381518, 0.84076357, 1.0], + [2.7677243, 0.9219802, 1.0], + [4.2827673, 0.9931431, 1.0], + [0.7215513, 1.0000048, 1.0], + [0.0, 1.0, 1.0], + [4.0477877, 0.82670283, 1.0], + [3.4655888, 0.91149926, 1.0], + [5.5720844, 0.97649837, 1.0], + [2.4715145, 0.9948263, 1.0], + [0.0, 1.0, 1.0], + [4.8419666, 0.8251362, 1.0], + [4.0095496, 0.90738267, 1.0], + [5.8746896, 0.9661354, 1.0], + [4.1081214, 0.98024684, 1.0], + [0.0, 1.0, 1.0], + [4.5143023, 0.835298, 1.0], + [3.7496395, 0.9078515, 1.0], + [5.115886, 0.9541289, 1.0], + [3.3494966, 0.97551304, 1.0], + [0.0, 1.0, 1.0], + [4.479901, 0.8648893, 1.0], + [3.820371, 0.9325536, 1.0], + [5.326134, 0.97972375, 1.0], + [4.717001, 1.0019616, 1.0], + [0.0, 1.0, 1.0], + [6.5529094, 0.68653643, 1.0], + [4.249971, 0.8358243, 1.0], + [3.886182, 0.9036481, 1.0], + [3.1052408, 0.93986005, 1.0], + [0.0, 1.0, 1.0], + [6.0453534, 0.69864804, 1.0], + [4.088292, 0.8079499, 1.0], + [4.4767594, 0.8848239, 1.0], + [4.920347, 0.8965313, 1.0], + [0.0, 1.0, 1.0], + [5.8496194, 0.71566397, 1.0], + [4.111261, 0.7927633, 1.0], + [4.182716, 0.8936798, 1.0], + [4.9267282, 0.9027427, 1.0], + [0.0, 1.0, 1.0], + [6.4675403, 0.7260139, 1.0], + [4.864559, 0.7873533, 1.0], + [3.662903, 0.8703417, 1.0], + [3.33834, 0.89759904, 1.0], + [0.0, 1.0, 1.0], + [6.627538, 0.73187256, 1.0], + [4.7362914, 0.7925029, 1.0], + [2.762565, 0.85528374, 1.0], + [1.6968315, 0.8718531, 1.0], + [0.0, 1.0, 1.0], + [6.1378064, 0.74603015, 1.0], + [3.7333937, 0.8095638, 1.0], + [1.7157373, 0.84111804, 1.0], + [0.7313545, 0.84846157, 1.0], + [0.0, 1.0, 1.0], + [5.8278027, 0.73261493, 1.0], + [3.218722, 0.7893107, 1.0], + [1.7261162, 0.8081886, 1.0], + [1.3808451, 0.80620646, 1.0], + [0.0, 1.0, 1.0], + [6.5486107, 0.735842, 1.0], + [3.34487, 0.7877779, 1.0], + [1.9852514, 0.7963513, 1.0], + [3.2250917, 0.7770536, 1.0], + [0.0, 1.0, 1.0], + [8.3918, 0.7492594, 1.0], + [5.8002057, 0.76848954, 1.0], + [3.2627735, 0.7929065, 1.0], + [4.1515646, 0.7706826, 1.0], + [0.0, 1.0, 1.0], + [9.339295, 0.7832515, 1.0], + [7.126709, 0.778491, 1.0], + [3.6896281, 0.8016613, 1.0], + [3.1497123, 0.78488094, 1.0], + [0.0, 1.0, 1.0], + [9.593153, 0.7601979, 1.0], + [7.8867235, 0.7450819, 1.0], + [5.9123063, 0.7422206, 1.0], + [3.8759134, 0.74508727, 1.0], + [0.0, 1.0, 1.0], + [8.585278, 0.7479115, 1.0], + [7.529606, 0.7175019, 1.0], + [6.162009, 0.7064114, 1.0], + [4.4188056, 0.7255969, 1.0], + [0.0, 1.0, 1.0], + [6.858168, 0.7368539, 1.0], + [5.492259, 0.7144793, 1.0], + [4.347751, 0.70099527, 1.0], + [3.5681868, 0.7093534, 1.0], + [0.0, 1.0, 1.0], + [5.2282605, 0.73686594, 1.0], + [3.2710242, 0.70864916, 1.0], + [2.9356287, 0.6760259, 1.0], + [3.0854452, 0.64669275, 1.0], + [0.0, 1.0, 1.0], + [4.4789567, 0.74254066, 1.0], + [2.5880811, 0.7186022, 1.0], + [2.387628, 0.66940564, 1.0], + [2.4265919, 0.6265305, 1.0], + [0.0, 1.0, 1.0], + [4.528232, 0.7247535, 1.0], + [2.7541575, 0.7074213, 1.0], + [2.5300722, 0.6434275, 1.0], + [2.7180758, 0.5980046, 1.0], + [0.0, 1.0, 1.0], + [4.102856, 0.728164, 1.0], + [2.6525943, 0.71301305, 1.0], + [2.7899814, 0.67262053, 1.0], + [3.1614673, 0.6270317, 1.0], + [0.0, 1.0, 1.0], + [3.6348686, 0.73153895, 1.0], + [2.726432, 0.6877879, 1.0], + [2.54031, 0.7097242, 1.0], + [2.7431397, 0.7150978, 1.0], + [0.0, 1.0, 1.0], + [2.9502785, 0.69625, 1.0], + [2.6242392, 0.6294526, 1.0], + [2.2081497, 0.66211057, 1.0], + [1.3193871, 0.82756114, 1.0], + [0.0, 1.0, 1.0], + [2.0414326, 0.6955794, 1.0], + [2.0578754, 0.6632495, 1.0], + [1.7014283, 0.7117629, 1.0], + [0.3407154, 0.9488139, 1.0], + [0.0, 1.0, 1.0], + [1.1289076, 0.7163192, 1.0], + [1.151557, 0.7172398, 1.0], + [0.52380514, 0.86909467, 1.0], + [0.019359209, 0.9961916, 1.0], + [0.0, 1.0, 1.0], + [0.67299646, 0.7355766, 1.0], + [0.51002353, 0.7648463, 1.0], + [0.06944082, 0.96125567, 1.0], + [0.000119109, 0.9998196, 1.0], + [0.0, 1.0, 1.0], + [-0.031164411, 0.77996194, 1.0], + [0.014347508, 0.86290824, 1.0], + [-0.007677018, 0.99158084, 1.0], + [-6.689423e-05, 0.9999727, 1.0], + [0.0, 1.0, 1.0], + [-0.6832901, 0.8222584, 1.0], + [-0.3419607, 0.92960227, 1.0], + [-0.022380346, 0.9983181, 1.0], + [-0.00018521023, 0.9999936, 1.0], + [0.0, 1.0, 1.0], + [-1.0638311, 0.89357316, 1.0], + [-0.2623169, 0.97307545, 1.0], + [-0.008422446, 0.99948406, 1.0], + [-0.00014290158, 0.99999386, 1.0], + [0.0, 1.0, 1.0], + [-1.1319717, 0.89954185, 1.0], + [-0.28591886, 0.9751361, 1.0], + [-0.01649866, 0.9978075, 1.0], + [-0.0016126642, 0.99978495, 1.0], + [0.0, 1.0, 1.0], + [-2.0644026, 0.86974543, 1.0], + [-0.26208007, 0.9613298, 1.0], + [-0.054121625, 0.9739053, 1.0], + [-0.018682964, 0.996442, 1.0], + [0.0, 1.0, 1.0], + [-1.9141648, 0.88052386, 1.0], + [-0.27626938, 0.93415976, 1.0], + [-0.24882263, 0.9263471, 1.0], + [-0.06974276, 0.9880197, 1.0], + [0.0, 1.0, 1.0], + [-2.0521605, 0.8621233, 1.0], + [-0.4830994, 0.962234, 1.0], + [-0.02020153, 0.9847871, 1.0], + [0.019950174, 0.99770623, 1.0], + [0.0, 1.0, 1.0], + [-3.3087368, 0.84192723, 1.0], + [-0.65065837, 0.9700698, 1.0], + [0.11342574, 0.99164987, 1.0], + [0.050568797, 0.99678665, 1.0], + [0.0, 1.0, 1.0], + [-3.7450078, 0.8366232, 1.0], + [-0.49405074, 0.9702762, 1.0], + [0.107314065, 0.9858985, 1.0], + [0.053864308, 0.99334574, 1.0], + [0.0, 1.0, 1.0], + [-2.5509698, 0.8247367, 1.0], + [-0.20142983, 0.96617097, 1.0], + [-0.2560394, 0.981325, 1.0], + [-0.1159319, 0.9916455, 1.0], + [0.0, 1.0, 1.0], + [-1.5854938, 0.81745756, 1.0], + [-0.14268312, 0.97136134, 1.0], + [-0.06509467, 0.9953569, 1.0], + [-0.029058143, 0.9980467, 1.0], + [0.0, 1.0, 1.0], + [-0.8367958, 0.8402703, 1.0], + [-0.10091935, 0.97176695, 1.0], + [-0.0010153912, 0.9995565, 1.0], + [-3.0446154e-05, 0.9999878, 1.0], + [0.0, 1.0, 1.0], + [-2.1198392, 0.84143376, 1.0], + [-0.03249274, 0.952964, 1.0], + [0.012029766, 0.99843806, 1.0], + [8.835511e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-1.9262636, 0.86367226, 1.0], + [0.5532922, 0.93086356, 1.0], + [0.09160686, 0.99354273, 1.0], + [0.00081093685, 0.9999354, 1.0], + [0.0, 1.0, 1.0], + [-1.3739588, 0.8016463, 1.0], + [1.2970295, 0.7846802, 1.0], + [0.45966804, 0.9381906, 1.0], + [0.0061520343, 0.99921715, 1.0], + [0.0, 1.0, 1.0], + [-1.4077954, 0.7208454, 1.0], + [0.4733461, 0.6787829, 1.0], + [0.47692603, 0.80988336, 1.0], + [0.012340703, 0.9957405, 1.0], + [0.0, 1.0, 1.0], + [-2.542054, 0.69510156, 1.0], + [-1.0206543, 0.66685224, 1.0], + [-0.3861955, 0.77343684, 1.0], + [-0.0016055064, 0.9925781, 1.0], + [0.0, 1.0, 1.0], + [-4.949766, 0.7236445, 1.0], + [-2.2197113, 0.72735757, 1.0], + [-0.622677, 0.88092417, 1.0], + [-0.015985137, 0.996581, 1.0], + [0.0, 1.0, 1.0], + [-6.6489305, 0.68735474, 1.0], + [-3.5802984, 0.78218496, 1.0], + [-0.5320879, 0.92443025, 1.0], + [-0.022485942, 0.9953605, 1.0], + [0.0, 1.0, 1.0], + [-7.177222, 0.67137307, 1.0], + [-3.1258667, 0.780959, 1.0], + [-1.202351, 0.83195996, 1.0], + [-0.23152742, 0.9466141, 1.0], + [0.0, 1.0, 1.0], + [-7.1036143, 0.67803013, 1.0], + [-3.811027, 0.75741714, 1.0], + [-1.5931911, 0.8057145, 1.0], + [-0.4153525, 0.8942517, 1.0], + [0.0, 1.0, 1.0], + [-6.7684326, 0.6596717, 1.0], + [-5.32512, 0.69404334, 1.0], + [-3.1082761, 0.7684906, 1.0], + [-1.2460512, 0.894522, 1.0], + [0.0, 1.0, 1.0], + [-5.8683157, 0.6405401, 1.0], + [-4.9210896, 0.68566376, 1.0], + [-6.001439, 0.7500565, 1.0], + [-7.3015804, 0.812211, 1.0], + [0.0, 1.0, 1.0], + [-5.0999904, 0.6463381, 1.0], + [-3.7537494, 0.71320873, 1.0], + [-3.3960533, 0.7802192, 1.0], + [-3.351788, 0.85274297, 1.0], + [0.0, 1.0, 1.0], + [-4.22911, 0.68255895, 1.0], + [-2.943262, 0.75258833, 1.0], + [-2.539806, 0.79581076, 1.0], + [-1.4436098, 0.85693127, 1.0], + [0.0, 1.0, 1.0], + [-4.421207, 0.6897405, 1.0], + [-2.546169, 0.7558628, 1.0], + [-0.93006057, 0.8137628, 1.0], + [0.48019657, 0.90172833, 1.0], + [0.0, 1.0, 1.0], + [-4.955394, 0.63199955, 1.0], + [-3.2294798, 0.7054303, 1.0], + [-1.6279404, 0.7905365, 1.0], + [0.42406368, 0.95621544, 1.0], + [0.0, 1.0, 1.0], + [-4.406013, 0.61143196, 1.0], + [-2.4224486, 0.6885289, 1.0], + [0.15226217, 0.94315904, 1.0], + [0.3682204, 1.0079356, 1.0], + [0.0, 1.0, 1.0], + [-2.6070106, 0.59719855, 1.0], + [-0.5420019, 0.6935155, 1.0], + [0.49049014, 0.98263836, 1.0], + [0.093591675, 1.0030893, 1.0], + [0.0, 1.0, 1.0], + [-1.4122118, 0.62467295, 1.0], + [0.61009103, 0.7372408, 1.0], + [0.48575926, 0.9820844, 1.0], + [0.025345104, 1.0000371, 1.0], + [0.0, 1.0, 1.0], + [-0.9076017, 0.68767226, 1.0], + [0.656146, 0.8025899, 1.0], + [0.37165537, 0.9719272, 1.0], + [0.009354364, 0.9996975, 1.0], + [0.0, 1.0, 1.0], + [-0.519386, 0.7165023, 1.0], + [0.67302865, 0.8780051, 1.0], + [0.5275675, 0.94928, 1.0], + [0.022246271, 0.9979151, 1.0], + [0.0, 1.0, 1.0], + [0.09680573, 0.738186, 1.0], + [0.793014, 0.8750658, 1.0], + [0.381764, 0.9613324, 1.0], + [0.009429865, 0.99904037, 1.0], + [0.0, 1.0, 1.0], + [1.8834604, 0.77035594, 1.0], + [1.7601224, 0.8644663, 1.0], + [0.14496735, 0.98939514, 1.0], + [0.00014458288, 0.999937, 1.0], + [0.0, 1.0, 1.0], + [1.8881173, 0.79435354, 1.0], + [2.1193182, 0.87771165, 1.0], + [0.18743913, 0.9930873, 1.0], + [0.00059748656, 0.999982, 1.0], + [0.0, 1.0, 1.0], + [0.8984065, 0.7908348, 1.0], + [0.8870811, 0.86748314, 1.0], + [0.060655933, 0.9830568, 1.0], + [-0.00023914597, 0.9998897, 1.0], + [0.0, 1.0, 1.0], + [-0.052016236, 0.79481345, 1.0], + [-0.2686036, 0.8499417, 1.0], + [-0.24796923, 0.94703746, 1.0], + [-0.00565591, 0.99915326, 1.0], + [0.0, 1.0, 1.0], + [-1.2217782, 0.81121093, 1.0], + [-1.5196391, 0.8464336, 1.0], + [-0.6995659, 0.93161744, 1.0], + [-0.015096771, 0.99854493, 1.0], + [0.0, 1.0, 1.0], + [-1.9182163, 0.7860046, 1.0], + [-1.7838149, 0.8694839, 1.0], + [-0.3889935, 0.966303, 1.0], + [-0.0060358397, 0.99937606, 1.0], + [0.0, 1.0, 1.0], + [-1.4767361, 0.76479936, 1.0], + [-0.9574383, 0.89968514, 1.0], + [-0.093257755, 0.9917394, 1.0], + [-0.0010219052, 0.99989015, 1.0], + [0.0, 1.0, 1.0], + [-1.1596841, 0.762179, 1.0], + [-1.0841764, 0.8807078, 1.0], + [-0.13100374, 0.99240035, 1.0], + [-0.0010621684, 0.9999584, 1.0], + [0.0, 1.0, 1.0], + [-0.6849198, 0.7800176, 1.0], + [-1.0038462, 0.8779829, 1.0], + [-0.09007008, 0.9921065, 1.0], + [-0.0005381258, 0.9999664, 1.0], + [0.0, 1.0, 1.0], + [0.28321254, 0.7977584, 1.0], + [-0.16388126, 0.890086, 1.0], + [0.03192479, 0.9929511, 1.0], + [0.001241174, 0.9999684, 1.0], + [0.0, 1.0, 1.0], + [0.86829543, 0.7936024, 1.0], + [-0.37591735, 0.89833724, 1.0], + [-0.14845619, 0.9868604, 1.0], + [0.00082641013, 0.99980897, 1.0], + [0.0, 1.0, 1.0], + [2.0176198, 0.7752524, 1.0], + [1.6908506, 0.8934624, 1.0], + [0.44986808, 0.9873634, 1.0], + [0.008859725, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [3.6487887, 0.748154, 1.0], + [2.6644409, 0.8853934, 1.0], + [0.99475205, 0.98551035, 1.0], + [0.050193034, 0.9997457, 1.0], + [0.0, 1.0, 1.0], + [4.480023, 0.7250523, 1.0], + [1.926729, 0.8424514, 1.0], + [0.040736295, 0.92375875, 1.0], + [0.051179513, 0.99580204, 1.0], + [0.0, 1.0, 1.0], + [5.1756477, 0.71742564, 1.0], + [1.3003006, 0.80706257, 1.0], + [-1.707145, 0.8457335, 1.0], + [-0.49415466, 0.96840185, 1.0], + [0.0, 1.0, 1.0], + [4.975246, 0.7181208, 1.0], + [2.2769587, 0.81511086, 1.0], + [-0.6684549, 0.8240774, 1.0], + [-1.1093187, 0.9021424, 1.0], + [0.0, 1.0, 1.0], + [5.6670837, 0.7181022, 1.0], + [3.5531816, 0.83718723, 1.0], + [2.3386168, 0.85608834, 1.0], + [1.0339524, 0.91459143, 1.0], + [0.0, 1.0, 1.0], + [4.161144, 0.74439704, 1.0], + [1.2685431, 0.90642077, 1.0], + [1.2386372, 0.91409504, 1.0], + [0.9295507, 0.9615406, 1.0], + [0.0, 1.0, 1.0], + [4.1777663, 0.7336535, 1.0], + [1.6435673, 0.89623827, 1.0], + [4.6341896, 0.8448458, 1.0], + [5.328556, 0.8743724, 1.0], + [0.0, 1.0, 1.0], + [3.8456101, 0.74945605, 1.0], + [1.7118593, 0.8730719, 1.0], + [4.7893443, 0.8741666, 1.0], + [5.7356167, 0.8831293, 1.0], + [0.0, 1.0, 1.0], + [4.541483, 0.68340665, 1.0], + [3.1775188, 0.750212, 1.0], + [2.7961404, 0.89943826, 1.0], + [2.240247, 0.94229096, 1.0], + [0.0, 1.0, 1.0], + [5.315433, 0.6658273, 1.0], + [4.4101477, 0.6992003, 1.0], + [2.1726768, 0.87961835, 1.0], + [0.8891901, 0.96283895, 1.0], + [0.0, 1.0, 1.0], + [5.594221, 0.6638776, 1.0], + [5.8431273, 0.65800124, 1.0], + [5.264457, 0.7205041, 1.0], + [2.3550959, 0.89265925, 1.0], + [0.0, 1.0, 1.0], + [6.089242, 0.6326394, 1.0], + [7.3321614, 0.59986347, 1.0], + [8.751942, 0.6218272, 1.0], + [7.835094, 0.73740655, 1.0], + [0.0, 1.0, 1.0], + [6.8648624, 0.6189883, 1.0], + [7.854025, 0.5970248, 1.0], + [9.556352, 0.6305344, 1.0], + [9.962623, 0.70967585, 1.0], + [0.0, 1.0, 1.0], + [8.106201, 0.6309747, 1.0], + [8.538228, 0.6175027, 1.0], + [9.568188, 0.6525294, 1.0], + [9.985585, 0.7052805, 1.0], + [0.0, 1.0, 1.0], + [8.281834, 0.64497524, 1.0], + [7.932338, 0.6421112, 1.0], + [6.0639887, 0.72733206, 1.0], + [5.3430767, 0.77500343, 1.0], + [0.0, 1.0, 1.0], + [8.019873, 0.6824282, 1.0], + [7.110651, 0.6669911, 1.0], + [5.331789, 0.7284526, 1.0], + [3.3326175, 0.8244816, 1.0], + [0.0, 1.0, 1.0], + [7.284438, 0.6928456, 1.0], + [7.2835546, 0.6664346, 1.0], + [4.883378, 0.72301525, 1.0], + [1.6401588, 0.8878237, 1.0], + [0.0, 1.0, 1.0], + [5.3979216, 0.7423805, 1.0], + [5.322938, 0.73012125, 1.0], + [2.8791666, 0.7680131, 1.0], + [1.2293904, 0.8762204, 1.0], + [0.0, 1.0, 1.0], + [3.9055657, 0.8100785, 1.0], + [3.3159246, 0.7462504, 1.0], + [2.3902402, 0.6859098, 1.0], + [1.833428, 0.74496704, 1.0], + [0.0, 1.0, 1.0], + [2.3105671, 0.8353257, 1.0], + [1.5842186, 0.76920885, 1.0], + [1.6503354, 0.6779311, 1.0], + [1.5094707, 0.6925992, 1.0], + [0.0, 1.0, 1.0], + [1.6453714, 0.83882755, 1.0], + [1.2651672, 0.7836908, 1.0], + [1.7122288, 0.64048153, 1.0], + [1.8864015, 0.6182712, 1.0], + [0.0, 1.0, 1.0], + [1.4681439, 0.84673464, 1.0], + [1.016748, 0.7926556, 1.0], + [1.5301808, 0.6895232, 1.0], + [1.7087327, 0.6774361, 1.0], + [0.0, 1.0, 1.0], + [1.2655282, 0.8380442, 1.0], + [1.400092, 0.7222438, 1.0], + [0.9317295, 0.751648, 1.0], + [0.38670647, 0.9075669, 1.0], + [0.0, 1.0, 1.0], + [0.99011, 0.8104178, 1.0], + [1.5600725, 0.6673882, 1.0], + [0.8698622, 0.75667554, 1.0], + [0.05312782, 0.9771482, 1.0], + [0.0, 1.0, 1.0], + [0.57059133, 0.84510267, 1.0], + [0.82562035, 0.7819899, 1.0], + [0.26795024, 0.91805077, 1.0], + [0.007893546, 0.9974623, 1.0], + [0.0, 1.0, 1.0], + [-0.025850505, 0.885807, 1.0], + [0.16424465, 0.907354, 1.0], + [0.02480482, 0.9894822, 1.0], + [0.0002904646, 0.9998891, 1.0], + [0.0, 1.0, 1.0], + [0.0051441705, 0.905744, 1.0], + [0.052886356, 0.94857466, 1.0], + [0.003892918, 0.99737555, 1.0], + [1.5296924e-05, 0.9999911, 1.0], + [0.0, 1.0, 1.0], + [-0.13219802, 0.9432007, 1.0], + [0.0067049917, 0.9855628, 1.0], + [0.00048594674, 0.99958736, 1.0], + [1.3410622e-06, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-0.10593759, 0.96154904, 1.0], + [-0.007101243, 0.9942198, 1.0], + [-5.9125785e-05, 0.99992687, 1.0], + [-1.7132953e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.1380907, 0.98662883, 1.0], + [-0.0150175765, 0.99846256, 1.0], + [-0.00010678602, 0.9999852, 1.0], + [-7.602713e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13843897, 0.99187297, 1.0], + [-0.014456171, 0.9993438, 1.0], + [-0.00010089983, 0.9999929, 1.0], + [-1.5361991e-06, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-0.25314486, 0.9803601, 1.0], + [-0.015702732, 0.9983346, 1.0], + [-0.0008912772, 0.99966294, 1.0], + [-0.00028706418, 0.9999485, 1.0], + [0.0, 1.0, 1.0], + [-0.13319662, 0.9884858, 1.0], + [-0.008045408, 0.9985032, 1.0], + [-0.00546511, 0.9987218, 1.0], + [-0.0013116907, 0.9997901, 1.0], + [0.0, 1.0, 1.0], + [-0.14743957, 0.98442274, 1.0], + [-0.010547765, 0.9988189, 1.0], + [-0.00075808156, 0.99986076, 1.0], + [-8.594952e-05, 0.99998194, 1.0], + [0.0, 1.0, 1.0], + [-0.37524244, 0.9815919, 1.0], + [-0.030857285, 0.9987118, 1.0], + [-7.657946e-06, 0.9999798, 1.0], + [7.531104e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-0.30199113, 0.97909504, 1.0], + [-0.019221604, 0.99835676, 1.0], + [0.000142119, 0.99995494, 1.0], + [0.000114501025, 0.99998343, 1.0], + [0.0, 1.0, 1.0], + [-0.07426342, 0.9718809, 1.0], + [0.006918636, 0.99745023, 1.0], + [-0.00086405553, 0.99991477, 1.0], + [-0.0004274567, 0.99996984, 1.0], + [0.0, 1.0, 1.0], + [0.1214249, 0.97155046, 1.0], + [0.020241432, 0.9978329, 1.0], + [-6.842438e-05, 0.9999722, 1.0], + [-0.000104643506, 0.99999297, 1.0], + [0.0, 1.0, 1.0], + [-0.00912883, 0.98002875, 1.0], + [0.009914126, 0.998089, 1.0], + [0.00014658475, 0.99998343, 1.0], + [-3.147069e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.34022465, 0.97413856, 1.0], + [-0.001044419, 0.99583006, 1.0], + [0.0007858895, 0.9998906, 1.0], + [2.2824117e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.234471, 0.98301566, 1.0], + [0.012073258, 0.9957116, 1.0], + [0.0027358236, 0.9997176, 1.0], + [1.8471012e-05, 0.9999981, 1.0], + [0.0, 1.0, 1.0], + [-0.37233362, 0.96034855, 1.0], + [0.20355557, 0.9555334, 1.0], + [0.040644884, 0.99291646, 1.0], + [0.00042774918, 0.99992293, 1.0], + [0.0, 1.0, 1.0], + [-0.26744676, 0.88313514, 1.0], + [0.25718036, 0.8152884, 1.0], + [0.09190484, 0.9529967, 1.0], + [0.0019344173, 0.999235, 1.0], + [0.0, 1.0, 1.0], + [-0.92890036, 0.8132875, 1.0], + [-0.62145543, 0.75051594, 1.0], + [-0.07648181, 0.9260143, 1.0], + [0.0015313957, 0.9985089, 1.0], + [0.0, 1.0, 1.0], + [-1.4036704, 0.87832355, 1.0], + [-0.62560636, 0.879744, 1.0], + [-0.07835409, 0.97920424, 1.0], + [-0.001020268, 0.9996515, 1.0], + [0.0, 1.0, 1.0], + [-2.5662296, 0.83067703, 1.0], + [-0.6488212, 0.94305354, 1.0], + [-0.052740045, 0.9922627, 1.0], + [-0.0016239518, 0.9996923, 1.0], + [0.0, 1.0, 1.0], + [-4.7540584, 0.71597546, 1.0], + [-2.4036474, 0.8318091, 1.0], + [-0.7855407, 0.9302143, 1.0], + [-0.030755855, 0.9963339, 1.0], + [0.0, 1.0, 1.0], + [-6.401625, 0.6375166, 1.0], + [-4.2524567, 0.73438454, 1.0], + [-1.7984731, 0.8661054, 1.0], + [-0.076227136, 0.9925712, 1.0], + [0.0, 1.0, 1.0], + [-7.093013, 0.57054, 1.0], + [-7.138467, 0.61704755, 1.0], + [-10.662155, 0.7250832, 1.0], + [-4.8741217, 0.8936352, 1.0], + [0.0, 1.0, 1.0], + [-6.7872076, 0.543695, 1.0], + [-9.125988, 0.5788258, 1.0], + [-17.282015, 0.6463619, 1.0], + [-15.361004, 0.7047228, 1.0], + [0.0, 1.0, 1.0], + [-6.5472207, 0.5460788, 1.0], + [-7.3952637, 0.5873735, 1.0], + [-9.985295, 0.6460461, 1.0], + [-9.715605, 0.75660294, 1.0], + [0.0, 1.0, 1.0], + [-6.143126, 0.5428474, 1.0], + [-6.979643, 0.59978616, 1.0], + [-7.3462987, 0.654454, 1.0], + [-4.6714454, 0.7781302, 1.0], + [0.0, 1.0, 1.0], + [-5.6845136, 0.5430165, 1.0], + [-7.2012515, 0.5718516, 1.0], + [-8.748415, 0.59380674, 1.0], + [-4.6859508, 0.76675576, 1.0], + [0.0, 1.0, 1.0], + [-5.1071506, 0.54315025, 1.0], + [-5.8883495, 0.56850153, 1.0], + [-7.7368093, 0.6292215, 1.0], + [-1.4560128, 0.9221482, 1.0], + [0.0, 1.0, 1.0], + [-2.574775, 0.5935165, 1.0], + [-1.5976609, 0.6629436, 1.0], + [-0.42182118, 0.9376009, 1.0], + [0.0032954898, 0.99905676, 1.0], + [0.0, 1.0, 1.0], + [-0.5040222, 0.66888565, 1.0], + [0.22192046, 0.765007, 1.0], + [0.039829105, 0.97707367, 1.0], + [0.0013656623, 0.99990016, 1.0], + [0.0, 1.0, 1.0], + [0.13229428, 0.750091, 1.0], + [0.26277184, 0.88919055, 1.0], + [0.020233026, 0.99522144, 1.0], + [0.00035138454, 0.99998504, 1.0], + [0.0, 1.0, 1.0], + [0.24901535, 0.8024303, 1.0], + [0.3177278, 0.91495854, 1.0], + [0.051968362, 0.9940321, 1.0], + [0.0007878256, 0.9999375, 1.0], + [0.0, 1.0, 1.0], + [0.40787914, 0.8393718, 1.0], + [0.6778435, 0.91640764, 1.0], + [0.2564198, 0.97801465, 1.0], + [0.0053125066, 0.99956983, 1.0], + [0.0, 1.0, 1.0], + [0.9363007, 0.86411923, 1.0], + [0.7308819, 0.92368954, 1.0], + [0.13012709, 0.9888115, 1.0], + [0.0020543067, 0.99983764, 1.0], + [0.0, 1.0, 1.0], + [1.792059, 0.9070631, 1.0], + [0.8328082, 0.961077, 1.0], + [0.03201244, 0.9983668, 1.0], + [0.00010289253, 0.99999255, 1.0], + [0.0, 1.0, 1.0], + [0.61971796, 0.93544394, 1.0], + [0.28986993, 0.98064864, 1.0], + [0.011807639, 0.99949574, 1.0], + [3.4211585e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [0.36694813, 0.9191711, 1.0], + [0.15008779, 0.9748141, 1.0], + [0.004682179, 0.9991603, 1.0], + [5.24191e-06, 0.9999966, 1.0], + [0.0, 1.0, 1.0], + [-0.04590827, 0.94109243, 1.0], + [-0.035157125, 0.9765446, 1.0], + [-0.007650317, 0.99820185, 1.0], + [-0.00011332178, 0.99998254, 1.0], + [0.0, 1.0, 1.0], + [-0.15313512, 0.97539425, 1.0], + [-0.13065065, 0.9864903, 1.0], + [-0.017957566, 0.9982736, 1.0], + [-0.00023828977, 0.99997765, 1.0], + [0.0, 1.0, 1.0], + [-0.32719183, 0.960239, 1.0], + [-0.13606764, 0.9877723, 1.0], + [-0.007543968, 0.99940944, 1.0], + [-6.793774e-05, 0.9999944, 1.0], + [0.0, 1.0, 1.0], + [-0.33494267, 0.960886, 1.0], + [-0.12677892, 0.9896274, 1.0], + [-0.00804112, 0.9995762, 1.0], + [-5.0966377e-05, 0.99999756, 1.0], + [0.0, 1.0, 1.0], + [-0.56956315, 0.94241995, 1.0], + [-0.83334297, 0.9581462, 1.0], + [-0.09486739, 0.9961979, 1.0], + [-0.00065249624, 0.99997574, 1.0], + [0.0, 1.0, 1.0], + [-0.39292914, 0.943953, 1.0], + [-0.43260995, 0.9667326, 1.0], + [-0.036218695, 0.99800736, 1.0], + [-0.00021563526, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [-0.32144308, 0.95278865, 1.0], + [-0.25995246, 0.9713513, 1.0], + [-0.016442856, 0.9985246, 1.0], + [-9.176571e-05, 0.9999942, 1.0], + [0.0, 1.0, 1.0], + [-0.2966282, 0.9744063, 1.0], + [-0.86246514, 0.9668585, 1.0], + [-0.17140412, 0.9942151, 1.0], + [-0.0021118673, 0.9999324, 1.0], + [0.0, 1.0, 1.0], + [0.08769655, 0.97262937, 1.0], + [-0.25923535, 0.9713818, 1.0], + [-0.06498976, 0.99482614, 1.0], + [-0.00078246964, 0.99993557, 1.0], + [0.0, 1.0, 1.0], + [0.5253878, 0.9505631, 1.0], + [-0.16767718, 0.9614315, 1.0], + [-0.24291219, 0.98733723, 1.0], + [-0.009154632, 0.99959785, 1.0], + [0.0, 1.0, 1.0], + [0.57957804, 0.9173889, 1.0], + [-3.944535, 0.831082, 1.0], + [-2.9686522, 0.8881784, 1.0], + [-0.14177988, 0.9944771, 1.0], + [0.0, 1.0, 1.0], + [0.42895818, 0.90997833, 1.0], + [-5.41419, 0.7882363, 1.0], + [-5.786541, 0.80714077, 1.0], + [-0.8622991, 0.97064185, 1.0], + [0.0, 1.0, 1.0], + [1.3849322, 0.89100575, 1.0], + [-3.418151, 0.8283688, 1.0], + [-7.0915565, 0.766481, 1.0], + [-2.9420247, 0.89867216, 1.0], + [0.0, 1.0, 1.0], + [2.6696517, 0.8438243, 1.0], + [-0.2037993, 0.8984032, 1.0], + [-3.0600338, 0.83977926, 1.0], + [-1.4190359, 0.93247384, 1.0] + ], + "srgb_value": true + } +} diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index efbf3506a..70ff6f2df 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -27,6 +27,7 @@ const MAX_FILE: u64 = 4 << 20; pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")), ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 106879fa2..9e1459188 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -113,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -136,6 +136,10 @@ The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023 and 2026, almost all Standard picture control, lossless 14-bit, with the NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the High Efficiency files can't be decoded and were skipped), 969,860 colour pairs, collected the same way as the Z 6II's with Nikon's distortion correction read. Holding back every fifth photo (55) and fitting the other four fifths, mean ΔE on the held-back photos went from 2.55 to 2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by 0.46. The public raw.pixls.us Z 8 file (`nef-nikon-z8-lossless.nef`, Z 14-24 f/2.8 S, not used for fitting) is accepted with the profile and stays about the same, 3.22 to 3.27. +### Nikon D5300 + +Fitted from 264 usable photos of 273 D5300 NEFs (one photographer, 2014 to 2017, mostly Standard picture control with some Portrait and Landscape, lossy 12-bit, nearly all with the AF-S DX 35mm f/1.8G), 964,687 colour pairs. On the 53 held-back photos mean ΔE went from 2.86 to 2.66 (median 2.78 to 2.60), 44 closer and 6 farther, the largest by 0.63. There's no public D5300 sample in the corpus to check it against. + The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile. ## Sony Dynamic Range Optimizer (DRO) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index c7b15805a..383f1703c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -234,7 +234,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) and NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -818,6 +818,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | | 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 8 camera profile; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon D5300 camera profile; status unchanged (🟡) | | 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 9e6f5fea9..779a91e65 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,8 +2,8 @@ ## 2026-10-14 -### Nikon Z 6II and Z 8 colour profiles -- Raws from the Nikon Z 6II and Z 8 now start from a colour profile built from a few hundred photos from each camera, the same way the +### Nikon Z 6II, Z 8 and D5300 colour profiles +- Raws from the Nikon Z 6II, Z 8 and D5300 now start from a colour profile built from a few hundred photos from each camera, the same way the Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo shows only a little of come out closer to the camera's JPEG. From 73c61aae4413fb30cda6d76a2a27ff6ba764c611 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <brandon@storyteller.ai> Date: Wed, 14 Oct 2026 09:21:04 -0400 Subject: [PATCH 164/315] FreeBSD CI: two flaky ui-egui tests made deterministic settings_dialog_by_keyboard_and_clicks: the Performance tab's GPU status hint reads process-wide GPU state, which other tests flip through app.gpu. Without a GPU driver (the FreeBSD VM) the reason is a long wgpu message that wraps to two lines, while "disabled by the GPU rendering preference" is one, so the controls below moved by 20 px between the frame that located "button:settingsMemory-2" and the frame that clicked it. The status is now one truncated line with the full text on hover; gpu_status_never_moves_the_performance_controls checks the buttons stay put (it fails without the fix when no Vulkan driver loads). offline_originals_never_block_a_frame: the slow NAS probe was a 400 ms sleep judged by a 300 ms wall-clock frame budget, which a loaded QEMU VM missed (1.06 s). The probe now waits on a gate the test opens; frames must finish while it is closed (a watchdog records any frame that had to wait), so no timing budget is needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/settings.rs | 5 +- crates/ui-egui/src/tests_dialog_fit.rs | 23 +++++ crates/ui-egui/src/tests_offline.rs | 117 +++++++++++++++++-------- 3 files changed, 107 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a0717ccfd..b95b55b19 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -412,7 +412,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } else { format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) }; - hint(ui, t, &status); + // One line, the whole text on hover: the reason can be a long driver message (no adapter + // among the backends …), and the GPU state is process-wide, so it may change between frames; + // a hint that wrapped would move every control below it under the pointer. + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); row(ui, t, crate::i18n::tr("Preview size"), |ui| { let opts: Vec<(u32, String)> = PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 0014c6bdb..29d0401c2 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -134,3 +134,26 @@ fn a_dialog_list_shrinks_to_fit_a_short_window() { let dialog = rect(&h, "dialog:window"); assert!(dialog.height() <= 320.0, "{dialog:?}"); } + +/// Settings ▸ Performance: the GPU status line above the controls stays one line whatever it says +/// (without a driver it is a long wgpu message, e.g. on the FreeBSD CI), so the controls below it +/// don't move when the process-wide GPU state changes between two frames (a click aimed at +/// "Memory for caches" then landed on the gap above it). +#[test] +fn gpu_status_never_moves_the_performance_controls() { + let mut h = demo([1300.0, 820.0]); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), T); + assert_eq!(r["ok"], true, "{r}"); + let mut seen = vec![]; + for gpu in [true, false, true] { + // the setting is applied process-wide at the next frame (`app.gpu`), as other tests do + h.app.ui.settings.gpu = gpu; + h.step(); + h.step(); + seen.push((gpu, rect(&h, "button:settingsMemory-2"), lightcraft_engine::gpu::unavailable_reason())); + } + let first = seen[0].1; + assert!(seen.iter().all(|(_, r, _)| *r == first), "the memory buttons moved: {seen:?}"); +} diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index 3e3ed49d6..e06fe1401 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -1,10 +1,14 @@ //! Slow or offline drives never stall a frame (issue #104): with an injected file-system probe -//! that takes far longer than a frame (a sleeping NAS), the grid, the Info panel, the Missing -//! Photos view, the Rename dialog and an import keep drawing frames quickly, the probe never runs -//! on the UI thread, and the background answers still arrive. +//! that does not answer at all until the test lets it (a sleeping NAS), the grid, the Info panel, +//! the Rename dialog and the Missing Photos view keep drawing frames, the probe never runs on the +//! UI thread, and the background answers arrive once the drive wakes up. +//! +//! The NAS is a gate, not a sleep: a frame that waited for the drive would wait for good (until a +//! watchdog gives up and records it), so the test needs no frame-time budget and holds on a slow +//! or loaded machine (the FreeBSD CI VM) as it does on a fast one. -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::ThreadId; use std::time::{Duration, Instant}; @@ -16,11 +20,11 @@ use crate::headless::Headless; use crate::state::{RightPanel, ViewMode}; use crate::{LightcraftApp, Services}; -/// What one file-system call takes on the "NAS". -const SLOW: Duration = Duration::from_millis(400); -/// A frame that waited for one such call would take at least this long. +/// The import test's frame budget (its files take 150 ms each to read). const FRAME_LIMIT: Duration = Duration::from_millis(300); const N: u64 = 12; +/// How long the drive stays asleep at most: only a frame stuck waiting for it gets this far. +const WATCHDOG: Duration = Duration::from_secs(120); /// Threads the probe ran on, and how often. #[derive(Clone, Default)] @@ -39,9 +43,49 @@ impl Calls { } } +/// The sleeping drive: every file-system call waits until [`Nas::wake`]. A watchdog wakes it +/// after [`WATCHDOG`] and records that it had to (some frame was waiting for the drive). +#[derive(Default)] +struct Nas { + awake: Mutex<bool>, + changed: Condvar, + forced: AtomicBool, +} + +impl Nas { + fn asleep() -> Arc<Self> { + let nas = Arc::new(Nas::default()); + let dog = nas.clone(); + std::thread::spawn(move || { + let awake = dog.awake.lock().unwrap(); + let (mut awake, _) = dog.changed.wait_timeout_while(awake, WATCHDOG, |a| !*a).unwrap(); + if !*awake { + dog.forced.store(true, Ordering::SeqCst); + *awake = true; + dog.changed.notify_all(); + } + }); + nas + } + fn wait(&self) { + let awake = self.awake.lock().unwrap(); + drop(self.changed.wait_while(awake, |a| !*a).unwrap()); + } + fn wake(&self) { + *self.awake.lock().unwrap() = true; + self.changed.notify_all(); + } + /// The watchdog had to wake the drive: a frame waited for it. + fn forced(&self) -> bool { + self.forced.load(Ordering::SeqCst) + } +} + /// `N` photos whose originals are on an unreachable share, a smart previews folder, and a probe -/// that sleeps like a NAS waking up before saying "not there". -fn offline_library(calls: &Calls) -> Session { +/// that waits for the NAS to wake up before saying "not there". Called on the UI thread: a call +/// from that thread is recorded and answered at once, so a regression fails the thread check +/// instead of hanging the test. +fn offline_library(calls: &Calls, nas: &Arc<Nas>) -> Session { let mut s = Session::new(); for i in 0..N { let path = format!("/lightcraft-offline-nas/IMG_{i:03}.jpg"); @@ -49,72 +93,71 @@ fn offline_library(calls: &Calls) -> Session { s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } s.media.smart_dir = Some(std::env::temp_dir().join("lightcraft-offline-smart-previews-none")); - let c = calls.clone(); + let (c, nas, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id()); s.media.availability.set_probe(Arc::new(move |_| { c.record(); - std::thread::sleep(SLOW); + if std::thread::current().id() != ui_thread { + nas.wait(); + } false })); s } -fn max_frame(h: &mut Headless, frames: usize) -> Duration { - let mut worst = Duration::ZERO; +/// Run `frames` frames while the drive sleeps; each one has to finish without it. +fn frames_while_asleep(h: &mut Headless, nas: &Nas, frames: usize, what: &str) { for _ in 0..frames { - let t0 = Instant::now(); h.step(); - worst = worst.max(t0.elapsed()); + assert!(!nas.forced(), "a {what} frame waited for the sleeping drive"); } - worst } #[test] fn offline_originals_never_block_a_frame() { let calls = Calls::default(); - let app = LightcraftApp::new(offline_library(&calls), Services { png: None, ..Default::default() }); + let nas = Nas::asleep(); + let app = LightcraftApp::new(offline_library(&calls, &nas), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); let ui_thread = std::thread::current().id(); h.app.ui.view = ViewMode::PhotoGrid; h.step(); - // The first frame installs fonts; finish the first layout before timing the views. + // The first frame installs fonts; finish the first layout before the drive matters. h.step(); - assert!(h.app.fonts_ready, "the UI is initialized before frame timing starts"); + assert!(h.app.fonts_ready, "the UI is initialized"); assert!(h.app.session.media.availability.is_background(), "the app checks files off the UI thread"); // the grid: every cell asks for its thumbnail (which asks whether the original is there) h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "grid frame took {worst:?}"); + frames_while_asleep(&mut h, &nas, 20, "grid"); // the Info panel of the active photo, in the loupe h.app.ui.view = ViewMode::Detail; h.app.ui.right = RightPanel::Info; - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "Info panel frame took {worst:?}"); + frames_while_asleep(&mut h, &nas, 20, "Info panel"); - // the Missing Photos view fills in as the background checks find the files gone + // the Rename dialog's preview (it checks which names are taken on disk) h.app.ui.view = ViewMode::PhotoGrid; h.app.ui.right = RightPanel::None; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap(); + h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 }); + frames_while_asleep(&mut h, &nas, 20, "Rename dialog"); + h.app.ui.dialog = None; + + // the Missing Photos view: nothing is known yet, so nothing is listed, and frames go on h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(h.app.session.source, LibrarySource::Missing); + frames_while_asleep(&mut h, &nas, 20, "Missing Photos"); + assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); + assert_eq!(h.app.session.visible().len(), 0, "unknown files count as there"); + + // the drive answers: the view fills in as the background checks find the files gone + nas.wake(); let t0 = Instant::now(); - let mut worst = Duration::ZERO; while h.app.session.visible().len() < N as usize && t0.elapsed() < Duration::from_secs(60) { - let f = Instant::now(); h.step(); - worst = worst.max(f.elapsed()); std::thread::sleep(Duration::from_millis(5)); } assert_eq!(h.app.session.visible().len(), N as usize, "every photo found missing in the end"); - assert!(worst < FRAME_LIMIT, "Missing Photos frame took {worst:?}"); - - // the Rename dialog's preview (it checks which names are taken on disk) - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap(); - h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 }); - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "Rename dialog frame took {worst:?}"); - - assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); assert_eq!(calls.on(ui_thread), 0, "the slow probe never ran on the UI thread"); } From 877ef342dd1e8940797f177454ef128246f5769b Mon Sep 17 00:00:00 2001 From: G6bill <263344878+G6bill@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:25:13 +0200 Subject: [PATCH 165/315] MCP-CONTROL: validate remote reply framing and IDs (#1716) The connect-mode MCP client accepted any newline-less or wrong-id reply as the answer to its request. Require a complete newline-terminated reply of at most 64 MiB, the matching integer id, boolean ok and a result or string error; otherwise close the connection and report an unknown outcome without resending. Idless pre-dispatch refusals from the control server still work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/mcp/src/backend.rs | 189 ++++++++++++++++++++++++++++++++++++-- docs/mcp.md | 7 +- 2 files changed, 187 insertions(+), 9 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 73a02624e..0c59afa6a 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,11 +1,14 @@ //! Where MCP tool calls end up: a control-channel method call. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; use serde_json::{Value, json}; +/// Reply payloads include base64-encoded previews and screenshots, unlike the small requests. +const MAX_REPLY: usize = 64 * 1024 * 1024; + /// Something that answers control-channel methods (`engine.execute`, `engine.commands`, /// `ui.render`, `ui.screenshot`, `ui.key`, …). See `docs/control-protocol.md`. pub trait Backend { @@ -84,11 +87,7 @@ impl Remote { data.push(b'\n'); writer.write_all(&data).map_err(|e| (e, false))?; writer.flush().map_err(|e| (e, true))?; - let mut reply = String::new(); - if reader.read_line(&mut reply).map_err(|e| (e, true))? == 0 { - return Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"), true)); - } - Ok(reply) + read_reply(reader, MAX_REPLY).map_err(|e| (e, true)) } fn unknown_outcome(&self, method: &str, e: &std::io::Error) -> String { @@ -97,6 +96,36 @@ impl Remote { } } +fn read_reply(reader: &mut impl BufRead, max: usize) -> std::io::Result<String> { + let mut reply = String::new(); + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + reader.take(limit).read_line(&mut reply)?; + if reply.ends_with('\n') { + Ok(reply) + } else if reply.len() > max { + Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("control reply longer than {max} bytes"))) + } else { + Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed before a complete reply line")) + } +} + +fn parse_reply(reply: &str, id: u64) -> std::io::Result<Value> { + let invalid = |message| std::io::Error::new(std::io::ErrorKind::InvalidData, message); + let v: Value = serde_json::from_str(reply).map_err(|e| invalid(format!("bad reply from app: {e}")))?; + let ok = v.get("ok").and_then(Value::as_bool); + match v.get("id") { + Some(reply_id) if reply_id.as_u64() == Some(id) => {} + // The server refuses connections or malformed input before dispatch without an id. + None if ok == Some(false) => {} + _ => return Err(invalid(format!("control reply id does not match request {id}"))), + } + match ok { + Some(true) if v.get("result").is_some() => Ok(v), + Some(false) if v.get("error").and_then(Value::as_str).is_some() => Ok(v), + _ => Err(invalid("control reply must contain boolean `ok` and a result or string error".to_string())), + } +} + /// True when the app has closed `stream` (or it is unusable), checked without blocking or consuming data. fn peer_closed(stream: &TcpStream) -> bool { if stream.set_nonblocking(true).is_err() { @@ -133,7 +162,13 @@ impl Backend for Remote { return Err(self.unknown_outcome(method, &e)); } }; - let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + let v = parse_reply(&reply, id).map_err(|e| { + self.conn = None; + self.unknown_outcome(method, &e) + })?; + if v.get("id").is_none() { + self.conn = None; + } if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { @@ -157,6 +192,143 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + fn with_reply(reply: &[u8], check: impl FnOnce(&mut Remote, Result<Value, String>)) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let reply = reply.to_vec(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + let request: Value = serde_json::from_str(&request).unwrap(); + assert_eq!(request["id"], 1); + writer.write_all(&reply).unwrap(); + writer.shutdown(std::net::Shutdown::Write).unwrap(); + let mut extra = String::new(); + assert_eq!(reader.read_line(&mut extra).unwrap(), 0, "request was resent on the same connection"); + listener + }); + let mut remote = Remote::lazy(&addr); + let result = remote.call("engine.execute", json!({"command": "edit.undo"})); + check(&mut remote, result); + drop(remote); + let listener = worker.join().unwrap(); + listener.set_nonblocking(true).unwrap(); + assert_eq!(listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock, "request was resent on a new connection"); + } + + #[test] + fn mismatched_reply_id_is_rejected_without_resending() { + with_reply(b"{\"id\":999,\"ok\":true,\"result\":\"unrelated\"}\n", |remote, result| { + let err = result.unwrap_err(); + assert!(err.contains("outcome is unknown"), "{err}"); + assert!(remote.conn.is_none(), "discard the untrustworthy connection"); + }); + } + + #[test] + fn unterminated_reply_is_rejected_without_resending() { + with_reply(b"{\"id\":1,\"ok\":true,\"result\":7}", |remote, result| { + let err = result.unwrap_err(); + assert!(err.contains("outcome is unknown"), "{err}"); + assert!(remote.conn.is_none(), "discard the incomplete response"); + }); + } + + #[test] + fn malformed_replies_are_rejected_without_resending() { + for reply in [ + "not JSON\n", + "[]\n", + "{\"id\":1,\"ok\":\"true\",\"result\":7}\n", + "{\"id\":1,\"result\":7}\n", + "{\"id\":1,\"ok\":true}\n", + "{\"ok\":true,\"result\":7}\n", + "{\"id\":null,\"ok\":true,\"result\":7}\n", + "{\"id\":\"1\",\"ok\":true,\"result\":7}\n", + "{\"id\":1.0,\"ok\":true,\"result\":7}\n", + "{\"id\":999,\"ok\":false,\"error\":\"unrelated\"}\n", + "{\"id\":1,\"ok\":false}\n", + "{\"id\":1,\"ok\":false,\"error\":7}\n", + "{\"ok\":false}\n", + ] { + with_reply(reply.as_bytes(), |remote, result| { + let err = result.unwrap_err(); + assert!(err.contains("outcome is unknown"), "{reply}: {err}"); + assert!(remote.conn.is_none()); + }); + } + with_reply(b"\xff\n", |remote, result| { + assert!(result.unwrap_err().contains("outcome is unknown")); + assert!(remote.conn.is_none()); + }); + } + + #[test] + fn valid_replies_and_errors_are_preserved() { + for (reply, expected) in [ + ("{\"id\":1,\"ok\":true,\"result\":7}\n", Ok(json!(7))), + ("{\"id\":1,\"ok\":true,\"result\":null}\r\n", Ok(Value::Null)), + ("{\"id\":1,\"ok\":false,\"error\":\"unknown command\"}\n", Err("unknown command".to_string())), + ("{\"ok\":false,\"error\":\"too many control connections\"}\n", Err("too many control connections".to_string())), + ] { + with_reply(reply.as_bytes(), |remote, result| { + assert_eq!(result, expected); + assert_eq!(remote.conn.is_none(), !reply.contains("\"id\"")); + }); + } + } + + #[test] + fn reply_line_is_bounded_and_requires_a_newline() { + let reply = b"{\"id\":1,\"ok\":true,\"result\":7}\n"; + let payload_len = reply.len() - 1; + assert_eq!(read_reply(&mut reply.as_slice(), payload_len).unwrap().as_bytes(), reply); + assert_eq!(read_reply(&mut reply.as_slice(), payload_len - 1).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert_eq!(read_reply(&mut &reply[..payload_len], payload_len).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof); + assert_eq!(read_reply(&mut &b""[..], payload_len).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof); + let mut oversized = std::io::Cursor::new(vec![b'x'; 100]); + assert_eq!(read_reply(&mut oversized, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert_eq!(oversized.position(), 11, "read only the bound plus one byte"); + } + + #[test] + fn preview_sized_replies_use_the_documented_limit() { + let input = std::io::repeat(b'x').take(MAX_REPLY as u64).chain(std::io::Cursor::new(b"\n")); + let reply = read_reply(&mut BufReader::new(input), MAX_REPLY).unwrap(); + assert_eq!(reply.len(), MAX_REPLY + 1); + assert!(reply.ends_with('\n')); + let mut oversized = BufReader::new(std::io::repeat(b'x')); + assert_eq!(read_reply(&mut oversized, MAX_REPLY).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn consecutive_replies_match_their_requests_on_one_connection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + for id in 1..=2 { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["id"], id); + writeln!(writer, "{}", json!({"id": id, "ok": true, "result": id})).unwrap(); + } + }); + let mut remote = Remote::lazy(&addr); + for id in 1..=2 { + assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(id)); + } + worker.join().unwrap(); + } + #[test] fn lost_reply_is_not_resent() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -192,7 +364,8 @@ mod tests { let mut line = String::new(); if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 { seen2.fetch_add(1, Ordering::SeqCst); - writer.write_all(b"{\"ok\": true, \"result\": 7}\n").unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": 7})).unwrap(); } } }); diff --git a/docs/mcp.md b/docs/mcp.md index f0cc9a1d1..466bdde66 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,7 +24,12 @@ it open, `--library` on the same folder fails with "This library is already open the app is not running yet and connects on the first call (and reconnects if the app restarts). A request is sent again only when it never reached the app. If the connection drops after a request was sent but before its reply, the tool fails with "the outcome is unknown" and nothing is resent, so a -command such as undo can't run twice: check the app's state before retrying. +command such as undo can't run twice: check the app's state before retrying. Replies must be +newline-terminated, at most 64 MiB before the newline (including encoded previews/screenshots), +and carry the request's integer `id`, a boolean `ok`, and either a `result` or a string `error`. +A malformed, incomplete, oversized or mismatched reply also closes the connection and reports +an unknown outcome without resending. An idless `ok: false` with a string `error` is preserved +as a pre-dispatch refusal (for example, too many control connections); the next call reconnects. Logs go to stderr; stdout carries only protocol messages. From 7984667269ffb7342d199df7ef999511cb2346a3 Mon Sep 17 00:00:00 2001 From: Lucas Lepski <lucas@lepski.de> Date: Tue, 13 Oct 2026 13:41:46 +0200 Subject: [PATCH 166/315] UI-ARROWS: reconcile chevrons with current upstream UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the HDR histogram and SDR/HDR legend, virtualized sidebar rows and current preset grouping while retaining shared chevrons and inset interactive clipping buttons. Apply shared submenu arrows to the new album import menus. Drop the earlier inpainting compatibility change now present upstream. Validation: formatting, strict native Clippy and HEIF checks passed; 811 compiled UI tests passed; native SDR and HDR screenshots checked. Full workspace CI is blocked by all_masks_overlay_shows_every_visible_mask_at_once, reproduced unchanged on clean upstream b3d392b4. Publish this prepared rebase at the contributor’s explicit request with that baseline failure documented. Co-Authored-By: GPT-6.1 Sol <noreply@openai.com> --- crates/ui-egui/src/album_picker.rs | 22 +++--- crates/ui-egui/src/credits.rs | 25 ++++--- crates/ui-egui/src/date_picker.rs | 6 +- crates/ui-egui/src/icons.rs | 42 ++++++++---- crates/ui-egui/src/import.rs | 10 +-- crates/ui-egui/src/menu_level.rs | 16 ++--- crates/ui-egui/src/menubar.rs | 2 +- crates/ui-egui/src/panels/bottombar.rs | 6 +- crates/ui-egui/src/panels/denoise.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 9 +-- crates/ui-egui/src/panels/edit.rs | 83 ++++++++++++++++++---- crates/ui-egui/src/panels/filterbar.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 27 +++++--- crates/ui-egui/src/panels/keyword_list.rs | 12 ++-- crates/ui-egui/src/panels/left.rs | 41 +++++------ crates/ui-egui/src/panels/masking.rs | 6 +- crates/ui-egui/src/panels/people.rs | 7 +- crates/ui-egui/src/panels/person.rs | 2 +- crates/ui-egui/src/panels/presets.rs | 6 +- crates/ui-egui/src/panels/profiles.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/panels/rules_editor.rs | 84 +++++++++++------------ crates/ui-egui/src/panels/settings.rs | 6 +- crates/ui-egui/src/tests_panels.rs | 27 ++++++++ crates/ui-egui/src/widgets.rs | 44 ++++++++++-- 26 files changed, 318 insertions(+), 177 deletions(-) diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 72ea3f226..914827e5d 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -11,7 +11,7 @@ //! `albumPicker:<id>` (the button), `albumPickerSearch:<id>`, `albumPickerFolder:<folder>:<id>` //! and `albumPickerItem:<album>:<id>`. -use egui::{Response, RichText, Ui, vec2}; +use egui::{Rect, Response, RichText, Ui, vec2}; use crate::icons::Icon; use crate::widgets::register; @@ -146,14 +146,14 @@ pub fn search(entries: &[AlbumEntry], query: &str) -> Vec<usize> { first.into_iter().chain(rest).collect() } -/// A small filled triangle centred on `c`: pointing down (`down`), else right. -fn triangle(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { - let pts = if down { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; - p.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); +/// A disclosure chevron centred on `c`: pointing down (`down`), else right. +fn disclosure(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { + crate::icons::paint( + p, + Rect::from_center_size(c, vec2(16.0, 16.0)), + if down { crate::icons::Icon::ChevronDown } else { crate::icons::Icon::ChevronRight }, + color, + ); } /// What the open popup remembers between frames. @@ -195,7 +195,7 @@ impl<'a> AlbumPicker<'a> { // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; - triangle(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); + disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); register(ui.ctx(), format!("albumPicker:{id}"), button.rect); let key = egui::Id::new(("albumPicker", id.as_str())); if button.clicked() { @@ -255,7 +255,7 @@ impl<'a> AlbumPicker<'a> { ui.painter().rect_filled(rect, 3.0, t.hover); } let x = rect.left() + indent; - triangle(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); + disclosure(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); ui.painter().text( diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 964396850..d451aa79d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -240,7 +240,7 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::i18n::tr("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { + crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() { v.key = k; @@ -248,7 +248,10 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } }); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { + let reverse = + crate::widgets::arrow_button(ui, "", if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, false); + reverse.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order"))); + if reverse.on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); @@ -289,12 +292,18 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui - .button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong()) - .on_hover_text(crate::i18n::tr(k.label().0)) - .clicked() - { + let text = RichText::new(crate::i18n::tr(k.label().1)).strong(); + let header = if v.key == k { + crate::widgets::arrow_button( + ui, + text, + if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, + false, + ) + } else { + ui.button(text) + }; + if header.on_hover_text(crate::i18n::tr(k.label().0)).clicked() { if v.key == k { v.ascending = !v.ascending; } else { diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/date_picker.rs index c04c309af..c84df98c4 100644 --- a/crates/ui-egui/src/date_picker.rs +++ b/crates/ui-egui/src/date_picker.rs @@ -266,7 +266,8 @@ impl<'a> DatePicker<'a> { ui.separator(); } ui.horizontal(|ui| { - let prev = ui.small_button("‹"); + let prev = crate::widgets::arrow_button(ui, "", Icon::ChevronLeft, true); + prev.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Previous page"))); register(ui.ctx(), format!("datePickerPrev:{id}"), prev.rect); let title = match view.precision { Precision::Day => crate::i18n::date_group_label(&format!("{:04}-{:02}", view.year, view.month), false), @@ -278,7 +279,8 @@ impl<'a> DatePicker<'a> { }; ui.label(RichText::new(title).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let next = ui.small_button("›"); + let next = crate::widgets::arrow_button(ui, "", Icon::ChevronRight, false); + next.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Next page"))); register(ui.ctx(), format!("datePickerNext:{id}"), next.rect); if next.clicked() { view = step(view, true); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b82194bbb..74d62b164 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -44,7 +44,9 @@ pub enum Icon { ChevronDown, ChevronRight, ChevronLeft, - TriangleLeft, + ChevronUp, + ChevronDoubleLeft, + ChevronDoubleRight, Plus, Minus, Album, @@ -175,6 +177,22 @@ pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) { let side = rect.width().min(rect.height()); + // All disclosure and navigation chevrons share a 16-point size and stroke. + let side = if matches!( + icon, + Icon::Back + | Icon::Forward + | Icon::ChevronDown + | Icon::ChevronUp + | Icon::ChevronLeft + | Icon::ChevronRight + | Icon::ChevronDoubleLeft + | Icon::ChevronDoubleRight + ) { + side.min(16.0) + } else { + side + }; let r = Rect::from_center_size(rect.center(), vec2(side, side)); let k = side / 20.0; let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn }; @@ -184,14 +202,6 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.rect(3.0, 4.0, 17.0, 16.0, 1.5); pen.line(&[(8.0, 4.0), (8.0, 16.0)]); } - Back => { - pen.line(&[(16.0, 10.0), (4.0, 10.0)]); - pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); - } - Forward => { - pen.line(&[(4.0, 10.0), (16.0, 10.0)]); - pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); - } Monitor => { pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); pen.line(&[(10.0, 13.5), (10.0, 16.5)]); @@ -399,9 +409,17 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.rect_fill(10.0, 4.0, 17.0, 16.0, 0.5); } ChevronDown => pen.line(&[(5.0, 7.5), (10.0, 12.5), (15.0, 7.5)]), - ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]), - ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]), - TriangleLeft => pen.fill(&[(13.0, 6.0), (13.0, 14.0), (7.0, 10.0)], color), + Forward | ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]), + Back | ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]), + ChevronDoubleLeft => { + pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); + pen.line(&[(16.0, 5.0), (11.0, 10.0), (16.0, 15.0)]); + } + ChevronDoubleRight => { + pen.line(&[(4.0, 5.0), (9.0, 10.0), (4.0, 15.0)]); + pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); + } + ChevronUp => pen.line(&[(5.0, 12.5), (10.0, 7.5), (15.0, 12.5)]), Plus => { pen.line(&[(10.0, 4.0), (10.0, 16.0)]); pen.line(&[(4.0, 10.0), (16.0, 10.0)]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..5b8147d74 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1102,7 +1102,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { ]; let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); - let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (k, label) in opts { let r = ui.selectable_label(key == k, crate::i18n::tr(label)); register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect); @@ -1185,7 +1185,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { _ => "Leave them", }; let mut pick = None; - egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { pick = Some(v); @@ -1215,7 +1215,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(), None => crate::i18n::tr("None").into(), }; - egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| { + crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| { if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() { d.album = None; d.new_album.clear(); @@ -1246,7 +1246,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .find(|p| p.id == d.preset) .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) .unwrap_or_else(|| crate::i18n::tr("None").into()); - egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { + crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } @@ -1259,7 +1259,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if !app.session.metadata_presets.is_empty() { field(ui, "Metadata", |ui| { let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; - egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| { + crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { d.metadata_preset.clear(); } diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menu_level.rs index 379a2dd0b..96ff422f4 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/menu_level.rs @@ -6,7 +6,7 @@ //! it: wheel, dragging, and ▲ / ▼ strips that scroll while the pointer rests on them, like //! native menus on a small display. The same approach as PhotoCraft's menu bar (its #319). -use egui::{Id, Rect, Sense, Ui, pos2, vec2}; +use egui::{Id, Rect, Sense, Ui, vec2}; /// Height of a scroll-arrow strip. pub const ARROW: f32 = 16.0; @@ -109,13 +109,12 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] { let strip = Rect::from_x_y_ranges(x, strip.y_range()); let c = strip.center(); - let (h, w) = (4.5, 6.0); - let tri = if dir < 0.0 { - vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)] - } else { - vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)] - }; - ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_disabled }, egui::Stroke::NONE)); + crate::icons::paint( + ui.painter(), + Rect::from_center_size(c, vec2(16.0, 16.0)), + if dir < 0.0 { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, + if live { t.text } else { t.text_disabled }, + ); let id = key.with(dir < 0.0); let r = ui.interact(strip, id, Sense::hover()); r.widget_info(|| { @@ -143,6 +142,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn #[cfg(test)] mod tests { use super::*; + use egui::pos2; #[test] fn level_room_keeps_every_level_below_the_bar() { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..5d2523281 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -930,7 +930,7 @@ fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option<e /// bounded like every level. The row is registered as `menusub:<label>` (the untranslated /// label), and while its submenu is open it is the anchor the next frame's room is measured from. fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) { - let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children)); + let r = crate::widgets::menu_button(ui, text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children)); crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect); if r.inner.is_some() { crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect); diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index b986b8995..d5c825010 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -62,9 +62,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } ui.separator(); - ui.menu_button(crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); if compact_zoom && matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { - ui.menu_button(crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| click_zoom_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| { + click_zoom_menu(app, ui) + }); } }); } diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 75488f76b..ab4bcca93 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -371,7 +371,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V ui.label(RichText::new(crate::i18n::tr("Limit")).color(t.text_label)); let current = list["cacheGb"].as_u64().unwrap_or(20) as u32; let mut choice = current; - egui::ComboBox::from_id_salt("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { + crate::widgets::combo_box("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { for gb in CACHE_LIMITS { ui.selectable_value(&mut choice, gb, tr_format!("{size} GB", size = gb)); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..5703efed6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -800,7 +800,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { preview_only_pill(ui, canvas, why); } resp.context_menu(|ui| { - ui.menu_button(crate::i18n::tr("Zoom"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run(cmd, json!({})); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..d79bfeedb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -558,7 +558,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { for pl in rows { ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); - ui.label(egui::RichText::new("→").color(t.text_dim)); + let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); ui.label(egui::RichText::new(&pl.to).color(t.text)); ui.end_row(); } @@ -646,7 +647,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let n = app.session.targets(&json!({})).len(); ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { - egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { + crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); ui.selectable_value(&mut options.paper, "letter".into(), "Letter"); }); @@ -665,7 +666,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // Preset: load a built-in or saved set of options into the dialog field(ui, "Preset", |ui| { let mut chosen = None; - let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { + let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { let mut builtin = true; for (p, b) in app.session.all_export_presets() { if builtin && !b { @@ -1821,7 +1822,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex field(ui, "Resize to", |ui| { let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); let before = r.mode; - let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (m, l) in MODES { ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..cafdc6b8d 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -455,7 +455,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); } for g in profile_groups() { - let r = ui.menu_button(crate::i18n::tr(g), |ui| { + let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { ui.set_min_width(170.0); for p in PROFILES.iter().filter(|p| p.group == g) { item(ui, "group", p.id, p.name, &mut pick); @@ -469,7 +469,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) lut_groups.dedup(); for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group - let r = ui.menu_button(g.as_str(), |ui| { + let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { ui.set_min_width(170.0); for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { item(ui, "group", &p.id, &p.name, &mut pick); @@ -620,6 +620,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", )); } + let clipping = hist.as_ref().map(|h| h.clipping()); if let Some(h) = hist { let smooth = |v: &[u32]| -> Vec<f32> { (0..256) @@ -678,15 +679,58 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); } } - let (lo, hi) = h.clipping(); - for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] { - let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0)); - p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) }); - paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) }); - } } - if resp.clicked() { - app.ui.show_clipping = !app.ui.show_clipping; + let mut clicked = resp.clicked(); + for (name, x, label, clipped) in [ + ("shadows", plot.left() + 20.0, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", plot.right() - 20.0, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + ] { + let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); + let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); + register(ui.ctx(), format!("histogram:{name}"), rect); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); + clicked |= response.clicked(); + let active = app.ui.show_clipping; + // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. + let box_rect = rect.shrink2(vec2(2.0, 2.0)); + p.rect_filled( + box_rect, + 3.0, + if active { + t.pressed + } else if response.hovered() { + t.hover + } else { + Color32::from_gray(60) + }, + ); + if active { + p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + } + paint( + p, + Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), + Icon::ChevronDown, + if clipped == Some(true) || active { t.text } else { t.text_dim }, + ); + let status = match clipped { + Some(true) => "Clipping detected in this preview.", + Some(false) => "No clipping detected in this preview.", + None => "Histogram not yet available.", + }; + response.on_hover_text(format!( + "{}\n{}\n{}", + crate::i18n::tr(label), + crate::i18n::tr(if active { + "Click to hide clipping warnings for shadows and highlights." + } else { + "Click to show clipping warnings for shadows and highlights." + }), + crate::i18n::tr(status) + )); + } + if clicked { + let _ = app.run("view.clipping", json!({})); } // EXIF strip if let Some(ph) = app.session.catalog.photo(id) { @@ -741,7 +785,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); let mut pick = false; - egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { + crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { for s in OutputSpace::ALL { if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { let _ = app.run("view.softProof", json!({"space": s})); @@ -1666,8 +1710,21 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { for (label, ctl, small, big) in rows { ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] { - let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0))); + // Keep the existing automation ids while drawing all step arrows as vectors. + for (txt, icon, d) in [ + ("◀◀", Icon::ChevronDoubleLeft, -big), + ("◀", Icon::ChevronLeft, -small), + ("▶", Icon::ChevronRight, small), + ("▶▶", Icon::ChevronDoubleRight, big), + ] { + let r = crate::widgets::arrow_button(ui, "", icon, false); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + ui.is_enabled(), + crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), + ) + }); crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) .clicked() diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c0962b59f..db1056ad8 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -280,7 +280,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("library.clearFilter", json!({})); } // filter presets: apply one, save the current filter, delete - let presets = ui.button(egui::RichText::new(crate::i18n::tr("Presets ▾")).font(t.font(12.5))); + let presets = crate::widgets::dropdown(ui, "filterPresets", crate::i18n::tr("Presets"), t.font(12.5), t.text); register(ui.ctx(), "button:filterPresets", presets.rect); egui::Popup::menu(&presets).show(|ui| { let names: Vec<String> = app.session.filter_presets.iter().map(|f| f.name.clone()).collect(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..1cd88f329 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -827,10 +827,15 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { // Prefer the current folder, showing more ancestors as space permits. let visible = ((ui.available_width() / 80.0) as usize).clamp(1, 4); let skip = parts.len().saturating_sub(visible); + let separator = |ui: &mut egui::Ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 24.0), Sense::hover()); + paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::ChevronRight, t.text_dim); + }; if skip > 0 { - ui.label(egui::RichText::new("… ›").color(t.text_dim)).on_hover_text(path); + ui.label(egui::RichText::new("…").color(t.text_dim)).on_hover_text(path); + separator(ui); } - let separator_w = ui.painter().layout_no_wrap("›".into(), t.font(13.0), t.text_dim).size().x + 8.0; + let separator_w = 16.0 + 2.0 * ui.spacing().item_spacing.x; for (i, part) in parts.iter().enumerate().skip(skip) { let remaining = parts.len() - i; let last = remaining == 1; @@ -848,7 +853,7 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { { let _ = app.run("library.browse", json!({"path": to})); } - ui.label(egui::RichText::new("›").size(13.0).color(t.text_dim)); + separator(ui); } r.on_hover_text(path); } @@ -989,7 +994,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // Photo ▸ Edit In: a copy with this photo's edits, opened in PhotoCraft or another editor // (desktop only: the web can't start programs) ui.add_enabled_ui(crate::menus::ui_enabled(app, "photo.editInPhotoCraft"), |ui| { - ui.menu_button(crate::i18n::tr("Edit In"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Edit In"), |ui| { if ui.button(crate::i18n::tr("Edit in PhotoCraft")).clicked() { let _ = app.run("photo.editInPhotoCraft", json!({})); } @@ -1025,27 +1030,27 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.setReference", json!({"id": id.0})); } ui.separator(); - ui.menu_button(crate::i18n::tr("Set Rating"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { let _ = app.run("photo.rate", json!({"rating": r})); } } }); - ui.menu_button(crate::i18n::tr("Set Flag"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Flag"), |ui| { for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] { if ui.button(crate::i18n::tr(l)).clicked() { let _ = app.run("photo.flag", json!({"flag": f})); } } }); - ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); if local_only(app, &app.session.targets(&serde_json::Value::Null)) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); register(ui.ctx(), "button:contextAddToAlbum", add.rect); } else { - let add = ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { + let add = crate::widgets::menu_button(ui, crate::i18n::tr("Add to Album"), |ui| { let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); let any = !albums.is_empty(); for (aid, name) in albums { @@ -1110,7 +1115,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } - ui.menu_button(crate::i18n::tr("Stack"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; for (label, cmd, on) in [ @@ -1145,7 +1150,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Reset Edits")).clicked() { let _ = app.run("develop.reset", json!({})); } - ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Photo Merge"), |ui| { let n = app.session.targets(&json!({})).len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { @@ -1173,7 +1178,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); } - ui.menu_button(crate::i18n::tr("Export with Preset"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { match app.run("app.export", json!({"preset": p.name, "background": true})) { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..ab8521821 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -8,6 +8,7 @@ use lightcraft_catalog::{Catalog, PhotoId}; use serde_json::json; use crate::LightcraftApp; +use crate::icons::{Icon, paint}; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::register; @@ -178,17 +179,12 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // the triangle if r.has_children { let c = pos2(x + 6.0, cy); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); - let pts = if r.open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; // while a filter opens the levels the triangle does nothing: it doesn't light up either let lit = tr.hovered() && can_fold; - ui.painter().add(egui::Shape::convex_polygon(pts, if lit { t.text } else { t.text_dim }, Stroke::NONE)); + paint(ui.painter(), tri, if r.open { Icon::ChevronDown } else { Icon::ChevronRight }, if lit { t.text } else { t.text_dim }); if tr.clicked() && can_fold { let key = r.path.to_lowercase(); if r.open { @@ -243,7 +239,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); if resp.hovered() || ar.hovered() { - ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim }); + paint(ui.painter(), arrow, Icon::ChevronRight, if ar.hovered() { t.text } else { t.text_dim }); } if ar.on_hover_text(crate::i18n::tr("Show Photos with Keyword")).clicked() { super::left::browse_all_photos(app, true); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..c4f90f689 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -173,17 +173,17 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) } -/// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing +/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing /// when closed, down when open, registered as widget `widget` for the control channel. Returns /// its response: the caller flips the open state on `clicked()`. -fn disclosure_triangle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { +fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { let t = Tokens::get(ui.ctx()); let c = pos2(row.rect.left() + 10.0 + indent, row.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); let col = if tr.hovered() { t.text } else { t.text_dim }; - ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); tr } @@ -197,11 +197,11 @@ fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { ui.data_mut(|d| d.insert_temp(key, open)); } -/// The triangle of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, -/// Keywords): a click flips `open` and remembers it under `key`. Returns the triangle's response +/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response /// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { - let tri = disclosure_triangle(ui, row, indent, *open, key.with("triangle"), widget); + let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); if tri.clicked() { *open = !*open; set_tree_open(ui, key, *open); @@ -211,15 +211,6 @@ fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui:: tri } -/// A disclosure triangle around `c`: pointing down when open, right when closed. -fn triangle_points(c: egui::Pos2, open: bool) -> Vec<egui::Pos2> { - if open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - } -} - /// Space above a section's header. const SECTION_GAP: f32 = 10.0; /// Height of a section's header. @@ -399,7 +390,7 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { } /// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their -/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so /// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at /// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo /// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). @@ -433,9 +424,9 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); } let c = pos2(r.left() + 10.0, r.center().y); - register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0))); + register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); let col = if hovered { t.text } else { t.text_dim }; - ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); let button = header_button(section); // (an album being dragged puts "Top Level" in the Albums header) @@ -1549,7 +1540,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { if a.folder { // the same commands as File ▸ New…, aimed at this folder - ui.menu_button(crate::i18n::tr("New"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { for (label, command) in [ ("Create Album…", "dialog.newAlbum"), ("Create Smart Album…", "dialog.smartAlbum"), @@ -1582,7 +1573,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } } // File ▸ Import's ways in; the import review opens with this album chosen - let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { let r = ui.button(label); crate::widgets::register(ui.ctx(), widget, r.rect); @@ -1600,7 +1591,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); } // like File ▸ Import from Device: one entry per camera or card found - let device = ui.menu_button(crate::i18n::tr("Import from Device"), |ui| { + let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); @@ -1649,7 +1640,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let _ = app.run("dialog.export", json!({})); } ui.add_enabled_ui(has_photos, |ui| { - ui.menu_button(crate::i18n::tr("Export Album with Preset"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { show_all(app); @@ -1666,7 +1657,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let mut folders: Vec<(u64, String)> = app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); folders.sort_by_key(|(_, n)| n.to_lowercase()); - ui.menu_button(crate::i18n::tr("Move to"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); } @@ -1879,7 +1870,7 @@ fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, /// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, /// its colours `folderLabel:<colour>`). fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let menu = ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { app.toast(ui.ctx(), e); diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 26577524a..816f7892a 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -659,14 +659,14 @@ fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: run(ui, "Invert", json!({"action": "invert"})); run(ui, &crate::i18n::tr_format!("Duplicate \"{label}\"", label = label), json!({"action": "duplicate"})); if k > 0 { - ui.menu_button(crate::i18n::tr("Mode"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Mode"), |ui| { for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] { run(ui, text, json!({"action": "op", "op": op})); } }); } - ui.menu_button(crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); - ui.menu_button(crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); + crate::widgets::menu_button(ui, crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); + crate::widgets::menu_button(ui, crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); ui.separator(); if ui.button(crate::i18n::tr("Rename…")).clicked() { app.ui.renaming_component = Some((mask, k, label.to_string())); diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..32c0dbc35 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -69,10 +69,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text); // the person page last left (a face opened from it, or its back button): one click returns to it if let Some(last) = app.ui.last_person.clone().filter(|n| people.iter().any(|p| &p.name == n)) { - let label = fit(ui.painter(), &format!("{last} ›"), t.font(13.0), 240.0); - let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 24.0; + let label = fit(ui.painter(), &last, t.font(13.0), 224.0); + let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 44.0; let r = Rect::from_min_size(pos2(title.right() + 14.0, head.center().y - 12.0), vec2(width, 24.0)); - let b = ui.put(r, egui::Button::new(RichText::new(label).font(t.font(13.0))).wrap_mode(egui::TextWrapMode::Extend)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); + let b = crate::widgets::arrow_button(&mut child, RichText::new(label).font(t.font(13.0)), crate::icons::Icon::ChevronRight, false); register(ui.ctx(), "people:last", b.rect); if b.on_hover_text(crate::i18n::tr("Back to this person")).clicked() { let _ = app.run("view.person", json!({"name": last})); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..2d584331b 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -93,7 +93,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(PAD - 4.0); - let back = ui.button("‹ People"); + let back = crate::widgets::arrow_button(ui, crate::i18n::tr("People"), crate::icons::Icon::ChevronLeft, true); register(ui.ctx(), "person:back", back.rect); if back.clicked() { let _ = app.run("view.people", json!({})); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 3d90c00c3..3f2bc43a7 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -134,7 +134,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { register(ui.ctx(), format!("presetGroup:{g}"), r); paint( ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); @@ -185,7 +185,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star); + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), Icon::StarFilled, t.star); } // resting on a preset previews it in the loupe (no history entry) if resp.hovered() @@ -223,7 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Rename…")).clicked() { crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); } - ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Move to Group"), |ui| { for g in groups.iter().filter(|x| x.user && x.name != group).map(|x| &x.name) { if ui.button(g).clicked() { let _ = app.run("preset.move", json!({"id": pid, "group": g})); diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 6294d1e64..6085829e4 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -82,7 +82,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); paint( ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..c0b7ad215 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -777,7 +777,7 @@ fn human_size(bytes: u64) -> String { fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { + let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { for st in lightcraft_catalog::CopyrightStatus::ALL { if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); @@ -911,7 +911,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = sets["current"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); - let combo = egui::ComboBox::from_id_salt("kw-set") + let combo = crate::widgets::combo_box("kw-set") .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) .show_ui(ui, |ui| { for set in sets["sets"].as_array().into_iter().flatten() { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 006d42667..1ec211d51 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -51,7 +51,7 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) { Match::Any => "any", Match::None => "none", }; - egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { for x in [Match::All, Match::Any, Match::None] { ui.selectable_value(m, x, crate::i18n::tr(label(x))); } @@ -149,7 +149,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & let mut unit = stored_unit.to_lowercase(); let mut changed = ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed(); - egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { for u in ["hours", "days", "weeks", "months", "years"] { if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() { unit = u.to_string(); @@ -198,23 +198,20 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & (Some(Kind::Number), _) => number_value(ui, v, field), (Some(Kind::Choice(c)), _) => { let cur = v.as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui( - ui, - |ui| { - for (id, _) in c.iter() { - if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { - *v = json!(id); - } + crate::widgets::combo_box(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(ui, |ui| { + for (id, _) in c.iter() { + if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { + *v = json!(id); } - }, - ); + } + }); } (Some(Kind::Bool), _) => { // the catalog's reading of the value ("false" is no); one that is neither yes nor no is // shown as written and kept until a choice replaces it, so the dialog reports it let cur = bool_value(v); let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string()); - egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { for b in [true, false] { if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() { *v = json!(b); @@ -235,38 +232,37 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String, value: &mut Value) { let label = field_label(field).unwrap_or(field.as_str()).to_string(); let current = field.clone(); - let r = - egui::ComboBox::from_id_salt(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { - let mut pick = None; - let mut item = |ui: &mut egui::Ui, id: &'static str| { - let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); - register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); - if r.clicked() { - pick = Some(id); - ui.close(); + let r = crate::widgets::combo_box(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { + let mut pick = None; + let mut item = |ui: &mut egui::Ui, id: &'static str| { + let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); + register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); + if r.clicked() { + pick = Some(id); + ui.close(); + } + }; + for id in TOP_LEVEL_FIELDS { + item(ui, id); + } + ui.separator(); + // submenu rows look like the items above them; the one holding the current field stands out + // (a transparent frame, not none: a frameless button drops its padding and hover fill) + let inactive = &mut ui.visuals_mut().widgets.inactive; + inactive.weak_bg_fill = egui::Color32::TRANSPARENT; + inactive.bg_stroke = egui::Stroke::NONE; + for (group, ids) in FIELD_GROUPS { + let text = RichText::new(crate::i18n::tr(group)); + let text = if ids.contains(¤t.as_str()) { text.strong() } else { text }; + let r = crate::widgets::menu_button(ui, text, |ui| { + for id in *ids { + item(ui, id); } - }; - for id in TOP_LEVEL_FIELDS { - item(ui, id); - } - ui.separator(); - // submenu rows look like the items above them; the one holding the current field stands out - // (a transparent frame, not none: a frameless button drops its padding and hover fill) - let inactive = &mut ui.visuals_mut().widgets.inactive; - inactive.weak_bg_fill = egui::Color32::TRANSPARENT; - inactive.bg_stroke = egui::Stroke::NONE; - for (group, ids) in FIELD_GROUPS { - let text = RichText::new(crate::i18n::tr(group)); - let text = if ids.contains(¤t.as_str()) { text.strong() } else { text }; - let r = ui.menu_button(text, |ui| { - for id in *ids { - item(ui, id); - } - }); - register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); - } - pick - }); + }); + register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); + } + pick + }); register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect); let Some(Some(id)) = r.inner else { return }; if field == id { @@ -321,7 +317,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: field_menu(ui, &rsalt, field, op, value); let kind = field_kind(field).unwrap_or(Kind::Text); let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); - egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { for (id, l) in ops_for(kind) { if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id { let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty"); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b95b55b19..0c7e9b04a 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -206,7 +206,7 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio }; let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); let mut out = None; - let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { out = Some(None); } @@ -276,7 +276,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { .unwrap_or_else(|| pid.to_string()), }; let id = format!("settingsCamera-{i}"); - let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { pick = Some(json!({"camera": cam, "remove": true})); } @@ -325,7 +325,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { let cur = d.metadata_preset.clone(); let mut pick = None; - let r = egui::ComboBox::from_id_salt("settingsMetaPreset") + let r = crate::widgets::combo_box("settingsMetaPreset") .width(240.0) .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index b18c4e968..5b979ccdc 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2365,3 +2365,30 @@ fn new_album_can_leave_the_selected_photos_out() { confirm(&mut h); assert!(photos(&h, "Nothing").is_empty()); } + +/// Histogram corner controls stay usable even before a histogram is available, and each +/// click invokes the same clipping-warning command as the J shortcut exactly once. +#[test] +fn histogram_corners_toggle_clipping_warnings() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + for texture in h.app.renderer.textures.values_mut() { + texture.histogram = None; + } + h.step(); + h.app.ui.show_clipping = false; + let histogram = h.app.widgets.iter().find(|(id, _)| id == "histogram").unwrap().1; + for id in ["histogram:shadows", "histogram:highlights"] { + let rect = h.app.widgets.iter().find(|(widget, _)| widget == id).unwrap().1; + assert!(histogram.shrink(8.0).contains_rect(rect), "{id} keeps clear space to the histogram edges: {rect:?} in {histogram:?}"); + } + for (id, enabled) in [("histogram:shadows", true), ("histogram:highlights", false), ("histogram", true)] { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.show_clipping, enabled, "{id} must toggle exactly once"); + } + let r = h.request("ui.key", json!({"key": "J"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!h.app.ui.show_clipping); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..56298609d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -8,6 +8,42 @@ use lightcraft_develop::{ControlSpec, Track}; use crate::icons::{Icon, paint}; use crate::theme::Tokens; +/// Standard dropdown chevrons, including settings, import/export and rule editors. +pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox { + egui::ComboBox::from_id_salt(id).icon(|ui, rect, visuals, _| { + paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color); + }) +} + +/// A native button with a vector arrow beside its text; egui reserves the icon's space. +pub fn arrow_button<'a>(ui: &mut Ui, text: impl egui::IntoAtoms<'a>, icon: Icon, leading: bool) -> Response { + let id = ui.next_auto_id().with("arrow"); + let mut atoms = text.into_atoms(); + let arrow = egui::Atom::custom(id, vec2(16.0, 16.0)); + if leading { + atoms.push_left(arrow); + } else { + atoms.push_right(arrow); + } + let r = egui::Button::new(atoms).atom_ui(ui); + if let Some(rect) = r.rect(id) { + paint(ui.painter(), rect, icon, ui.style().interact(&r.response).fg_stroke.color); + } + r.response +} + +/// Keep egui's submenu interaction and layout, using our vector chevron instead of a font glyph. +pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse<Option<R>> { + if !egui::containers::menu::is_in_menu(ui) { + return ui.menu_button(atoms, contents); + } + let button = egui::Button::new(atoms.into_atoms()).right_text(" "); + let (response, inner) = egui::containers::menu::SubMenuButton::from_button(button).ui(ui, contents); + let center = pos2(response.rect.right() - ui.spacing().button_padding.x - 8.0, response.rect.center().y); + paint(ui.painter(), Rect::from_center_size(center, vec2(16.0, 16.0)), Icon::ChevronRight, ui.style().interact(&response).fg_stroke.color); + egui::InnerResponse::new(inner.map(|r| r.inner), response) +} + // ------------------------------------------------------------------ automation registry #[derive(Clone, Default)] @@ -441,7 +477,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O if ui.rect_contains_pointer(r) { p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); } - let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0)); + let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(16.0, 16.0)); paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label); p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text); let mut toggled = None; @@ -491,8 +527,8 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); paint( p, - Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)), - if open { Icon::ChevronDown } else { Icon::TriangleLeft }, + Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(16.0, 16.0)), + if open { Icon::ChevronDown } else { Icon::ChevronLeft }, t.text_label, ); resp @@ -629,7 +665,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co register(ui.ctx(), format!("dropdown:{id}"), r); let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c); + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 10.0, r.center().y), vec2(16.0, 16.0)), Icon::ChevronDown, c); resp } From 16dbb90499d3c63a6582fcbba1b2550a6e2da032 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <brandon@storyteller.ai> Date: Wed, 14 Oct 2026 09:34:55 -0400 Subject: [PATCH 167/315] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 62 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..5f445192f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -3097,7 +3097,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.7.1" +version = "0.8.0" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3130,7 +3130,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.7.1" +version = "0.8.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3159,7 +3159,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3168,7 +3168,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.7.1" +version = "0.8.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3185,7 +3185,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3200,7 +3200,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3212,7 +3212,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "ab_glyph", "harfrust", @@ -3252,7 +3252,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.7.1" +version = "0.8.0" dependencies = [ "matrixmultiply", "ndarray", @@ -3266,7 +3266,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.7.1" +version = "0.8.0" dependencies = [ "log", "rustls", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.7.1" +version = "0.8.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3285,7 +3285,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "proptest", "serde", @@ -3293,7 +3293,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.7.1" +version = "0.8.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3311,7 +3311,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.7.1" +version = "0.8.0" dependencies = [ "heic-rs", "rayon", @@ -3319,14 +3319,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.7.1" +version = "0.8.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3335,7 +3335,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3362,7 +3362,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3374,7 +3374,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "lightcraft-color", @@ -3392,7 +3392,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.7.1" +version = "0.8.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3404,7 +3404,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.7.1" +version = "0.8.0" dependencies = [ "libc", "lightcraft-color", @@ -3416,7 +3416,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.7.1" +version = "0.8.0" dependencies = [ "jpeg-encoder", "jxl-oxide", @@ -3438,7 +3438,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "lightcraft-color", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.7.1" +version = "0.8.0" dependencies = [ "candle-core", "candle-nn", @@ -3462,11 +3462,11 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.7.1" +version = "0.8.0" [[package]] name = "lightcraft-tiff" -version = "0.7.1" +version = "0.8.0" dependencies = [ "proptest", "serde", @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "egui", @@ -3502,7 +3502,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "crc32fast", "eframe", @@ -6972,7 +6972,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7effbe0d9..2b89df35e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 1e55d36e36a0ba447374c40cfe9538c4c70342ed Mon Sep 17 00:00:00 2001 From: dev-null-undefined <martinkos007@gmail.com> Date: Wed, 14 Oct 2026 15:58:37 +0200 Subject: [PATCH 168/315] =?UTF-8?q?Perf:=20DNG=20Deflate=20tiles=20at=20le?= =?UTF-8?q?vel=201,=204.8=E2=80=937=C3=97=20faster=20writes=20for=203?= =?UTF-8?q?=E2=80=9312=20%=20more=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deflate-compressed DNG tiles are written at zlib level 1 instead of 6. Measured on Sony A7R VI files (67 MP), 4 threads, best of 3: - Photo Merge DNG (half floats, 3 channels): 3.88 → 0.55 s, 265.7 → 274.8 MB (+3.4 %) - raw export with Deflate (16-bit CFA): 0.89 → 0.18 s, 96.7 → 103.0 MB (+6.5 %) - rendered DNG: 4.60 → 0.69 s, 239.0 → 267.1 MB (+11.8 %) Lossless either way; both written files decode back bit-exactly. Lossless-JPEG DNGs (the default for convert/import) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raw/src/dngwrite.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 379267c87..08281bf38 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -29,6 +29,10 @@ pub enum DngCompression { /// the sensor origin instead of the ActiveArea (the reader keeps their original reading). pub const SOFTWARE: &str = "LightCraft DNG 2"; +/// zlib level of Deflate tiles. On a 67 MP focus-stack merge (half floats) level 6 took 7× as long as level 1 +/// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs. +const DEFLATE_LEVEL: u8 = 1; + /// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). pub fn f32_to_f16(v: f32) -> u16 { let b = v.to_bits(); @@ -274,7 +278,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { } buf.extend_from_slice(&float_predict(&row, tile * cpp, bp, cpp)); } - miniz_oxide::deflate::compress_to_vec_zlib(&buf, 6) + miniz_oxide::deflate::compress_to_vec_zlib(&buf, DEFLATE_LEVEL) }) .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); @@ -337,7 +341,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { } row.iter().for_each(|&x| opts.order.put_u16(&mut buf, x)); } - miniz_oxide::deflate::compress_to_vec_zlib(&buf, 6) + miniz_oxide::deflate::compress_to_vec_zlib(&buf, DEFLATE_LEVEL) }) .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); From f360ad949d21e2dc56d775864cf0645bd039f3e0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 16:22:07 +0200 Subject: [PATCH 169/315] render --settings applies the preset in a .lcpreset, and refuses files it can't read (fixes #1730) --- apps/lightcraft-cli/src/main.rs | 53 ++++++++++++++++++++++++++------ apps/lightcraft-cli/tests/cli.rs | 45 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..7dd1cac18 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -3,7 +3,7 @@ //! ```text //! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]… //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] -//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] +//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json|FILE.lcpreset] [--preset ID] [--size N] [--quality Q] //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] //! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… //! lightcraft-cli synth-merge hdr|panorama -o DIR @@ -55,7 +55,8 @@ USAGE: Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the same encoder as the app's Export dialog. Options: --set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5) - --settings FILE merge a partial develop-settings JSON file + --settings FILE merge a partial develop-settings JSON file, or apply the one preset of a + .lcpreset (preset.export) or XMP preset --preset ID apply a preset (see `commands`/presets.list) --size N long edge in pixels (default: full size, cropped) --quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92) @@ -635,11 +636,41 @@ fn connect_port(addr: &str) -> &str { addr.rsplit_once(':').map_or(addr, |(_, port)| port) } +/// What `render --settings FILE` applies: a partial develop-settings object, or the one preset of +/// a preset file (`.lcpreset` from `preset.export`, XMP), applied as the app applies it. Merging a +/// `.lcpreset` as settings dropped its wrapper and applied nothing (#1730). +enum SettingsFile { + Partial(Value), + Preset(lightcraft_engine::develop::Preset), +} + +fn read_settings(p: &str) -> Result<SettingsFile, String> { + use lightcraft_engine::presets::{LCPRESET_EXT, parse_preset_file}; + let bytes = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?; + let ext = Path::new(p).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + let json = serde_json::from_slice::<Value>(&bytes); + // a settings object never has a top-level `presets` or `settings` key; a preset file always does + let preset_file = + ext == LCPRESET_EXT || ext == "xmp" || json.as_ref().is_ok_and(|v| v.is_array() || v.get("presets").is_some() || v.get("settings").is_some()); + if preset_file { + let mut list = parse_preset_file(p, &bytes).map_err(|e| format!("{p}: {e}"))?; + return match (list.pop(), list.len()) { + (Some(preset), 0) => Ok(SettingsFile::Preset(preset)), + (None, _) => Err(format!("{p}: no presets in this file")), + (Some(_), n) => Err(format!("{p}: {} presets in this file; --settings applies one, export it on its own", n + 1)), + }; + } + match json.map_err(|e| format!("{p}: {e}"))? { + v @ Value::Object(_) => Ok(SettingsFile::Partial(v)), + _ => Err(format!("{p}: not a develop-settings object or a preset file")), + } +} + fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; let mut values = serde_json::Map::new(); - let mut settings: Option<Value> = None; + let mut settings = None; let mut preset = None; let mut size: Option<u64> = None; let mut quality = 92u8; @@ -654,11 +685,7 @@ fn render(args: &[String]) -> Result<(), String> { let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?; values.insert(k.trim().to_string(), json!(v)); } - "--settings" => { - let p = take_value(args, &mut i, "--settings")?; - let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?; - settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?); - } + "--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?), "--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()), "--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?), "--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?, @@ -684,8 +711,14 @@ fn render(args: &[String]) -> Result<(), String> { if let Some(p) = preset { run(&mut s, "preset.apply", json!({"id": p}))?; } - if let Some(st) = settings { - run(&mut s, "develop.merge", json!({"settings": st}))?; + match settings { + Some(SettingsFile::Partial(st)) => run(&mut s, "develop.merge", json!({"settings": st}))?, + Some(SettingsFile::Preset(mut p)) => { + p.id = "cli.settings".into(); + s.presets.push(p); + run(&mut s, "preset.apply", json!({"id": "cli.settings"}))?; + } + None => {} } if !values.is_empty() { run(&mut s, "develop.set", json!({"values": values}))?; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index bf56b02ab..a64e9d3f7 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -139,6 +139,51 @@ fn render_subcommand() { assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); } +/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper +/// and rendered the photo unedited. +#[test] +fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() { + let input = tmp("lcpreset-in.png"); + gradient_png(&input); + let preset = tmp("bright.lcpreset"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "develop.set", + "control=light.exposure", + "value=1", + "preset.create", + "name=Bright", + "preset.export", + &format!("path={}", preset.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let plain = tmp("lcpreset-plain.png"); + let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); + assert!(st.success()); + let bright = tmp("lcpreset-bright.png"); + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()]) + .output() + .unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(mean(&bright) > mean(&plain) + 10.0, "the preset's exposure was not applied"); + + let other = tmp("other.lcpreset"); + std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap(); + let out = tmp("lcpreset-other.png"); + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!o.status.success(), "an unreadable settings file was accepted"); + assert!(String::from_utf8_lossy(&o.stderr).contains("other.lcpreset"), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(!out.exists(), "nothing is written when the settings can't be read"); +} + /// Issue #93: `render IMG -o IMG` replaced its own input with the render. #[test] fn render_refuses_to_overwrite_its_input() { From 3b4279fc2485bb51847c0071a1b3c514aa0f98f4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <brandon@storyteller.ai> Date: Wed, 14 Oct 2026 10:36:53 -0400 Subject: [PATCH 170/315] FreeBSD CI: three more flaky tests made deterministic import_reads_files_off_the_ui_thread_and_can_be_cancelled: the probe was a 150 ms sleep judged by a 300 ms wall-clock frame budget, which the loaded FreeBSD QEMU VM can miss. It now waits on the sleeping-NAS gate of tests_offline, which can also let a counted number of reads through: frames must go on while every read is held (a watchdog records a frame that waited), one batch is let through and joins the catalog, then Cancel and wake: the files being read finish and nothing new starts. compare_at_one_to_one_gets_a_sharp_window_per_pane ("both originals stay decoded"): the media cache takes its budget from the process-wide memory budget, which settings_dialog_by_keyboard_and_clicks sets to 1 GB for a moment (and left there when it failed on FreeBSD). That leaves a 512 MiB share, too small for two 24 MP originals (288 MB each), so one pane's was evicted. compare_at now gives its session the default 768 MiB share; LIGHTCRAFT_MEMORY_MB=1024 reproduces the failure without this. dropping_the_lock_frees_the_library_while_another_thread_spawns_processes: "the spawning thread never got a child off the ground" when a spawn took longer than the 2 s of lock cycles. The cycles now go on until at least 5 children were spawned during them (at most a minute). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/catalog/src/tests_lock.rs | 23 ++++-- crates/ui-egui/src/tests_offline.rs | 92 +++++++++++++---------- crates/ui-egui/src/tests_preview_limit.rs | 9 +++ 3 files changed, 77 insertions(+), 47 deletions(-) diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index d464d5c25..baa8da659 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() { /// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires, /// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix -/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail. +/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a +/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few +/// children have been spawned during them, at most a minute. #[cfg(unix)] #[test] fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + /// Children spawned while the lock cycles, at least, before the test may stop. + const CHILDREN: usize = 5; let dir = temp_dir("spawn"); let stop = Arc::new(AtomicBool::new(false)); + let count = Arc::new(AtomicUsize::new(0)); let spawner = { - let stop = Arc::clone(&stop); + let (stop, count) = (Arc::clone(&stop), Arc::clone(&count)); std::thread::spawn(move || { - let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); - spawned += 1; + count.fetch_add(1, Ordering::Relaxed); } } - spawned + count.load(Ordering::Relaxed) }) }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let t0 = Instant::now(); + let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN; let mut cycles = 0usize; let mut refused = Vec::new(); - while cycles < 200_000 && std::time::Instant::now() < deadline { + while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) { match LibraryLock::acquire(&dir, "LightCraft") { Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back Err(e) => refused.push(format!("cycle {cycles}: {e:?}")), diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index e06fe1401..9e0b85b78 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -1,11 +1,11 @@ //! Slow or offline drives never stall a frame (issue #104): with an injected file-system probe //! that does not answer at all until the test lets it (a sleeping NAS), the grid, the Info panel, -//! the Rename dialog and the Missing Photos view keep drawing frames, the probe never runs on the -//! UI thread, and the background answers arrive once the drive wakes up. +//! the Rename dialog, the Missing Photos view and an import keep drawing frames, the probe never +//! runs on the UI thread, and the background answers arrive once the drive wakes up. //! //! The NAS is a gate, not a sleep: a frame that waited for the drive would wait for good (until a -//! watchdog gives up and records it), so the test needs no frame-time budget and holds on a slow -//! or loaded machine (the FreeBSD CI VM) as it does on a fast one. +//! watchdog gives up and records it), so the tests need no frame-time budget and hold on a slow +//! or loaded machine (the FreeBSD CI VM) as they do on a fast one. use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; @@ -20,8 +20,6 @@ use crate::headless::Headless; use crate::state::{RightPanel, ViewMode}; use crate::{LightcraftApp, Services}; -/// The import test's frame budget (its files take 150 ms each to read). -const FRAME_LIMIT: Duration = Duration::from_millis(300); const N: u64 = 12; /// How long the drive stays asleep at most: only a frame stuck waiting for it gets this far. const WATCHDOG: Duration = Duration::from_secs(120); @@ -43,11 +41,13 @@ impl Calls { } } -/// The sleeping drive: every file-system call waits until [`Nas::wake`]. A watchdog wakes it -/// after [`WATCHDOG`] and records that it had to (some frame was waiting for the drive). +/// The sleeping drive: every file-system call waits until [`Nas::wake`] (or takes one of the +/// calls [`Nas::let_through`] allows). A watchdog wakes it after [`WATCHDOG`] and records that it +/// had to (some frame was waiting for the drive). #[derive(Default)] struct Nas { - awake: Mutex<bool>, + /// (awake, calls let through while asleep) + state: Mutex<(bool, usize)>, changed: Condvar, forced: AtomicBool, } @@ -57,22 +57,30 @@ impl Nas { let nas = Arc::new(Nas::default()); let dog = nas.clone(); std::thread::spawn(move || { - let awake = dog.awake.lock().unwrap(); - let (mut awake, _) = dog.changed.wait_timeout_while(awake, WATCHDOG, |a| !*a).unwrap(); - if !*awake { + let state = dog.state.lock().unwrap(); + let (mut state, _) = dog.changed.wait_timeout_while(state, WATCHDOG, |s| !s.0).unwrap(); + if !state.0 { dog.forced.store(true, Ordering::SeqCst); - *awake = true; + state.0 = true; dog.changed.notify_all(); } }); nas } fn wait(&self) { - let awake = self.awake.lock().unwrap(); - drop(self.changed.wait_while(awake, |a| !*a).unwrap()); + let state = self.state.lock().unwrap(); + let mut state = self.changed.wait_while(state, |s| !s.0 && s.1 == 0).unwrap(); + if !state.0 { + state.1 -= 1; + } + } + /// Answer `n` more calls, then sleep again. + fn let_through(&self, n: usize) { + self.state.lock().unwrap().1 += n; + self.changed.notify_all(); } fn wake(&self) { - *self.awake.lock().unwrap() = true; + self.state.lock().unwrap().0 = true; self.changed.notify_all(); } /// The watchdog had to wake the drive: a frame waited for it. @@ -162,24 +170,27 @@ fn offline_originals_never_block_a_frame() { } /// An import whose files take long to read (probe: decoding headers and hashing on a slow drive) -/// runs on a worker thread: frames stay quick, the probe never runs on the UI thread, and Cancel -/// stops it with what was read so far added. +/// runs on a worker thread: frames go on while the drive holds every read, the probe never runs +/// on the UI thread, and Cancel stops it with what was read so far added. #[test] fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - // enough files that the import is still running when its activity row shows (after 0.5 s) let n = 120; for i in 0..n { std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); } let calls = Calls::default(); + let nas = Nas::asleep(); let mut s = Session::new(); - let c = calls.clone(); + let (c, drive, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id()); s.media.file_probe = Some(Arc::new(move |p: &str| { c.record(); - std::thread::sleep(Duration::from_millis(150)); + // on the UI thread: answered at once, so a regression fails the thread check, not by hanging + if std::thread::current().id() != ui_thread { + drive.wait(); + } Ok(lightcraft_engine::media::ProbeInfo { width: 60, height: 40, @@ -190,32 +201,35 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { })); let app = LightcraftApp::new(s, Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - let ui_thread = std::thread::current().id(); h.step(); - // The first frame installs fonts; finish the first layout before timing the import. + // The first frame installs fonts; finish the first layout before the drive matters. h.step(); - assert!(h.app.fonts_ready, "the UI is initialized before import timing starts"); + assert!(h.app.fonts_ready, "the UI is initialized"); let undo0 = h.app.session.undo.len(); crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); - // some batches arrive; meanwhile every frame is quick - let t0 = Instant::now(); - let mut worst = Duration::ZERO; - while h.app.import.as_ref().is_some_and(|t| t.imported < 8) && t0.elapsed() < Duration::from_secs(60) { - let f = Instant::now(); - h.step(); - worst = worst.max(f.elapsed()); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(worst < FRAME_LIMIT, "import frame took {worst:?}"); - assert!(h.app.import.is_some(), "still importing"); - let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); - assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); - // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts + // every read is held: frames go on, and the import's row shows (after 0.5 s) with its ✕ + frames_while_asleep(&mut h, &nas, 20, "import"); + assert!(h.app.import.is_some(), "importing"); let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row"); let cross = format!("activity:cancel:{id}"); - assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + assert!(!nas.forced(), "an import frame waited for the sleeping drive"); + // the drive answers one batch of reads: it joins the catalog while the next batch waits + let batch = lightcraft_engine::import::batch_size(); + nas.let_through(batch); + let t0 = Instant::now(); + while h.app.import.as_ref().is_some_and(|t| t.imported < batch) && t0.elapsed() < Duration::from_secs(60) { + h.step(); + assert!(!nas.forced(), "an import frame waited for the sleeping drive"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(h.app.import.is_some(), "still importing"); + let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); + assert!(status["imported"].as_u64().unwrap_or(0) >= 8 && status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); + // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); + nas.wake(); let t0 = Instant::now(); while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) { h.step(); diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f4e384ef6..4f058f536 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -881,9 +881,18 @@ mod in_the_loupe { assert!(window[0].0 > whole[0].0, "over the whole-frame render"); } + /// The engine caches' share of the default memory budget (1.5 GiB) on a machine with 6 GiB of + /// RAM or more: room for both panes' 24 MP originals (288 MB each as linear float). + const COMPARE_CACHE: usize = 768 << 20; + /// Compare view of the demo library at `zoom`, settled. + /// + /// The session's cache budget is set here rather than taken from the process-wide memory + /// budget, which other tests change while they run (Settings ▸ Performance ▸ 1 GB leaves a + /// 512 MiB share, too small for two originals, so one pane's was evicted). fn compare_at(zoom: crate::state::Zoom) -> Headless { let (mut h, _) = detail(); + h.app.session.media.set_budget(COMPARE_CACHE); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); From c472f52c84be64ca4f7925a0d9d6fc00ae2fad8f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 14 Oct 2026 14:51:26 +0000 Subject: [PATCH 171/315] Lightroom import: raise catalog/WAL size limit from 1 GiB to 4 GiB Reserve the exact file size before reading so a large catalog does not need twice its size in memory while the buffer grows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TGi6EFawAr2RPfqXsoCrQ --- crates/engine/src/lightroom_catalog.rs | 11 ++++++++--- docs/lightroom-catalog-import.md | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a3..31300812f 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -16,7 +16,8 @@ use serde_json::{Value, json}; type Row = BTreeMap<String, Value>; const COMMAND: &str = "library.importLightroom"; -const MAX_FILE: u64 = 1 << 30; +/// Largest catalog database or WAL read into memory (4 GiB). The whole file is held in RAM, so the cap stays finite. +const MAX_FILE: u64 = 1 << 32; fn error(why: impl Into<String>) -> crate::EngineError { crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() } @@ -234,10 +235,14 @@ pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> { fn bytes(path: &Path) -> Result<Vec<u8>, String> { use std::io::Read; let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; - if f.metadata().map_err(|e| e.to_string())?.len() > MAX_FILE { - return Err("catalog or WAL exceeds 1 GiB import limit".into()); + let len = f.metadata().map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err("catalog or WAL exceeds 4 GiB import limit".into()); } let mut out = Vec::new(); + // Reserve the exact size up front: growing by doubling would briefly need about twice the catalog's size. + let wanted = usize::try_from(len).map_err(|_| "catalog is too large for this platform".to_string())?; + out.try_reserve_exact(wanted).map_err(|_| "not enough memory to read the catalog".to_string())?; f.take(MAX_FILE + 1).read_to_end(&mut out).map_err(|e| e.to_string())?; if out.len() as u64 > MAX_FILE { return Err("catalog grew beyond import limit".into()); diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 1f9deeea4..dbdd92049 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -32,7 +32,7 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un `Interop/lightroom-index.json` records imported identities. Source catalogs and originals are never overwritten. -Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per +Native import is bounded to a 4 GiB database/WAL (each file is read fully into memory), 16 MiB XMP packets and one million rows per table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. Optional history, keyword and collection tables that exceed limits are skipped with warnings; From 14d321c107c690f1de7f2d97f880a9cd771adff2 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:20:34 +0300 Subject: [PATCH 172/315] catalog: slice captured dates on char boundaries so non-ASCII dates don't panic --- crates/catalog/src/query.rs | 11 +++++++---- crates/catalog/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9956703d..99d14deea 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -397,10 +397,13 @@ impl Catalog { let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); for p in self.photos().filter(|p| p.in_library()) { let Some(d) = p.captured.as_deref() else { continue }; - if d.len() >= 10 { - let (months, days) = years.entry(&d[..4]).or_default(); - *months.entry(&d[..7]).or_default() += 1; - *days.entry(&d[..10]).or_default() += 1; + // `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's + // captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index + // would panic — the same rule as `dates::group_label`. + if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) { + let (months, days) = years.entry(year).or_default(); + *months.entry(month).or_default() += 1; + *days.entry(day).or_default() += 1; } } let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::<Vec<_>>(); diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..d2c0d0c64 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -123,6 +123,23 @@ fn filter_search_sort() { assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } +/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an +/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be +/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores +/// the rest instead of panicking on a char boundary. +#[test] +fn date_groups_ignores_a_non_ascii_captured() { + let mut c = Catalog::new(); + let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00"); + // `AddPhoto` takes the field as it is, like `library.importLightroom` building the op + let bad = photo(&mut c, "bad.jpg", "日本語日本語"); + assert_ne!(ok, bad); + let g = c.date_groups(); + assert_eq!(g.len(), 1, "the one well-formed date still groups"); + assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1)); + assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 1); +} + #[test] fn snapshot_and_log_replay() { let mut c = Catalog::new(); From e8818281d5109a2bf447206d2e5c4dcf3cb8f66c Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 10:40:41 -0500 Subject: [PATCH 173/315] LR-IMP-LOCAL: Forget deleted files when browsing from the sidebar too The sidebar reads the folder on a worker thread and skipped the cleanup library.browse does, so the fix for #1392 didn't reach the app. --- crates/engine/src/cmd/browse.rs | 9 ++++++++ crates/ui-egui/src/headless.rs | 37 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 6 ++++++ docs/whats-new.md | 2 +- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 80475651c..df8de4301 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -60,6 +60,15 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> { })) } +impl Session { + /// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when + /// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a + /// UI that reads it on a worker thread instead. Returns how many went. + pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize { + forget_gone(self, dir, subfolders, files) + } +} + /// Local records in the browsed folder whose files are no longer on disk (deleted or moved by /// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets /// them: the folder shows what is on disk, not photos that fail to load (and log an error) on diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..fedc7ee60 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2983,6 +2983,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by + /// another app, as `library.browse` does (issue #1392). + #[test] + fn browsing_again_from_the_ui_forgets_deleted_files() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-gone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let png = lightcraft_engine::export::encode_image(&img, &o).unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + std::fs::write(dir.join("b.png"), &png).unwrap(); + let path = dir.to_string_lossy().to_string(); + let browse = |h: &mut Headless| { + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + }; + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // another app deletes the photo that isn't selected (a selected or open one stays, shown as missing) + let held: Vec<lightcraft_catalog::PhotoId> = + h.app.session.selection.ids.iter().copied().chain(h.app.session.selection.active).chain(h.app.session.previous_active).collect(); + let name = |id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default(); + let gone = if held.iter().any(|id| name(id) == "b.png") { "a.png" } else { "b.png" }; + std::fs::remove_file(dir.join(gone)).unwrap(); + // a later session: the browse's "Add 2 Photos" step is no longer in the undo history (photos it refers to + // are kept, like selected ones) + h.app.session.undo.clear(); + h.app.session.redo.clear(); + // the folder clicked again in the sidebar + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 1, "the deleted file's record is forgotten"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn add_folder_scans_in_the_background() { let mut h = demo([1200.0, 900.0]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..b306a3a28 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -559,6 +559,12 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { } app.session.import_probes = out.probes; if task.browse { + // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, + // as `library.browse` forgets them (issue #1392) + if let Some(b) = app.session.browse.clone() { + let listed: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.forget_gone_from(&b.path, b.subfolders, &listed); + } // what the library doesn't know yet joins the Local view let queue: Vec<String> = out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); diff --git a/docs/whats-new.md b/docs/whats-new.md index 98ed2c2c2..7da834bbc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -4,7 +4,7 @@ ### Local folders - Photos deleted (or moved) by another app no longer stay in a Local folder, failing to load and logging an error - each time you look (issue #1392): browsing the folder again leaves them out. A photo you had rated, flagged or + each time you look (issue #1392): the next time you browse the folder, in a later session, they're left out. A photo you had rated, flagged or edited stays, marked missing, so its changes aren't lost; photos in My Photos are unchanged. ### Hard-edged Heal and Clone spots From 0467c8904d778a2aee5a8a63d7c0df09e7cff590 Mon Sep 17 00:00:00 2001 From: tydude001 <tylergrose321@gmail.com> Date: Wed, 14 Oct 2026 10:41:07 -0500 Subject: [PATCH 174/315] packaging: RPM requires library sonames, so openSUSE can install it LightCraft's RPM requires Fedora package names: `libxkbcommon`, `(vulkan-loader or libglvnd-egl)`. openSUSE has no `libxkbcommon`, `vulkan-loader` or `libglvnd-egl` package, so zypper refuses it with `nothing provides 'libxkbcommon'` (exit 4, which the ArtCraft Launcher shows as "error 4"; storytold/craft-launcher#341). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packaging/linux/nfpm.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e7103f7f3..47543d01d 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -43,9 +43,16 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) recommends: - libwayland-client - libX11 From fbe3810aa82aace7ccb4190bb01d3a43194b87f7 Mon Sep 17 00:00:00 2001 From: Gert Pellin <gert@pellin.be> Date: Wed, 14 Oct 2026 17:42:09 +0200 Subject: [PATCH 175/315] Metadata: list the Panasonic lens name in All Metadata (#1671) photo.allMetadata listed no lens for a RW2: Panasonic writes no Exif LensModel, and the maker note that names the lens is in the Exif of the embedded JpgFromRaw. List that maker note tag (LensType, 0x0051) as a row of a Panasonic group, the same name photo.inspect shows, for a RW2/RWL and for a JPEG with the note in its own Exif. Checked on the CC0 sample DMC-G80 P1000403.RW2: Panasonic / Lens Type = SIGMA 60mm F2.8 DN. --- crates/meta/src/exif.rs | 24 +++++++++++++++++------- crates/meta/src/tags.rs | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index 51da692e8..2ce5ff1a2 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -21,7 +21,7 @@ pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> { let mut m = from_tiff(&tiff); // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) if m.lens_model.is_none() { - m.lens_model = panasonic_lens(data, &tiff).or_else(|| panasonic_jpg_from_raw_lens(&tiff)); + m.lens_model = panasonic_maker_note_lens(data, &tiff); } Ok(m) } @@ -149,6 +149,12 @@ const PANASONIC_LENS_TYPE: u16 = 0x0051; /// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included. const PANASONIC_JPG_FROM_RAW: u16 = 0x002E; +/// The lens name in the Panasonic maker note (tag `0x0051`) of this Exif or RW2 stream (`data` starts at its +/// TIFF header): the note of its own Exif IFD, else, for a raw, the note in its JpgFromRaw's Exif. +pub(crate) fn panasonic_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option<String> { + panasonic_lens(data, tiff).or_else(|| panasonic_jpg_from_raw_lens(tiff)) +} + /// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option<String> { let exif = tiff.exif()?; @@ -176,7 +182,7 @@ pub(crate) mod tests { /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). - fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec<u8> { + pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec<u8> { let note = |at: u32| { let mut v = b"Panasonic\0\0\0".to_vec(); order.put_u16(&mut v, 1); @@ -220,12 +226,11 @@ pub(crate) mod tests { assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None); } - #[test] - fn rw2_reads_the_lens_from_its_jpg_from_raw() { - // a RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD + /// A RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD. + pub(crate) fn panasonic_rw2(lens: &str) -> Vec<u8> { let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; let mut app1 = b"Exif\0\0".to_vec(); - app1.extend(panasonic_exif(ByteOrder::Little, Some("LUMIX S 85/F1.8"), None)); + app1.extend(panasonic_exif(ByteOrder::Little, Some(lens), None)); jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); jpg.extend(app1); jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); @@ -235,7 +240,12 @@ pub(crate) mod tests { ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); ifd0.set_child(t::EXIF_IFD, exif); - let rw2 = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + #[test] + fn rw2_reads_the_lens_from_its_jpg_from_raw() { + let rw2 = panasonic_rw2("LUMIX S 85/F1.8"); let m = read_exif(&rw2); assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8")); assert_eq!(m.focal_length, Some(85.0)); diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 39e26097d..3cbab5cc2 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -1,14 +1,15 @@ //! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info //! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; //! common enumerations are spelled out, everything else is shown as stored. Maker notes and -//! binary blobs are summarised by size. +//! binary blobs are summarised by size; of a maker note only the lens name is listed (the +//! Panasonic one, as Panasonic writes no Exif LensModel). use lightcraft_tiff::{Ifd, Tiff, Value}; /// One metadata row. #[derive(Clone, Debug, PartialEq)] pub struct TagRow { - /// `TIFF`, `EXIF`, `GPS`, `Interop`. + /// `TIFF`, `EXIF`, `GPS`, `Interop`, or a maker note's (`Panasonic`). pub group: &'static str, pub tag: u16, pub name: String, @@ -309,6 +310,13 @@ fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { nef_compression(mn.ifd.u32(0x0093)?) } +/// The Panasonic maker note's lens name (LensType, `0x0051`) as a row, the name `photo.inspect` shows as the lens. +fn panasonic_lens_row(data: &[u8], tiff: &Tiff, out: &mut Vec<TagRow>) { + if let Some(lens) = crate::exif::panasonic_maker_note_lens(data, tiff) { + out.push(TagRow { group: "Panasonic", tag: 0x0051, name: "Lens Type".into(), value: lens.trim().to_string() }); + } +} + /// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, /// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor /// image's, not IFD0's thumbnail's. @@ -347,10 +355,15 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> { { row.value = words.to_string(); } + panasonic_lens_row(bytes, &t, &mut rows); return rows; } let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; - Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() + let data = crate::strip_exif_header(&exif); + let Ok(t) = Tiff::parse(data) else { return Vec::new() }; + let mut rows = tag_rows(&t); + panasonic_lens_row(data, &t, &mut rows); + rows } #[cfg(test)] @@ -461,4 +474,22 @@ mod tests { let b = [b"II*����".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); } + + #[test] + fn panasonic_lens_is_listed_from_the_maker_note() { + let lens = |rows: &[TagRow]| rows.iter().filter(|r| r.group == "Panasonic" && r.tag == 0x0051).map(|r| r.value.clone()).collect::<Vec<_>>(); + // a RW2: the note is only in the JpgFromRaw's Exif + let rw2 = crate::exif::tests::panasonic_rw2("LUMIX S 85/F1.8"); + assert_eq!(lens(&file_tag_rows(&rw2)), ["LUMIX S 85/F1.8"]); + // a JPEG: the note is in its own Exif + let exif = crate::exif::tests::panasonic_exif(lightcraft_tiff::ByteOrder::Big, Some("LUMIX G 25/F1.7"), None); + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpg.extend(b"Exif\0\0"); + jpg.extend(exif); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + assert_eq!(lens(&file_tag_rows(&jpg)), ["LUMIX G 25/F1.7"]); + // no Panasonic note, no row + assert!(lens(&file_tag_rows(&synthetic_raw(Some(3)))).is_empty()); + } } From 3d80ed531c7c11b6f3d5e52eb43970aa6ff64f9d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 17:52:13 +0200 Subject: [PATCH 176/315] Before/After: a click on the Before view zooms in on the point clicked (fixes #1742) --- crates/ui-egui/src/panels/detail.rs | 10 +++--- crates/ui-egui/src/tests_before_after.rs | 39 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..22820ac6b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -750,14 +750,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // side by side / stacked: Before shows the same frame, so a click in its view (white // balance, colour samples, targeted adjustment) means the same point of the photo. // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + // A click-zoom there centres both views on the point clicked (#1742). let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let map = match (split, areas.first(), pointer) { + let (map, img, pane) = match (split, areas.first(), pointer) { (true, Some(before), Some(q)) if before.contains(q) => { - CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan)) + let br = fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan); + (CanvasMap::new(&frame, br), br, *before) } - _ => map, + _ => (map, img_rect, main_area), }; - general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect) + general_interaction(app, ui, &resp, &map, img, pane, native, aspect) } } } diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 50ec66cf4..8a89098fe 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -70,6 +70,45 @@ fn wb_selector_picks_the_same_point_in_the_before_view() { } } +/// A click at Fit zooms in on the point clicked whichever view of a side-by-side or top/bottom +/// Before / After layout it lands in: both views then show the same part of the photo. A click +/// on the Before view used to be measured against the After view's rectangle, so it zoomed in on +/// the photo's edge (#1742). +#[test] +fn a_click_in_the_before_view_zooms_in_on_the_point_clicked() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + let fit = |h: &mut Headless, mode: BeforeAfter| { + h.app.ui.before_after = mode; + h.app.ui.zoom = Zoom::Fit; + h.app.ui.pan = Default::default(); + h.settle(SETTLE); + (h.app.image_rect.expect("After view on screen"), h.app.canvas_rect.expect("canvas")) + }; + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] { + let (img, canvas) = fit(&mut h, mode); + let shift = match mode { + BeforeAfter::SideBySide => egui::vec2(img.left() + img.right() - 2.0 * canvas.center().x, 0.0), + _ => egui::vec2(0.0, img.top() + img.bottom() - 2.0 * canvas.center().y), + }; + assert!(shift.length() > 100.0, "{mode:?}: two views, {shift:?} apart"); + // off the middle of the photo, so that a zoom on another point shows + let after = img.center() + egui::vec2(img.width() * 0.3, img.height() * -0.25); + let mut pans = Vec::new(); + for before_view in [false, true] { + fit(&mut h, mode); + click_at(&mut h, if before_view { after - shift } else { after }); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{mode:?}: the click zoomed in ({:?})", h.app.ui.zoom); + pans.push(h.app.ui.pan); + } + let (a, b) = (pans[0], pans[1]); + assert!((a.0 - 0.8).abs() < 0.02 && (a.1 - 0.25).abs() < 0.02, "{mode:?}: the After view zooms in on the point clicked ({a:?})"); + assert!((b.0 - a.0).abs() < 1e-3 && (b.1 - a.1).abs() < 1e-3, "{mode:?}: the Before view zooms in on the same point ({b:?}, not {a:?})"); + } +} + /// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { let mut sum = [0.0f32; 3]; From 17e05759b4c5c50a79a46c2f90c9cfc687375079 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 18:08:53 +0200 Subject: [PATCH 177/315] DNG export and Convert / Copy as DNG write the colour a raw without matrices opens with (fixes #1745) --- crates/engine/src/camera_preview.rs | 17 ++++++++++ crates/engine/src/cmd/convert.rs | 3 +- crates/engine/src/export.rs | 3 +- crates/engine/src/tests_xmp.rs | 50 +++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..1f3598a99 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -123,6 +123,23 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C dng_colour(&raw.color, &t, look.as_ref()) } +/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens +/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw +/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when +/// the photo opens. +pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) { + if lightcraft_raw::color::has_matrix(&raw.color) { + return; + } + let list3 = raw.opcodes.list3.clone(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let tags = starting_colour_tags(raw, bytes); + raw.opcodes.list3 = list3; + if let Some(tags) = tags { + raw.color = tags; + } +} + /// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward /// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation /// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..fe29410c7 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path Some(r) => r(path)?, None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, }; - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) .map_err(|e| e.to_string())?; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..8309caf7e 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1785,7 +1785,8 @@ impl PreparedExport { if lightcraft_raw::probe(&bytes).is_none() { return Err(format!("{label}: DNG export needs a raw photo")); } - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let compression = match dng { DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..280b3d053 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -950,6 +950,56 @@ fn dng_export_compression_choices() { assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); } +/// Issue #1745: the DNG of a raw without colour matrices of its own (a Nikon Z 9 NEF) had no `ColorMatrix1`, and +/// other raw converters refused it. Export as DNG and Convert / Copy as DNG now write the colour the raw opens with: +/// the measured matrices of a camera that has them (Z f), the neutral fallback of one that doesn't (Z 9, whose +/// look is fitted to its JPEG when there is one). +#[test] +fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() { + use lightcraft_raw::color::{as_shot_white_xy, camera_transform, has_matrix}; + let dir = temp_dir("dng-colour"); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |t: &lightcraft_raw::color::CameraTransform, c: [f64; 3]| { + t.matrix.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()) + }; + for (model, measured) in [("NIKON Z f", true), ("NIKON Z 9", false)] { + let metadata = lightcraft_meta::Metadata { make: Some("NIKON CORPORATION".into()), model: Some(model.into()), ..Default::default() }; + let mut raw = lightcraft_raw::decode(&synthetic_dng_with(None, metadata)).unwrap(); + // a vendor raw's colour: a white balance, no matrices + raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some([0.5, 1.0, 0.7]), ..Default::default() }; + let bytes = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let path = dir.join(format!("{model}.dng")); + std::fs::write(&path, &bytes).unwrap(); + let mut opened = lightcraft_raw::decode(&bytes).unwrap(); + assert!(!has_matrix(&opened.color)); + let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes); + assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}"); + + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let o = crate::export::ExportOptions::from_json(&json!({"format": "dng"})); + let mut exported = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + exported = b.to_vec(); + Ok(()) + }; + crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap(); + let converted = crate::cmd::convert::write_dng_with(None, &path.to_string_lossy(), String::new()).unwrap(); + for (how, dng) in [("export", exported), ("convert", std::fs::read(&converted).unwrap())] { + let back = lightcraft_raw::decode(&dng).unwrap(); + assert!(back.color.color_matrix[0].is_some() && back.color.illuminant[0] != 0, "{model} {how}: {:?}", back.color); + let got = camera_transform(&back, as_shot_white_xy(&back)); + for c in samples { + let (a, b) = (render(&want, c), render(&got, c)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3), "{model} {how} {c:?}: {a:?} vs {b:?}"); + } + } + std::fs::remove_file(&converted).unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); +} + fn write_jpeg(path: &Path, capture: Option<&str>) { let (w, h) = (32usize, 24usize); let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 7) as u8, (i / w * 9) as u8, 90, 255]).collect(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..4d6d1172a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -385,7 +385,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | From b858d3e34aca97cc40c71cf141c31eafa1509492 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:14:57 +0700 Subject: [PATCH 178/315] fix(ui): keep search-field text drags out of titlebar window gestures (#1646) --- crates/ui-egui/src/panels/topbar.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 03810aca9..d29f5a4c1 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -10,7 +10,7 @@ use crate::widgets::{icon_button, register}; /// The bar doubles as the window's title bar: dragging its empty space moves the window and a /// double-click zooms or restores it. Registered before the bar's widgets so they win the click. -fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32) { +fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32, search_rect: Rect) { use crate::titlebar::{Gesture, WindowState, command_for}; let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom())); register(ui.ctx(), "region:titlebar", bar); @@ -27,9 +27,12 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l ctx.data_mut(|d| d.insert_temp(memo, on_widget)); } let on_widget = ctx.data(|d| d.get_temp::<bool>(memo)).unwrap_or(false); - let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) { + // The search field is drawn after this handle. Its hit-test response may not exist + // yet when the press is recorded; the press origin still identifies text selection. + let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p))); + let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search { Some(Gesture::DoubleClicked) - } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget { + } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search { Some(Gesture::DragStarted) } else { None @@ -50,9 +53,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - if app.integrated_titlebar { - window_handle(app, ui, full, left as f32, 12.0); - } // room for the filter icon and the six icons on the right let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); if !app.native_menu { @@ -61,6 +61,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); } let search_left = full.center().x - sw / 2.0; + let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); + if app.integrated_titlebar { + window_handle(app, ui, full, left as f32, 12.0, sr); + } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) @@ -81,7 +85,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); // search field (centred on the window) - let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); let id = crate::text_field::id("field:search"); if std::mem::take(&mut app.ui.focus_search) { ui.memory_mut(|m| m.request_focus(id)); From 44629a0df11c8651d6c46bfaf9423f3924e723ab Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:15:16 +0700 Subject: [PATCH 179/315] test(ui): regress search text dragging without moving the app (#1646) --- crates/ui-egui/src/tests_titlebar.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index 40eba4b13..40d70c452 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -104,6 +104,20 @@ fn double_click_in_the_search_field_does_not_zoom() { assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); } +// Scenario: dragging within the search field selects text rather than moving the window (#1646) +#[test] +fn dragging_within_search_does_not_move_the_window() { + let mut h = integrated(); + let r = widget(&h, "field:search"); + let x = r.left() + r.width() * 0.35; + h.request("ui.click", json!({"x": x, "y": r.center().y}), T); + h.request("ui.text", json!({"text": "landscape"}), T); + play(&mut h); + h.request("ui.drag", json!({"x": x, "y": r.center().y, "toX": x + 60.0, "toY": r.center().y}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + // Scenario: a window with its native title bar (Windows, Linux) leaves the bar alone #[test] fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { From 8a218bddc14380cc1465aa806038eecc1029214c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 18:22:51 +0200 Subject: [PATCH 180/315] Edit panel: the Reset tooltip shows the platform's shortcut, Ctrl+Shift+R on Windows and Linux (fixes #1751) --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/panels/edit.rs | 25 ++++++++++++++++++++++++- 10 files changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..7e2e7e4ba 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1241,7 +1241,7 @@ "Reset Section": "Bereich zurücksetzen", "Reset Tone Curve": "Gradationskurve zurücksetzen", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Alle Kurven zurücksetzen: Punktkurven (alle Kanäle) und parametrische Kurve (Doppelklick auf einen Kanal setzt nur diesen zurück)", - "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", + "Reset all edits": "Alle Bearbeitungen zurücksetzen", "Reshuffle": "Neu mischen", "Resize Face Box": "Gesichtsrahmengröße ändern", "Resize panel window": "Größe des Bereichsfensters ändern", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..6bb8663da 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1212,7 +1212,7 @@ "Reset Section": "Restablecer sección", "Reset Tone Curve": "Restablecer curva de tonos", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Restablecer todas las curvas: curvas de puntos (todos los canales) y paramétrica (doble clic en un canal para restablecer solo ese)", - "Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)", + "Reset all edits": "Restablecer todas las ediciones", "Reshuffle": "Volver a mezclar", "Resize Face Box": "Cambiar tamaño del recuadro de cara", "Resize panel window": "Cambiar tamaño de la ventana del panel", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..5aeaa2638 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1181,7 +1181,7 @@ "Reset Section": "Réinitialiser la section", "Reset Tone Curve": "Réinitialiser la courbe des tonalités", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Réinitialiser toutes les courbes : courbes de points (tous les canaux) et paramétrique (double-cliquez sur un canal pour réinitialiser uniquement celui-ci)", - "Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)", + "Reset all edits": "Réinitialiser toutes les retouches", "Reshuffle": "Mélanger à nouveau", "Resize Face Box": "Redimensionner le cadre de visage", "Resize to": "Redimensionner à", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..19abaf56c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1132,7 +1132,7 @@ "Reset Section": "項目をリセット", "Reset Tone Curve": "トーンカーブをリセット", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "すべてのポイントカーブとパラメトリックカーブをリセット(チャンネルのダブルクリックで個別にリセット)", - "Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)", + "Reset all edits": "すべての編集をリセット", "Resize Face Box": "顔の枠のサイズを変更", "Resize panel window": "パネルウィンドウのサイズを変更", "Resize panels": "パネルのサイズを変更", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..4dc58a297 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1202,7 +1202,7 @@ "Reset Section": "Redefinir seção", "Reset Tone Curve": "Redefinir curva de tonalidade", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Redefine todas as curvas: curvas de pontos (todos os canais) e paramétrica (clique duas vezes em um canal para redefinir apenas ele)", - "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", + "Reset all edits": "Redefinir todas as edições", "Reshuffle": "Embaralhar de novo", "Resize Face Box": "Redimensionar caixa de rosto", "Resize panel window": "Redimensionar janela do painel", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..8dd6a9d2f 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -994,7 +994,7 @@ "Reset Section": "Сбросить раздел", "Reset Tone Curve": "Сбросить тоновую кривую", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Сбросить все кривые: точечные (все каналы) и параметрическую (двойной щелчок по каналу сбрасывает только его)", - "Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)", + "Reset all edits": "Сбросить все правки", "Reshuffle": "Перемешать", "Resize panel window": "Изменить размер окна панели", "Resize panels": "Изменить размеры панелей", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..f1880f3fe 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1228,7 +1228,7 @@ "Reset Parametric Curve": "Скинути параметричну криву", "Reset Tone Curve": "Скинути тональну криву", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Скинути всі криві: точкові криві (усі канали) і параметричну (подвійне клацання каналу скидає лише його)", - "Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)", + "Reset all edits": "Скинути всі зміни", "Resize Face Box": "Змінити розмір рамки обличчя", "Resize to": "Змінити розмір до", "Resolution (ppi)": "Роздільність (ppi)", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..201fc678c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1188,7 +1188,7 @@ "Reset Section": "重置此区段", "Reset Tone Curve": "重置色调曲线", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)", - "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", + "Reset all edits": "重置所有编辑", "Resize Face Box": "调整人脸框大小", "Resize panel window": "调整面板窗口大小", "Resize panels": "调整面板大小", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..33b1a9428 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1188,7 +1188,7 @@ "Reset Section": "重設區段", "Reset Tone Curve": "重設色調曲線", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)", - "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", + "Reset all edits": "重設所有編輯", "Resize Face Box": "調整臉部框大小", "Resize panel window": "調整面板視窗大小", "Resize panels": "調整面板大小", diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..67fd96021 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -125,7 +125,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr("Reset all edits (Cmd+Shift+R)")).clicked() + if text_button(ui, "reset", crate::i18n::tr("Reset"), false) + .on_hover_text(reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac)) + .clicked() { reset_clicked = true; } @@ -1681,6 +1683,15 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { }); } +/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). +fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { + let base = crate::i18n::tr("Reset all edits"); + match crate::shortcuts::shortcut_of(keymap, "develop.reset") { + Some(sc) => format!("{base} ({})", crate::menubar::shortcut_text(sc, mac)), + None => base.to_string(), + } +} + #[cfg(test)] mod curve_histogram_tests { use super::*; @@ -1730,4 +1741,16 @@ mod tests { assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); } + + /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. + #[test] + fn reset_tooltip_names_the_shortcut_of_this_platform() { + let keymap = crate::shortcuts::Keymap::new(); + assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)"); + assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)"); + let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into(); + assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)"); + let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into(); + assert_eq!(reset_tip(&unbound, false), "Reset all edits"); + } } From 648b1b5a930fcf83471124ec98ad0d866658f2bc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Wed, 14 Oct 2026 12:38:02 -0400 Subject: [PATCH 181/315] LRC-LIB-KEYWORDS: the Keyword List holds its place as the Keywords box above it changes Scrolled down a long Keyword List, ticking or unticking a keyword grew or shrank the Keywords box above it (a row of chips, suggestions) and slid the tree under the pointer. The panel now moves its scroll offset by the same amount and draws the pass again, so the list stays put. It holds only while the box's top is scrolled out of sight and the change does not come from the box itself (the pointer on it, or typing in its field). The offset is set on the scroll area as the redrawn pass starts, from the offset the pass before started at: a scroll asked for during a pass lands a frame later, and egui pulls a shrunken panel's offset back by the end of a pass. Ticks clicked one after another (a script, a quick hand) then land on the rows clicked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/right.rs | 64 ++++++++++++++++++++++- crates/ui-egui/src/tests_keyword_list.rs | 66 ++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..612053308 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,6 +15,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { + // (taken on every pass, so a hold the panel was not drawn for is forgotten) + let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { @@ -32,7 +34,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); return; }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]); + if let Some(offset) = held { + area = area.vertical_scroll_offset(offset); + } + // (where the pass starts: by its end egui may have pulled a shrunken panel's offset back) + let area_id = ui.data(|d| d.get_temp::<egui::Id>(egui::Id::new(AREA))); + let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y)); + ui.data_mut(|d| match start { + Some(o) => { + d.insert_temp(egui::Id::new(OFFSET), o); + } + None => d.remove::<f32>(egui::Id::new(OFFSET)), + }); + let scrolled = area.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), @@ -48,9 +63,25 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::None => {} } }); + ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id)); }); } +/// The panel's scroll area, its offset as the last pass started, and how far +/// [`hold_list_in_place`] asks it to move. +const AREA: &str = "right-scroll-id"; +const OFFSET: &str = "right-scroll-offset"; +const HOLD: &str = "right-scroll-hold"; + +/// Where [`hold_list_in_place`] holds the panel: its offset as the pass before started, moved by as much as +/// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list +/// in place (a scroll asked for during a pass only lands a frame later). +fn held_offset(ctx: &egui::Context) -> Option<f32> { + let grew = ctx.data_mut(|d| d.remove_temp::<f32>(egui::Id::new(HOLD)))?; + let offset = ctx.data(|d| d.get_temp::<f32>(egui::Id::new(OFFSET)))?; + Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) +} + pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); @@ -818,6 +849,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.catalog.photo(id).is_none() { return; } + let top = ui.cursor().top(); header(ui, "Keywords"); let t = Tokens::get(ui.ctx()); padded(ui, |ui| { @@ -897,9 +929,39 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } }); + hold_list_in_place(ui, top); super::keyword_list::show(app, ui); } +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::new("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), grew)); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); +} + /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 657e5f337..bdeaef0dc 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -428,3 +428,69 @@ fn dropping_photos_counts_those_that_get_the_keyword() { let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); assert!(toast.contains("1 photo") && !toast.contains("2 photos"), "{toast}"); } + +/// Scrolled down a long list, ticking and unticking keywords leaves the tree where it is: the +/// Keywording box above grows and shrinks out of sight instead of pushing the rows down. +#[test] +fn ticking_a_keyword_keeps_the_list_in_place() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + assert!(rect(&h, "field:keyword").top() < 0.0, "the box is scrolled out of sight"); + let mut box_moved = false; + let picked: Vec<String> = (30..36).map(|i| format!("zz{i}")).collect(); + for (n, k) in picked.iter().chain(picked.iter().rev()).enumerate() { + let (before, box_top) = (rect(&h, &format!("keywordRow:{k}")), rect(&h, "field:keyword").top()); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + assert_eq!(rect(&h, &format!("keywordRow:{k}")), before, "{k} stayed under the pointer"); + // (the click found the row where it is drawn, not where a discarded pass had it) + assert_eq!(keywords_of(&h, ids[0]).contains(k), n < picked.len(), "{k} was the one ticked: {:?}", keywords_of(&h, ids[0])); + box_moved |= rect(&h, "field:keyword").top() != box_top; + } + assert!(keywords_of(&h, ids[0]).is_empty(), "ticked and unticked again"); + assert!(box_moved, "the box changed height (the list was held, not left alone)"); +} + +/// At the top of the panel the box is in sight: a ticked keyword's chip pushes the list down, +/// as before. +#[test] +fn at_the_top_the_box_grows_in_sight() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + let (title, box_top) = (rect(&h, "keywordList:topLevel"), rect(&h, "field:keyword")); + for k in ["aaa1", "aaa2", "aaa3", "aaa4", "aaa5", "aaa6"] { + run(&mut h, "keyword.create", json!({"name": k})); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + } + assert_eq!(rect(&h, "field:keyword"), box_top, "the panel stays at its top"); + assert!(rect(&h, "keywordList:topLevel").top() > title.top(), "the chips pushed the list down"); +} + +/// Ticks clicked one right after another land on the rows clicked: a click looks a row up where +/// it was last drawn, not where a pass drawn again (the list held in place) first had it. +#[test] +fn ticks_in_quick_succession_land_on_their_rows() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + let picked: Vec<String> = (30..36).map(|i| format!("zz{i}")).collect(); + for k in &picked { + // (no settling in between, as a script or a quick hand clicks) + let r = h.request("ui.clickWidget", json!({"id": format!("keywordCheck:{k}")}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let mut have = keywords_of(&h, ids[0]); + have.sort(); + assert_eq!(have, picked); +} From 37b8c256ada8c82e898e1abacfe1b9b8aedcc1f3 Mon Sep 17 00:00:00 2001 From: thanat0s <thanspam@trollprod.org> Date: Tue, 13 Oct 2026 13:39:00 +0200 Subject: [PATCH 182/315] LRC-LIB-PEOPLE: confirm ranked face suggestions in one batch (#1442) Co-Authored-By: GPT-6-Sol medium <noreply@openai.com> --- assets/ATTRIBUTION.md | 1 + contributors/people.toml | 3 + crates/ui-egui/src/headless.rs | 116 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/person.rs | 101 ++++++++++++++++++++++-- crates/ui-egui/src/shortcuts.rs | 3 + docs/faces.md | 5 ++ docs/parity-checklist.md | 4 +- docs/whats-new.md | 3 + 8 files changed, 227 insertions(+), 9 deletions(-) diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..4f7730ecf 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -18,6 +18,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | +| `crates/ui-egui/src/panels/person.rs` | Up arrow on the People page bulk-confirm control, drawn in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-10-13 | n/a | | `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..d7a0afe7a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,6 +25,9 @@ sync_github_name = false [people.rock-meister] sync_github_name = true +[people.Th4nat0s] +display_name = "Thanat0s" + [people.sanyamsingh001] display_name = "sanyamsingh001" diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..d43929d8f 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1387,6 +1387,122 @@ mod tests { assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {"name": " "}}), t)["ok"], false); } + /// A person's More grid confirms a score-bounded batch through either ↑ or the focused tile's A key. Both + /// routes use one undo step and leave hidden, lower-score and already named faces alone. + #[test] + fn person_more_confirms_all_above_with_one_undo_step() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(7).collect(); + assert_eq!(ids.len(), 7); + let region = |name: Option<&str>| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: name.map(str::to_string), + description: None, + }; + for (id, name) in ids.iter().zip([Some("Jane Doe"), None, None, None, None, None, Some("John Roe")]) { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![region(name)]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.settle(SETTLE); + let page = crate::panels::person::PersonPage { + name: "Jane Doe".into(), + total: 1, + confirmed: vec![crate::panels::person::Face { photo: ids[0].0, index: 0, rect: region(None).rect, score: 0.0 }], + more: ids[1..6] + .iter() + .zip([0.95, 0.80, 0.80, 0.80, 0.60]) + .map(|(id, score)| crate::panels::person::Face { photo: id.0, index: 0, rect: region(None).rect, score }) + .collect(), + ready: true, + pending: 0, + }; + let set_page = |h: &mut Headless| { + h.app.caches.person_page = + Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); + h.step(); + }; + h.app.ui.dismissed_faces.insert((ids[4].0, 0)); + set_page(&mut h); + let bulk_id = format!("more-face-above:{}:0", ids[2].0); + assert!(h.request("ui.widgets", json!({"filter": "more-face-above:"}), t).to_string().contains(&bulk_id)); + let undo = h.app.session.undo.len(); + let r = h.request("ui.clickWidget", json!({"id": bulk_id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::<Vec<_>>(); + assert_eq!( + names(&h), + vec![ + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + None, + None, + Some("John Roe".into()) + ] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]); + // With no target A cannot name a face, even on this page. + h.request("ui.move", json!({"x": 1, "y": 1}), t); + h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new(("more-face-above", ids[2].0, 0_usize)))); + set_page(&mut h); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + // Text editing owns A even if a face was the last pointer target. + let search = h.request("ui.clickWidget", json!({"id": "field:search"}), t); + assert_eq!(search["ok"], true, "{search}"); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + // Hovering a tile is enough for the A key, without clicking or moving keyboard focus there. + let tile_id = format!("more-face:{}:0", ids[2].0); + let rect = h.app.widgets.iter().find(|(id, _)| id == &tile_id).unwrap().1; + h.request("ui.move", json!({"x": rect.center().x, "y": rect.center().y}), t); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1..4], [Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into())]); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + h.request("ui.move", json!({"x": 1, "y": 1}), t); + set_page(&mut h); + // Keyboard focus on the candidate makes the same batch action available without hovering it. + h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::new(("more-face", ids[2].0, 0_usize)))); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!( + names(&h), + vec![ + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + None, + None, + Some("John Roe".into()) + ] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "view.photoGrid"}), t); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(h.app.session.undo.len(), undo + 1, "A outside the person page cannot name more faces"); + } + /// The People view lists the unnamed faces below the named people: select some (a click each, or all), type a name, /// press Enter, and they are all named at once, in one undo step; the new person appears among the named. #[test] diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..c265b5bb1 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -1,5 +1,6 @@ //! A person's page in the People view: only cropped faces, never whole photos. First the faces already named, then a -//! "More" section with the unnamed faces that look like them (best first), to be confirmed with a click. +//! "More" section with the unnamed faces that look like them (best first), to be confirmed one at a time or in a +//! user-chosen batch. //! //! The engine does the looking (`faces.person`); this draws it. Only the rows on screen ask for a face render. @@ -82,9 +83,62 @@ enum Hit { Nothing, Open, Confirm, + ConfirmAbove, Dismiss, } +/// The current, session-visible candidates at or above the selected score. The score, not the tile position, is the +/// boundary: a later tile with an equal score belongs to the batch too. +fn at_or_above(more: &[&Face], score: f32) -> Vec<(u64, usize)> { + more.iter().filter(|f| f.score >= score).map(|f| (f.photo, f.index)).collect() +} + +fn confirm_above(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, more: &[&Face], score: f32) { + let faces: Vec<Value> = at_or_above(more, score).into_iter().map(|(photo, index)| json!({"photo": photo, "index": index})).collect(); + if !faces.is_empty() + && let Err(error) = app.run("faces.nameFaces", json!({"faces": faces, "name": name})) + { + app.toast(ctx, error); + } +} + +/// The page-scoped A key takes precedence over View ▸ Visualize Spots only for a visible More tile. Called before the +/// general shortcut dispatcher, using the previous frame's registered rectangles as the pointer's target. +pub(crate) fn handle_bulk_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + if app.ui.view != crate::state::ViewMode::People || app.ui.dialog.is_some() || ctx.text_edit_focused() { + return false; + } + let Some(name) = app.ui.person_page.clone() else { return false }; + if !ctx.input(|i| i.modifiers == egui::Modifiers::NONE && i.key_pressed(egui::Key::A)) { + return false; + } + let Some((cached_name, cached_revision, _, _, page)) = &app.caches.person_page else { return false }; + if *cached_name != name || *cached_revision != app.session.catalog.revision { + return false; + } + let page = page.clone(); + let pointer = ctx.pointer_hover_pos(); + let target = page + .more + .iter() + .filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))) + .find(|f| { + let widget = format!("more-face:{}:{}", f.photo, f.index); + app.widgets.iter().any(|(id, rect)| id == &widget && pointer.is_some_and(|p| rect.contains(p))) + }) + .or_else(|| { + page.more.iter().filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))).find(|f| { + ctx.memory(|m| { + m.has_focus(egui::Id::new(("more-face", f.photo, f.index))) || m.has_focus(egui::Id::new(("more-face-above", f.photo, f.index))) + }) + }) + }); + let Some(target) = target else { return false }; + let more: Vec<&Face> = page.more.iter().filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))).collect(); + confirm_above(app, ctx, &name, &more, target.score); + true +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let t = Tokens::get(ui.ctx()); let now = ui.input(|i| i.time); @@ -161,7 +215,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = page.confirmed.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), PAD + row as f32 * row_h); - if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name) { + if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name, 0) { hit = Some((Hit::Open, f.clone())); } } @@ -171,7 +225,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.painter().text(heading, Align2::LEFT_CENTER, "More", t.semibold(15.0), t.text); let sub = match ¬e { Some(n) => n.clone(), - None => format!("Faces that look like {shown_name}. Click one to confirm it, or × to hide it."), + None => format!("Faces that look like {shown_name}. Click to confirm one, ↑ for all above, or × to hide."), }; ui.painter().text(pos2(area.left() + PAD, area.top() + more_top + 36.0), Align2::LEFT_CENTER, sub, t.font(12.0), t.text_dim); if !recognising { @@ -193,8 +247,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = more.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), grid_top + row as f32 * row_h); - match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name) { + let above_count = more.iter().filter(|candidate| candidate.score >= f.score).count(); + match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name, above_count) { Hit::Confirm => hit = Some((Hit::Confirm, (*f).clone())), + Hit::ConfirmAbove => hit = Some((Hit::ConfirmAbove, (*f).clone())), Hit::Dismiss => hit = Some((Hit::Dismiss, (*f).clone())), _ => {} } @@ -216,6 +272,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { Hit::Confirm => { let _ = app.run("faces.setName", json!({"id": f.photo, "index": f.index, "name": shown_name})); } + Hit::ConfirmAbove => confirm_above(app, ui.ctx(), &shown_name, &more, f.score), Hit::Dismiss => { app.ui.dismissed_faces.insert((f.photo, f.index)); } @@ -226,7 +283,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { /// One face tile. A confirmed one opens its photo when clicked; a "More" one is confirmed when clicked, and has a × that /// hides it. -fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str) -> Hit { +fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::new((salt, f.photo, f.index)), Sense::click()); @@ -234,7 +291,12 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, // the × is added after the tile, so it wins where they overlap let x_rect = Rect::from_min_size(pos2(r.right() - 24.0, r.top() + 4.0), vec2(20.0, 20.0)); let x_resp = more.then(|| ui.interact(x_rect, egui::Id::new(("more-face-x", f.photo, f.index)), Sense::click())); - let hovered = resp.hovered() || x_resp.as_ref().is_some_and(|x| x.hovered()); + let above_rect = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(24.0, 20.0)); + let above_resp = more.then(|| ui.interact(above_rect, egui::Id::new(("more-face-above", f.photo, f.index)), Sense::click())); + if more { + register(ui.ctx(), format!("more-face-above:{}:{}", f.photo, f.index), above_rect); + } + let hovered = resp.hovered() || x_resp.as_ref().is_some_and(|x| x.hovered()) || above_resp.as_ref().is_some_and(|x| x.hovered()); let p = ui.painter(); p.rect_filled(r, 3.0, t.canvas); if let Some(job) = app.session.face_job(lightcraft_catalog::PhotoId(f.photo), f.rect, (r.width() * ppp).ceil() as usize) @@ -247,7 +309,7 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, } let mut hit = Hit::Nothing; if more && hovered { - // a check at the bottom right says a click confirms; the × at the top right hides the face + // a check at the bottom right says a click confirms; × hides and ↑ names all above this score let badge = Rect::from_min_size(pos2(r.right() - 28.0, r.bottom() - 28.0), vec2(24.0, 24.0)); p.circle_filled(badge.center(), 12.0, Color32::from_rgba_unmultiplied(30, 30, 30, 220)); let c = badge.center(); @@ -258,12 +320,25 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, let xc: Pos2 = x_rect.center(); p.line_segment([pos2(xc.x - 3.5, xc.y - 3.5), pos2(xc.x + 3.5, xc.y + 3.5)], Stroke::new(1.6, Color32::WHITE)); p.line_segment([pos2(xc.x - 3.5, xc.y + 3.5), pos2(xc.x + 3.5, xc.y - 3.5)], Stroke::new(1.6, Color32::WHITE)); + p.rect_filled(above_rect, 4.0, Color32::from_rgba_unmultiplied(30, 30, 30, 220)); + let ac = above_rect.center(); + let arrow = Stroke::new(2.0, Color32::WHITE); + p.line_segment([pos2(ac.x, ac.y + 5.0), pos2(ac.x, ac.y - 5.0)], arrow); + p.line_segment([pos2(ac.x - 4.0, ac.y - 1.0), pos2(ac.x, ac.y - 5.0)], arrow); + p.line_segment([pos2(ac.x, ac.y - 5.0), pos2(ac.x + 4.0, ac.y - 1.0)], arrow); } if more { let tip = format!("Looks like {name} (similarity {:.2}). Click to confirm.", f.score); let _ = resp.clone().on_hover_text(tip); + if let Some(above) = above_resp.as_ref() { + let _ = above.clone().on_hover_text(format!( + "All above: confirm {above_count} faces as {name} (this face and every shown face with equal or higher similarity). Press A." + )); + } if x_resp.as_ref().is_some_and(|x| x.clicked()) { hit = Hit::Dismiss; + } else if above_resp.as_ref().is_some_and(|x| x.clicked()) { + hit = Hit::ConfirmAbove; } else if resp.clicked() { hit = Hit::Confirm; } @@ -280,6 +355,18 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, mod tests { use super::*; + #[test] + fn all_above_uses_score_including_later_ties_and_only_visible_faces() { + let face = |photo, score| Face { photo, index: 0, rect: lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }, score }; + // The tie is deliberately after a lower-score tile: an index slice would miss it. + let candidates = [face(1, 0.95), face(2, 0.80), face(3, 0.70), face(4, 0.80), face(5, 0.60)]; + let dismissed = (2_u64, 0_usize); + let shown: Vec<&Face> = candidates.iter().filter(|f| (f.photo, f.index) != dismissed).collect(); + assert_eq!(at_or_above(&shown, 0.80), vec![(1, 0), (4, 0)], "the dismissed face is excluded and the late tie included"); + assert_eq!(at_or_above(&shown, 0.95), vec![(1, 0)], "the highest choice names only itself"); + assert_eq!(at_or_above(&shown, 0.60), vec![(1, 0), (3, 0), (4, 0), (5, 0)], "the lowest choice names the full displayed range"); + } + #[test] fn the_engines_answer_becomes_a_page_and_odd_entries_are_left_out() { let v = json!({ diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b10387c76..e087130f7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -339,6 +339,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if app.ui.dialog.is_some() { return; } + if app.recording_shortcut.is_none() && app.ui.name_edit.is_none() && crate::panels::person::handle_bulk_key(app, ctx) { + return; + } // don't steal keys from text fields or from the keymap editor recording a shortcut if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; diff --git a/docs/faces.md b/docs/faces.md index 62309f337..41a39e0a8 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -278,6 +278,11 @@ by its own box. In **People**, a click on a person opens their page: **only cropped faces**, never whole photos. First the faces named with their name (a click opens that photo), then **More**: the unnamed faces that look like them, most alike first. Click one to confirm it (that names the face, as one undo step, and it moves up into their faces), or × to hide it for this session. +The `↑` control at the top left of a suggested face confirms that face and every currently shown, non-hidden suggestion +with an equal or higher similarity score. Its tooltip names the person and shows the number of faces before you act. +Hover a suggestion or focus it with the keyboard and press `A` for the same action. The batch is one undo step; +suggestions below that score, previously confirmed faces and hidden suggestions stay as they were. Nothing is named +without this explicit action. **Show photos** puts their photos in the grid; **‹ People** or Escape goes back. "More" only holds faces the scan has already embedded, so it fills in as the scan goes; it shows faces scoring above four fifths of the model's suggestion bar, since you look at each one before anything is named. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..2b9f08473 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -104,7 +104,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | @@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..96926b4c2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -108,6 +108,9 @@ - An export enlarged far past the photo's size (20000 typed for 2000 with Don't Enlarge off) no longer ends LightCraft: an enlargement stops at 512 megapixels, as in Lightroom, and one that needs more memory than the computer has is refused with the size it would take, before it starts. +### Confirm matching faces together +- On a person's page, the ↑ control on a suggested face confirms it and all shown suggestions with equal or higher + similarity as one undo step. Hover or focus that face and press A for the same action; hidden suggestions stay hidden. ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you From 2b635bb16759f5d90538e21ece67c8c31bde26c0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Wed, 14 Oct 2026 12:41:48 -0400 Subject: [PATCH 183/315] LRC-LIB-KEYWORDS: control requests wait for a frame's first pass; a hold lasts one pass A pass drawn again after a discard still has the widgets of the pass thrown away, so a control request answered there found a row where it no longer is; it also added a 240 fps sample to perf.fps. Control requests and the fps sample now run on a frame's first pass only. The Keyword List's hold is kept for the pass right after it was asked for, so it never moves a panel that was not drawn in between. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 16 ++++++++++------ crates/ui-egui/src/panels/right.rs | 9 ++++++--- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..35d29e06b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1021,13 +1021,17 @@ impl LightcraftApp { if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + // (once a frame: a pass drawn again after a discard has no input of its own, and still the + // widgets of the pass thrown away, where a request would find them out of place) + if ctx.current_pass_index() == 0 { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + self.drain_control(ctx); } - self.last_time = now; - self.drain_control(ctx); self.apply_settings(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 612053308..49f1949d0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,7 +15,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { - // (taken on every pass, so a hold the panel was not drawn for is forgotten) + // (taken on every pass the panel is drawn; one from earlier than the pass before is forgotten) let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection @@ -77,7 +77,10 @@ const HOLD: &str = "right-scroll-hold"; /// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list /// in place (a scroll asked for during a pass only lands a frame later). fn held_offset(ctx: &egui::Context) -> Option<f32> { - let grew = ctx.data_mut(|d| d.remove_temp::<f32>(egui::Id::new(HOLD)))?; + let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?; + if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) { + return None; + } let offset = ctx.data(|d| d.get_temp::<f32>(egui::Id::new(OFFSET)))?; Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) } @@ -957,7 +960,7 @@ fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { if on_box || typing { return; } - ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), grew)); + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), (grew, pass))); // (drawn again at once at the new offset, so the list never shows a frame out of place) ui.ctx().request_discard("the Keyword List holds its place"); } From 1f4aaaec4728b18e8380ee1f0249280084d4abfc Mon Sep 17 00:00:00 2001 From: LabDennis <341377186+LabDennis@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:52:15 +0200 Subject: [PATCH 184/315] Fonts: finish Thai fallback delivery for desktop and web releases Retain the complete reviewed change, including prior integration resolutions, on current upstream. #736: deliver Thai UI fonts in desktop and web releases Use the merged craft-fonts input containing Noto Sans Thai across release, FreeBSD and Nix builds. Retain the small Thai face in the wasm subset and reject required release inputs that lack it. Exercise the reported Thai folder name through all UI families and include original font licence texts in Windows installers as well as portable packages. Refs #736; completes delivery of the fallback added in #750. Validation: UI tests, UI-only strict Clippy, 28 wasm checks, HEIF checks, assets, layers, parity and formatting pass. All 21 unrelated workspace failures reproduce with upstream Rust sources and the old font input; existing Rust 1.95 metadata/engine Clippy findings remain unchanged. --- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 12 ++++++------ NOTICE | 7 ++++--- assets/ATTRIBUTION.md | 2 +- crates/engine/build.rs | 9 +++++++-- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/theme.rs | 27 +++++++++++++++++++++------ docs/web.md | 3 ++- flake.lock | 8 ++++---- flake.nix | 2 +- packaging/windows/font-licences.ps1 | 22 ++++++++++++++++++++++ packaging/windows/lightcraft.wxs | 3 +++ packaging/windows/package.ps1 | 4 ++++ 13 files changed, 77 insertions(+), 26 deletions(-) create mode 100644 packaging/windows/font-licences.ps1 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index c63fdeef5..b10e65a75 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7e3036d6..739a5f262 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,7 +78,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -136,7 +136,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -194,7 +194,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - name: Install build and packaging tools run: | @@ -291,7 +291,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -362,7 +362,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - name: Build and package in a FreeBSD VM (tar.gz) # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). @@ -411,7 +411,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: diff --git a/NOTICE b/NOTICE index c9d650bb1..8b16eeba0 100644 --- a/NOTICE +++ b/NOTICE @@ -51,9 +51,10 @@ no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included. Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md -(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ -UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's -licence as OFL-<family>.txt. +(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans +Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto +Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's +licence as OFL-<family>.txt; Windows installers include their texts in FONT-LICENCES.txt. Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and are never distributed with it. On the user's request (Photo > Download Place Names) the app diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..6d2d8ec22 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -24,7 +24,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | -| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | +| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | Notes diff --git a/crates/engine/build.rs b/crates/engine/build.rs index e097889ff..056c7adc8 100644 --- a/crates/engine/build.rs +++ b/crates/engine/build.rs @@ -3,7 +3,7 @@ //! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this //! script only reads a local directory (no network). //! -//! wasm32 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the +//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the //! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export //! watermark falls back to it there. Native builds embed every font. use std::fmt::Write as _; @@ -69,6 +69,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> { let mut out = String::new(); let mut has_japanese_ui = false; let mut has_chinese_ui = false; + let mut has_thai_ui = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { @@ -76,7 +77,8 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> { }; has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular"); has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans"); - if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") { + has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai"); + if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; @@ -89,6 +91,9 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> { path.display().to_string(), ); } + if required && !has_thai_ui { + return Err("required Thai UI face Noto Sans Thai Regular (Thai) is missing from fonts/manifest.txt".into()); + } if required && !has_japanese_ui { return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into()); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..a7c8c1968 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -780,7 +780,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the render failed while a stand-in is up: what is on screen is all there will be (also while // a library change retries it) let failed = (!rendering - && !(show_before && !split) + && (!show_before || split) && !matches!(shown, "render" | "none" | "hover") && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) .then(|| app.renderer.failure_of(Slot::Main, id)) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 282f9c70e..e09db8810 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -462,14 +462,15 @@ mod tests { } } - /// #736: a Thai folder name drew boxes because only CJK craft-fonts faces were fallbacks. With a - /// craft-fonts build that carries a Thai face, an English UI offers it in every family. + /// #736: the release input must carry Thai, not silently skip the check with an older pin. + /// Exercise the Thai characters of the reported folder name through every egui family. #[test] - fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { - let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { - eprintln!("no Thai face in craft-fonts: nothing to check"); + fn thai_folder_name_has_glyphs_in_every_ui_family() { + if lightcraft_engine::CRAFT_FONTS.is_empty() { + eprintln!("built without CRAFT_FONTS_DIR: no craft-fonts to check"); return; - }; + } + let thai = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")).expect("font input includes Thai"); set_language(Locale::En); let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); let name = craft_font_name(thai); @@ -480,6 +481,20 @@ mod tests { let names = definitions.families.get(&family).expect("UI font family"); assert!(names.contains(&name) || (family == semibold_family() && names.contains(&strong)), "{family:?} falls back on {name}"); } + let ctx = egui::Context::default(); + ctx.set_fonts(definitions); + let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {}); + frame.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] { + let id = FontId::new(13.0, family); + // Check the Thai portion; egui 0.36's has_glyph reports false for characters + // in the replacement glyph's own face (including normal monospace Latin). + for ch in "Studio34 ถ่ายภาพงานแต่งงาน".chars().filter(|ch| !ch.is_ascii()) { + assert!(fonts.has_glyph(&id, ch), "{id:?} has {ch}"); + } + } + }); } #[test] diff --git a/docs/web.md b/docs/web.md index a3f705db4..b275e837d 100644 --- a/docs/web.md +++ b/docs/web.md @@ -55,7 +55,8 @@ gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further. **Fonts.** The browser has no system fonts to fall back on, so CJK text comes from [craft-fonts](https://github.com/storytold/craft-fonts), the optional `CRAFT_FONTS_DIR` build input (`CRAFT_FONTS_DIR=../craft-fonts cargo xtask web`; release builds always set it). On wasm32 -`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI and watermark fallback). The web +`crates/engine/build.rs` embeds BIZ UDPGothic Regular (UI and watermark fallback) and Noto Sans +Thai Regular (~214 KiB, for Thai file and folder names in every UI language). The web bundle also ships Noto Sans CJK SC Regular as `lightcraft_zh_hans.otf` and loads it before the UI starts. Keeping the 16 MB Chinese face separate leaves the module under Cloudflare's 25 MiB per-file limit (the older Japanese-only module measured 21.8 MB). Without `CRAFT_FONTS_DIR`, the diff --git a/flake.lock b/flake.lock index ecf87d370..a9953fd12 100644 --- a/flake.lock +++ b/flake.lock @@ -3,17 +3,17 @@ "craft-fonts": { "flake": false, "locked": { - "lastModified": 1791311399, - "narHash": "sha256-4E9OrthE5z39xFXpvpwn4Fn6RH3wX2sF56rZZejZT+I=", + "lastModified": 1791722036, + "narHash": "sha256-mNnDzBcOp1X10dzmiW0iBAkb74QjuzMO4slWudxXCTE=", "owner": "storytold", "repo": "craft-fonts", - "rev": "8dcdacd5153e64560d109541a47d806f26f048c0", + "rev": "3d9ab3a132ed0eff457e424226a435a901da30d3", "type": "github" }, "original": { "owner": "storytold", "repo": "craft-fonts", - "rev": "8dcdacd5153e64560d109541a47d806f26f048c0", + "rev": "3d9ab3a132ed0eff457e424226a435a901da30d3", "type": "github" } }, diff --git a/flake.nix b/flake.nix index e12b1f8af..51dd933f9 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ # glyphs. Pinned to the revision the release workflow uses — bump deliberately # (`nix flake update craft-fonts`); craftrules standards/fonts.md. craft-fonts = { - url = "github:storytold/craft-fonts/8dcdacd5153e64560d109541a47d806f26f048c0"; + url = "github:storytold/craft-fonts/3d9ab3a132ed0eff457e424226a435a901da30d3"; flake = false; }; }; diff --git a/packaging/windows/font-licences.ps1 b/packaging/windows/font-licences.ps1 new file mode 100644 index 000000000..3fd143adb --- /dev/null +++ b/packaging/windows/font-licences.ps1 @@ -0,0 +1,22 @@ +# Keep the upstream licence texts intact, including the copyright notices above the OFL. +function Write-FontLicences([string] $Destination, [string] $InterLicence, [string] $CraftFontsDir) { + if ($env:CRAFT_FONTS_REQUIRED -and -not $CraftFontsDir) { throw 'CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing' } + $parts = [Collections.Generic.List[string]]::new() + $parts.Add("LightCraft font licences`n") + $parts.Add([IO.File]::ReadAllText($InterLicence)) + if ($CraftFontsDir) { + $manifest = Join-Path $CraftFontsDir 'fonts\manifest.txt' + $licences = Get-Content -LiteralPath $manifest | ForEach-Object { + if ($_.Trim() -and -not $_.Trim().StartsWith('#')) { + $fields = $_ -split '\|' + if ($fields.Count -lt 6) { throw "Malformed font manifest line: $_" } + $fields[5].Trim() + } + } | Sort-Object -Unique + foreach ($licence in $licences) { + $parts.Add("`n--- $licence ---`n") + $parts.Add([IO.File]::ReadAllText((Join-Path $CraftFontsDir $licence))) + } + } + [IO.File]::WriteAllText($Destination, ($parts -join "`n"), [Text.UTF8Encoding]::new($false)) +} diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index 38fe8f391..533c24d20 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -206,6 +206,9 @@ <Component Id="LightcraftCli"> <File Id="LightcraftCliExe" Source="$(var.BinDir)\lightcraft-cli.exe" KeyPath="yes" /> </Component> + <Component Id="LightcraftFontLicences"> + <File Id="LightcraftFontLicencesFile" Source="$(var.BinDir)\FONT-LICENCES.txt" KeyPath="yes" /> + </Component> </ComponentGroup> </Fragment> </Wix> diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index e2cd2a8e7..fc031c6e3 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -112,6 +112,9 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'lightcraft.exe'), (Join-Path $Bin 'lightcraft-cli.exe') $Stage +# Include the embedded fonts' unmodified OFL texts in both installer and portable releases. +. (Join-Path $PSScriptRoot 'font-licences.ps1') +Write-FontLicences (Join-Path $Stage 'FONT-LICENCES.txt') (Join-Path $Root 'assets\fonts\OFL-Inter.txt') $env:CRAFT_FONTS_DIR & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'lightcraft.exe') (Join-Path $Stage 'lightcraft-cli.exe') @@ -147,6 +150,7 @@ $Portable = Join-Path $TargetDir "windows-package\lightcraft-$Version-windows-$A Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable +Copy-Item (Join-Path $Stage 'FONT-LICENCES.txt') $Portable foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } From 6e0880769a4a8bc1a3c0ec776fb2ac86bc0c0f57 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:31:05 +0200 Subject: [PATCH 185/315] gpu: init-marker tests use their own scope counter so parallel device tests cannot join them (#1721) --- crates/gpu/src/backend.rs | 46 +++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 57e47f22e..d970003e7 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -213,12 +213,12 @@ fn disarm(marker: &std::path::Path, active: &Mutex<u32>) { } /// Removes the marker on the way out, a panic included (the process survived). -struct Armed(Option<PathBuf>); +struct Armed<'a>(Option<PathBuf>, &'a Mutex<u32>); -impl Drop for Armed { +impl Drop for Armed<'_> { fn drop(&mut self) { if let Some(m) = &self.0 { - disarm(m, &ACTIVE); + disarm(m, self.1); } } } @@ -227,10 +227,16 @@ impl Drop for Armed { /// removing the init marker (if one is set). pub(crate) fn with_marker<T>(what: &str, f: impl FnOnce() -> T) -> T { let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(); + with_marker_on(marker, &ACTIVE, what, f) +} + +/// [`with_marker`] on an explicit marker path and scope counter (the tests use their own, so that +/// device-creating tests running in parallel cannot join their scopes). +fn with_marker_on<T>(marker: Option<PathBuf>, active: &Mutex<u32>, what: &str, f: impl FnOnce() -> T) -> T { if let Some(m) = &marker { - arm(m, &ACTIVE, what); + arm(m, active, what); } - let _armed = Armed(marker); + let _armed = Armed(marker, active); f() } @@ -352,9 +358,6 @@ mod tests { } use super::*; - /// The tests that set the process-wide marker take turns. - static GLOBAL: Mutex<()> = Mutex::new(()); - #[test] fn parses_backend_names() { assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12))); @@ -421,13 +424,12 @@ mod tests { #[test] fn init_marker_is_written_during_creation_and_removed_after() { - let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id())); let m = dir.join("gpu-init.marker"); let _ = std::fs::remove_dir_all(&dir); - set_init_marker(Some(m.clone())); - let seen = with_init_marker(Backends::DX12, || m.exists()); - set_init_marker(None); + // a local scope counter: other tests create real devices through the process-wide one + let active = Mutex::new(0); + let seen = with_marker_on(Some(m.clone()), &active, "device creation", || m.exists()); assert!(seen, "the marker exists while the device is created"); assert!(!m.exists(), "and is removed afterwards"); // a marker left behind by a crashed process is reported once; reading it leaves it in place @@ -440,6 +442,20 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn with_init_marker_writes_the_process_wide_marker() { + let dir = std::env::temp_dir().join(format!("lc-gpu-global-{}", std::process::id())); + let m = dir.join("gpu-init.marker"); + let _ = std::fs::remove_dir_all(&dir); + set_init_marker(Some(m.clone())); + let seen = with_init_marker(Backends::DX12, || m.exists()); + set_init_marker(None); + // not checked for removal: device-creating tests running in parallel may still hold the file + // (the test above checks removal on its own scope counter) + assert!(seen, "the marker set with set_init_marker exists while the device is created"); + let _ = std::fs::remove_dir_all(&dir); + } + /// The window's renderer fallback: forced, remembered, the platform chain, the failed one last. #[test] fn window_renderer_fallback_order() { @@ -505,13 +521,11 @@ mod tests { #[test] fn a_panic_inside_the_scope_still_removes_the_marker() { - let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("lc-gpu-panic-{}", std::process::id())); let m = dir.join("gpu-init.marker"); let _ = std::fs::remove_dir_all(&dir); - set_init_marker(Some(m.clone())); - let r = std::panic::catch_unwind(|| with_marker("kernel compilation", || panic!("compile"))); - set_init_marker(None); + let active = Mutex::new(0); + let r = std::panic::catch_unwind(|| with_marker_on(Some(m.clone()), &active, "kernel compilation", || panic!("compile"))); assert!(r.is_err()); assert!(!m.exists(), "the process survived, so nothing is left to report"); let _ = std::fs::remove_dir_all(&dir); From 4551790eabb2c6698df5f07a901073d808cd30d9 Mon Sep 17 00:00:00 2001 From: BuonBreda <86723373+BuonBreda@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:32:06 +0200 Subject: [PATCH 186/315] =?UTF-8?q?LRC-DEV-LOUPEOVERLAY:=20a=20layout=20im?= =?UTF-8?q?age=20over=20the=20loupe=20(View=20=E2=96=B8=20Loupe=20Overlay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightroom Classic's Loupe Overlay ▸ Layout Image: a PNG with transparency (a cover's masthead and cover lines, a banner's safe areas) drawn over the photo in the loupe, fitted and centred, following zoom and pan. Opacity and matte (the photo darkened outside the graphic's box) in steps, ⌥⌘O shows / hides it, the choice is saved with the UI state. The file is read off the UI thread; an unreadable one is reported on the canvas. engine::export::read_graphic is the watermark logo's reader, now shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mj2w3LwYjFhMHALZuFp2rU --- crates/engine/src/export.rs | 11 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 34 ++- crates/ui-egui/src/menus.rs | 52 +++++ crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/panels/layout_overlay.rs | 223 ++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/state.rs | 36 ++++ crates/ui-egui/src/tests_layout_image.rs | 181 ++++++++++++++++ docs/parity-checklist.md | 7 +- docs/whats-new.md | 6 + 11 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/panels/layout_overlay.rs create mode 100644 crates/ui-egui/src/tests_layout_image.rs diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..6ee4cfb46 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -560,12 +560,19 @@ fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> { { return Some(img.clone()); } - let bytes = std::fs::read(path).ok()?; - let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8()); + let img = Arc::new(read_graphic(path).ok()?); map.insert(path.to_string(), (modified, img.clone())); Some(img) } +/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the +/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded. +pub fn read_graphic(path: &str) -> Result<Rgba8, String> { + let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; + Ok(decoded.to_srgb8()) +} + /// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to /// `image_width` of the photo, placed like the text, blended with its alpha × opacity. fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..ae0550e6a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -89,6 +89,8 @@ mod tests_keywording; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_layout_image; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_lights_out; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..7a00ec8a3 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -136,6 +136,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.histogram", "view.navigator", "view.infoOverlay", + "@Loupe Overlay", "@Appearance", "---", "view.fullScreenPreview", @@ -329,6 +330,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), "view.navigator" => Some(u.navigator), + "view.layoutImage" => Some(u.layout_image.show && !u.layout_image.path.trim().is_empty()), "view.fullScreenPreview" => Some(u.fullscreen), "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), "view.enterFullScreen" => Some(app.window_is_fullscreen), @@ -367,7 +369,7 @@ fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { } /// The parameterized submenus. -fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { +pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); let has = active.is_some(); Some(match name { @@ -507,6 +509,36 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { } v } + "Loupe Overlay" => { + // Lightroom Classic's View ▸ Loupe Overlay ▸ Layout Image: choose the graphic, show it, + // and how opaque it is and how dark the photo is outside it + let li = &app.ui.layout_image; + let chosen = !li.path.trim().is_empty(); + let mut v = vec![ + item("view.chooseLayoutImage", Value::Null, "Choose Layout Image…", None, true, None), + item( + "view.layoutImage", + Value::Null, + "Show Layout Image", + crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.layoutImage"), + chosen, + Some(li.show && chosen), + ), + MenuNode::Separator, + ]; + let near = |a: f32, b: f32| (a - b).abs() < 0.005; + for step in crate::state::LayoutImage::OPACITY_STEPS { + let label = format!("{} {:.0}%", crate::i18n::tr("Opacity"), step * 100.0); + v.push(item("view.layoutImageOpacity", json!({"opacity": step}), label, None, chosen, Some(near(li.opacity, step)))); + } + v.push(MenuNode::Separator); + for step in crate::state::LayoutImage::MATTE_STEPS { + let label = + if step == 0.0 { crate::i18n::tr("No Matte").to_string() } else { format!("{} {:.0}%", crate::i18n::tr("Matte"), step * 100.0) }; + v.push(item("view.layoutImageMatte", json!({"matte": step}), label, None, chosen, Some(near(li.matte, step)))); + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..52f8dbbbe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -112,6 +112,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), + ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), + ("view.layoutImageMatte", "Layout Image Matte", None, ""), ("view.navigator", "Navigator", None, "View"), ("panel.edit", "Edit", Some("E"), "Window"), ("panel.profiles", "Profile Browser", None, "Window"), @@ -651,6 +655,54 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.toast(&ctx, label); Ok(json!({"infoOverlay": app.ui.info_overlay})) } + "view.chooseLayoutImage" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(path) => path.trim().to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => match paths.first() { + Some(path) => path.trim().to_string(), + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), + } + } + }; + if path.is_empty() { + return Some(Err("view.chooseLayoutImage: path is empty".into())); + } + let li = &mut app.ui.layout_image; + li.path = path; + li.show = true; + li.revision = li.revision.wrapping_add(1); + app.toast(&ctx, "Layout Image On"); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImage" => { + let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); + // nothing chosen yet: showing it chooses one first + if show && app.ui.layout_image.path.trim().is_empty() { + return run_ui_command(app, "view.chooseLayoutImage", &json!({})); + } + app.ui.layout_image.show = show; + app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImageOpacity" | "view.layoutImageMatte" => { + let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; + let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { + return Some(Err(format!("{id}: {key} (0..1) is required"))); + }; + let v = v.clamp(0.0, 1.0) as f32; + if key == "opacity" { + app.ui.layout_image.opacity = v; + } else { + app.ui.layout_image.matte = v; + } + Ok(json!({"layoutImage": app.ui.layout_image})) + } "view.navigator" => { app.ui.navigator = !app.ui.navigator; Ok(json!({"navigator": app.ui.navigator})) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..96697c16e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -689,6 +689,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, &p, img_rect); register(ui.ctx(), "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); info_overlay(app, &p, canvas, &photo); diff --git a/crates/ui-egui/src/panels/layout_overlay.rs b/crates/ui-egui/src/panels/layout_overlay.rs new file mode 100644 index 000000000..9b4800d0f --- /dev/null +++ b/crates/ui-egui/src/panels/layout_overlay.rs @@ -0,0 +1,223 @@ +//! View ▸ Loupe Overlay ▸ Layout Image, as in Lightroom Classic: a graphic with transparency (a +//! magazine cover's masthead and cover lines, a web banner's safe areas, a print layout's +//! margins) drawn over the photo in the loupe, so a shot can be judged against the page it is made +//! for. The graphic is fitted inside the photo and centred, and follows zoom and pan with it. +//! `opacity` fades it; `matte` darkens the photo outside its box, where the page would crop it. +//! +//! The file is read and decoded off the UI thread, once per path and Choose Layout Image… (an +//! offline drive never holds up a frame); until it arrives nothing is drawn, and a file that can't +//! be read says so at the canvas' bottom left. Graphics larger than [`MAX_SIDE`] are scaled down +//! once, premultiplied so transparent edges stay clean. + +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::{Color32, Rect, pos2, vec2}; +use lightcraft_raster::Rgba8; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// The longest side a layout image keeps (a screen never shows more of it). +pub const MAX_SIDE: usize = 4096; + +/// The decoded graphic once the worker is done, or why it couldn't be read. +type Loaded = Arc<Mutex<Option<Result<Rgba8, String>>>>; + +/// What the loupe keeps between frames (in egui's temporary memory). +#[derive(Clone)] +struct Cache { + /// The (path, revision) the file was read for. + file: (String, u32), + loaded: Loaded, + /// The texture made from the graphic, and the display profile it was made for. + tex: Option<(Option<u64>, egui::TextureHandle)>, +} + +/// The rectangle a `size` (pixels) graphic takes fitted inside `photo` and centred on it. +pub fn fit(photo: Rect, size: [usize; 2]) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let scale = (photo.width() / w).min(photo.height() / h).max(0.0); + Rect::from_center_size(photo.center(), vec2(w * scale, h * scale)) +} + +/// The parts of `photo` outside `inner` (the matte): up to four strips. +pub fn outside(photo: Rect, inner: Rect) -> Vec<Rect> { + let inner = inner.intersect(photo); + let strips = [ + Rect::from_min_max(photo.min, pos2(inner.left(), photo.bottom())), + Rect::from_min_max(pos2(inner.right(), photo.top()), photo.max), + Rect::from_min_max(pos2(inner.left(), photo.top()), pos2(inner.right(), inner.top())), + Rect::from_min_max(pos2(inner.left(), inner.bottom()), pos2(inner.right(), photo.bottom())), + ]; + strips.into_iter().filter(|r| r.width() > 0.0 && r.height() > 0.0).collect() +} + +/// `img` with its longest side at most `max` pixels (scaled premultiplied, so transparent edges +/// keep their colour); an empty graphic is an error. +pub fn fit_within(img: Rgba8, max: usize) -> Result<Rgba8, String> { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 { + return Err("the layout image is empty".into()); + } + let max = max.max(1); + if w.max(h) <= max { + return Ok(img); + } + let s = max as f32 / w.max(h) as f32; + let (nw, nh) = (((w as f32 * s).round() as usize).clamp(1, max), ((h as f32 * s).round() as usize).clamp(1, max)); + let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(w, h, |x, y| { + let p = img.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = lightcraft_raster::resample::resize(&pre, nw, nh, lightcraft_raster::resample::Filter::Mitchell); + Ok(Rgba8::from_fn(nw, nh, |x, y| { + let [r, g, b, a] = scaled.get(x, y); + let a = a.clamp(0.0, 1.0); + let c = |v: f32| if a <= 1e-4 { 0 } else { (v / a).round().clamp(0.0, 255.0) as u8 }; + [c(r), c(g), c(b), (a * 255.0).round() as u8] + })) +} + +/// Reads and decodes `path` on a worker thread (in place on the web, which has no threads and no +/// files to read: the error says so). +fn start(ctx: &egui::Context, path: &str) -> Loaded { + let loaded: Loaded = Default::default(); + let max = match ctx.input(|i| i.max_texture_side) { + 0 => MAX_SIDE, + m => m.min(MAX_SIDE), + }; + let (out, repaint, path) = (loaded.clone(), ctx.clone(), path.to_string()); + let work = move || { + let r = lightcraft_engine::export::read_graphic(&path).and_then(|img| fit_within(img, max)); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(r); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-layout-image".into()).spawn(work) { + *loaded.lock().unwrap_or_else(PoisonError::into_inner) = Some(Err(format!("can't start reading the layout image: {e}"))); + } + #[cfg(target_arch = "wasm32")] + work(); + loaded +} + +/// Draws the layout image (when shown) over `photo`, the photo's rectangle on screen; `p` clips it +/// to the canvas. +pub fn draw(app: &LightcraftApp, p: &egui::Painter, photo: Rect) { + let li = &app.ui.layout_image; + let path = li.path.trim(); + if !li.show || path.is_empty() || photo.width() < 1.0 || photo.height() < 1.0 { + return; + } + let ctx = p.ctx().clone(); + let id = egui::Id::new("loupe-layout-image"); + let file = (path.to_string(), li.revision); + let mut cache = match ctx.data(|d| d.get_temp::<Cache>(id)) { + Some(c) if c.file == file => c, + _ => Cache { loaded: start(&ctx, path), file, tex: None }, + }; + let display_id = app.renderer.display_id(); + if cache.tex.as_ref().is_none_or(|(d, _)| *d != display_id) { + let made = match cache.loaded.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { + // still reading: the worker repaints when it is done + None => None, + Some(Err(e)) => Some(Err(e.clone())), + Some(Ok(img)) => { + let mut img = img.clone(); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("layout image, display profile: {e}"); + } + let color = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()); + Some(Ok(ctx.load_texture("loupe-layout-image", color, egui::TextureOptions::LINEAR))) + } + }; + match made { + None => {} + Some(Ok(tex)) => cache.tex = Some((display_id, tex)), + Some(Err(e)) => note(p, &e), + } + } + if let Some((_, tex)) = &cache.tex { + let r = fit(photo, tex.size()); + let matte = li.matte.clamp(0.0, 1.0); + if matte > 0.0 { + let veil = Color32::from_black_alpha((matte * 255.0).round() as u8); + for strip in outside(photo, r) { + p.rect_filled(strip, 0.0, veil); + } + } + let alpha = (li.opacity.clamp(0.0, 1.0) * 255.0).round() as u8; + p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(alpha)); + register(&ctx, "canvas:layoutImage", r.intersect(p.clip_rect())); + } + ctx.data_mut(|d| d.insert_temp(id, cache)); +} + +/// Why the layout image isn't shown, at the canvas' bottom left. +fn note(p: &egui::Painter, why: &str) { + let t = Tokens::get(p.ctx()); + let canvas = p.clip_rect(); + let lines = [crate::i18n::tr("The layout image can't be shown"), why]; + let mut y = canvas.bottom() - 14.0; + for (i, l) in lines.iter().enumerate().rev() { + let font = if i == 0 { t.semibold(13.0) } else { t.font(12.0) }; + let g = p.layout_no_wrap(l.to_string(), font, Color32::WHITE); + y -= g.size().y; + let at = pos2(canvas.left() + 14.0, y); + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g, Color32::WHITE); + y -= 3.0; + } + register(p.ctx(), "canvas:layoutImageError", Rect::from_min_max(pos2(canvas.left(), y), pos2(canvas.left() + 360.0, canvas.bottom()))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_graphic_fits_inside_the_photo_centred() { + let photo = Rect::from_min_size(pos2(100.0, 50.0), vec2(600.0, 400.0)); + // a portrait cover over a landscape photo: as tall as the photo, centred across + let r = fit(photo, [800, 1000]); + assert!((r.height() - 400.0).abs() < 1e-3 && (r.width() - 320.0).abs() < 1e-3, "{r:?}"); + assert!((r.center().x - photo.center().x).abs() < 1e-3 && (r.center().y - photo.center().y).abs() < 1e-3); + // the same shape fills it + let same = fit(photo, [1200, 800]); + assert!((same.width() - 600.0).abs() < 1e-3 && (same.height() - 400.0).abs() < 1e-3); + // a zero-size graphic never divides by zero + assert!(fit(photo, [0, 0]).width().is_finite()); + } + + #[test] + fn the_matte_covers_the_photo_outside_the_graphic_only() { + let photo = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let r = fit(photo, [800, 1000]); + let strips = outside(photo, r); + assert_eq!(strips.len(), 2, "{strips:?}"); + let area: f32 = strips.iter().map(|s| s.area()).sum(); + assert!((area - (photo.area() - r.area())).abs() < 1e-2); + assert!(strips.iter().all(|s| s.intersect(r).area() < 1e-3)); + // a graphic of the photo's own shape leaves nothing to darken + assert!(outside(photo, fit(photo, [3, 2])).is_empty()); + } + + #[test] + fn large_graphics_are_scaled_down_keeping_shape_and_transparency() { + // a transparent graphic with an opaque red bar across the middle + let img = Rgba8::from_fn(5000, 2500, |_, y| if (1000..1500).contains(&y) { [255, 0, 0, 255] } else { [0, 0, 0, 0] }); + let out = fit_within(img, 1000).unwrap(); + assert_eq!((out.width, out.height), (1000, 500)); + assert_eq!(out.get(500, 250), [255, 0, 0, 255]); + assert_eq!(out.get(500, 20)[3], 0); + // small graphics are kept as they are, empty ones are refused + let small = Rgba8::from_fn(10, 4, |x, _| [x as u8, 0, 0, 255]); + assert_eq!(fit_within(small.clone(), 1000).unwrap().data, small.data); + assert!(fit_within(Rgba8::new(0, 0), 1000).is_err()); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..d3d44e911 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -15,6 +15,7 @@ pub mod grid; pub mod keymap; pub mod keyword_list; pub mod keywording; +pub mod layout_overlay; pub mod left; pub mod library_problem; pub mod masking; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..5acd9d9c9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -135,6 +135,39 @@ impl InfoOverlay { } } +/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with +/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the +/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across +/// launches. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayoutImage { + /// The graphic's file; empty until one is chosen. + pub path: String, + /// Drawn over the loupe (⌥⌘O). + pub show: bool, + /// The graphic's opacity, 0..=1. + pub opacity: f32, + /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). + pub matte: f32, + /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after + /// it was edited elsewhere). + #[serde(skip)] + pub revision: u32, +} + +impl Default for LayoutImage { + fn default() -> Self { + LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } + } +} + +impl LayoutImage { + /// The opacity and matte steps View ▸ Loupe Overlay offers. + pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; + pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; +} + /// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back /// on): everything but the photo is veiled in black, the panels stay where they are. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -725,6 +758,8 @@ pub struct UiState { pub slideshow: Option<(f64, f64, bool)>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, + /// The loupe's layout image overlay (View ▸ Loupe Overlay). + pub layout_image: LayoutImage, /// Navigator mini map in the loupe while zoomed in. pub navigator: bool, /// App preferences (Settings dialog). @@ -1145,6 +1180,7 @@ impl Default for UiState { keyword_list_selected: None, dragging_keyword: None, info_overlay: InfoOverlay::Off, + layout_image: LayoutImage::default(), navigator: true, settings: AppSettings::default(), window_fullscreen: None, diff --git a/crates/ui-egui/src/tests_layout_image.rs b/crates/ui-egui/src/tests_layout_image.rs new file mode 100644 index 000000000..f07b391ca --- /dev/null +++ b/crates/ui-egui/src/tests_layout_image.rs @@ -0,0 +1,181 @@ +//! Headless tests of View ▸ Loupe Overlay ▸ Layout Image: a PNG with transparency drawn over the +//! photo in the loupe, fitted and centred, its opaque parts covering the photo and its transparent +//! parts leaving it alone; opacity, matte, ⌥⌘O, a file that can't be read, and the choice saved +//! with the UI state. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The graphic's opaque band: magenta, unlike anything in the demo photos. +const BAND: [u8; 4] = [255, 0, 255, 255]; + +fn loupe() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// A portrait "cover": an opaque magenta masthead across its top quarter, transparent below. +fn cover(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-layout-image-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(300, 400, |_, y| if y < 100 { BAND } else { [0, 0, 0, 0] }); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let path = dir.join("cover.png"); + std::fs::write(&path, png).unwrap(); + path +} + +fn widget(h: &Headless, name: &str) -> Option<egui::Rect> { + h.app.widgets.iter().find(|(w, _)| w == name).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + h.step(); + h.step(); + r +} + +fn px(img: &egui::ColorImage, p: egui::Pos2) -> [u8; 3] { + let c = img.pixels[(p.y as usize) * img.size[0] + p.x as usize]; + [c.r(), c.g(), c.b()] +} + +fn close(a: [u8; 3], b: [u8; 3], tol: i32) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol) +} + +#[test] +fn the_layout_image_covers_the_photo_where_opaque_and_follows_opacity_matte_and_the_toggle() { + let mut h = loupe(); + let path = cover("draw"); + let photo = widget(&h, "canvas:image").expect("the loupe shows a photo"); + let before = h.snapshot(SETTLE); + + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": path.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.layout_image.show, "choosing a layout image shows it"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some()), "the layout image is drawn once read"); + let shown = h.snapshot(SETTLE); + let r = widget(&h, "canvas:layoutImage").unwrap(); + // fitted inside the photo, centred, with the graphic's 3:4 shape + assert!(photo.expand(1.0).contains_rect(r), "{r:?} inside {photo:?}"); + assert!((r.center().x - photo.center().x).abs() < 1.5 && (r.center().y - photo.center().y).abs() < 1.5, "{r:?} centred on {photo:?}"); + assert!((r.width() / r.height() - 0.75).abs() < 0.02, "{r:?}"); + // the masthead covers the photo; below it the photo shows through untouched + let masthead = egui::pos2(r.center().x, r.top() + r.height() * 0.12); + let clear = egui::pos2(r.center().x, r.top() + r.height() * 0.7); + assert!(close(px(&shown, masthead), [255, 0, 255], 12), "masthead {:?}", px(&shown, masthead)); + assert!(close(px(&shown, clear), px(&before, clear), 3), "{:?} vs {:?}", px(&shown, clear), px(&before, clear)); + + // half opacity: the masthead blends with the photo + let r2 = run(&mut h, "view.layoutImageOpacity", json!({"opacity": 0.5})); + assert_eq!(r2["ok"], true, "{r2}"); + let half = h.snapshot(SETTLE); + let m = px(&half, masthead); + assert!(!close(m, [255, 0, 255], 12) && !close(m, px(&before, masthead), 12), "half-opaque masthead {m:?}"); + + // the matte darkens the photo outside the graphic's box, and only there + // (a 3:4 graphic leaves the sides of a wider photo, the top and bottom of a taller one) + let side = if r.left() - photo.left() > 8.0 { + egui::pos2((photo.left() + r.left()) / 2.0, photo.center().y) + } else { + egui::pos2(photo.center().x, (photo.top() + r.top()) / 2.0) + }; + assert!(!r.expand(2.0).contains(side) && photo.contains(side), "{side:?}: in the photo, outside {r:?}"); + run(&mut h, "view.layoutImageMatte", json!({"matte": 0.75})); + let matte = h.snapshot(SETTLE); + let (lit, dark) = (px(&before, side), px(&matte, side)); + assert!(dark.iter().zip(lit).all(|(d, l)| *d as i32 <= l as i32 / 2 + 2), "matte {dark:?} vs {lit:?}"); + assert!(close(px(&matte, clear), px(&before, clear), 3), "inside the box the matte leaves the photo alone"); + assert!(run(&mut h, "view.layoutImageMatte", json!({}))["ok"] == false, "a matte is required"); + + // ⌥⌘O hides it and shows it again + let k = h.request("ui.key", json!({"key": "o", "cmd": true, "alt": true}), T); + assert_eq!(k["ok"], true, "{k}"); + h.step(); + h.step(); + assert!(!h.app.ui.layout_image.show); + assert!(widget(&h, "canvas:layoutImage").is_none(), "hidden: not drawn"); + let back = h.snapshot(SETTLE); + assert!(close(px(&back, masthead), px(&before, masthead), 3), "hidden: the photo is untouched"); + run(&mut h, "view.layoutImage", json!({"show": true})); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some())); + + // saved with the UI state (the revision is not) + let saved = serde_json::to_value(&h.app.ui).unwrap(); + assert_eq!(saved["layoutImage"]["path"], json!(path.to_string_lossy())); + assert_eq!(saved["layoutImage"]["show"], true); + assert_eq!(saved["layoutImage"]["opacity"], json!(0.5)); + assert!(saved["layoutImage"].get("revision").is_none()); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.layout_image.path, path.to_string_lossy()); + // an older ui.json without it opens with nothing chosen + let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(old.layout_image, crate::state::LayoutImage::default()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_layout_image_that_cant_be_read_says_so_and_showing_none_asks_for_one() { + let mut h = loupe(); + // nothing chosen: showing it asks for a file, which headless has no dialog for + let r = run(&mut h, "view.layoutImage", json!({})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!h.app.ui.layout_image.show); + assert!(run(&mut h, "view.chooseLayoutImage", json!({"path": " "}))["ok"] == false, "an empty path is refused"); + + let dir = cover("missing"); + let missing = dir.parent().unwrap().join("no-such-cover.png"); + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": missing.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImageError").is_some()), "the canvas says the file can't be read"); + assert!(widget(&h, "canvas:layoutImage").is_none()); + // choosing a readable one replaces the error + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": dir.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some())); + h.step(); + assert!(widget(&h, "canvas:layoutImageError").is_none()); + let _ = std::fs::remove_dir_all(dir.parent().unwrap()); +} + +#[test] +fn view_loupe_overlay_lists_the_layout_image_commands() { + let mut h = loupe(); + let names = |h: &Headless| -> Vec<(String, bool, Option<bool>)> { + let menu = crate::menubar::expanded(&h.app, "Loupe Overlay").unwrap_or_default(); + menu.into_iter() + .filter_map(|n| match n { + crate::menubar::MenuNode::Item { id, enabled, checked, .. } => Some((id, enabled, checked)), + _ => None, + }) + .collect() + }; + let before = names(&h); + assert_eq!(before[0], ("view.chooseLayoutImage".to_string(), true, None)); + // nothing chosen: only Choose… is enabled + assert!(before[1..].iter().all(|(_, enabled, _)| !enabled), "{before:?}"); + let path = cover("menu"); + run(&mut h, "view.chooseLayoutImage", json!({"path": path.to_string_lossy()})); + let after = names(&h); + assert_eq!(after[1], ("view.layoutImage".to_string(), true, Some(true))); + // 100% opacity and no matte are checked by default + let checked: Vec<&String> = after.iter().filter(|(_, _, c)| *c == Some(true)).map(|(id, ..)| id).collect(); + assert_eq!(checked, ["view.layoutImage", "view.layoutImageOpacity", "view.layoutImageMatte"]); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..3e94700e5 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 409 | 53 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | +| Lightroom Classic extras | 34 | 17 | 35 | 8 | — | 24/26 (92%) | +| **Total** | 409 | 54 | 72 | 35 | 193/201 (96%) | 146/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 535 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 50.0% of 174. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -724,6 +724,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | +| LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | | LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..58c3c9f4f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Layout image over the loupe +- View ▸ Loupe Overlay ▸ Choose Layout Image… draws a PNG with transparency over the photo in the loupe, such as + a magazine cover's masthead and cover lines, so you can frame a shot for the page it is made for. It fits inside + the photo, follows zoom and pan, and ⌥⌘O shows or hides it. The same menu sets its opacity and a matte that + darkens the photo outside it. The layout stays chosen across launches. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 6f9aae0445c385a792a5b08de18eb89e3a51d8f0 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 20:02:11 +0200 Subject: [PATCH 187/315] LR-PROF-DROPDOWN: a long profile name no longer widens the Edit panel (fixes #1759) --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/edit.rs | 5 +- crates/ui-egui/src/tests_edit_layout.rs | 68 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 11 +++- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/tests_edit_layout.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..f711fbf2b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,8 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_edit_layout; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..81d5a00aa 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -159,7 +159,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); - let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); + // a long name is cut short before Browse Profiles: a wider row would widen the whole panel's + // content, and the sliders below would run past its edge + let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; + let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( diff --git a/crates/ui-egui/src/tests_edit_layout.rs b/crates/ui-egui/src/tests_edit_layout.rs new file mode 100644 index 000000000..7c6d79ff0 --- /dev/null +++ b/crates/ui-egui/src/tests_edit_layout.rs @@ -0,0 +1,68 @@ +//! The Edit panel at its narrowest with a long profile applied: the profile row fits (#1759). + +use egui::{Rect, vec2}; +use serde_json::json; + +use crate::{LightcraftApp, Services, headless::HeadlessView}; + +const LONG_ID: &str = "lut:kodak-portra-400-plus-one-stop-overexposed"; +const LONG_NAME: &str = "Kodak Portra 400 Plus One Stop Overexposed"; + +fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::Shape::Text(text) => out.push(text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)), + _ => {} + } +} + +fn panel(width: f32) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: LONG_ID.into(), + name: LONG_NAME.into(), + group: "Film Stocks".into(), + file: String::new(), + }); + app.run("develop.profile", json!({"id": LONG_ID})).unwrap(); + let photo = app.session.active().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 4000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + crate::panels::edit::show(&mut app, ui, photo); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_shapes(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + (texts, widgets) +} + +#[test] +fn a_long_profile_name_leaves_the_edit_panel_its_width() { + let width = crate::state::RIGHT_WIDTH.min; + let (texts, widgets) = panel(width); + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not drawn")).1; + let dropdown = rect("dropdown:profile"); + let browse = rect("icon:profileBrowser"); + assert!(dropdown.right() <= browse.left(), "the profile menu {dropdown:?} runs into Browse Profiles {browse:?}"); + let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap(); + assert!(name.galley.elided, "a name wider than the row is cut short"); + assert!(name.visual_bounding_rect().right() <= dropdown.right(), "the name leaves its menu"); + // every slider drawn, from the profile's Amount down + rect("sliderValue:profile.amount"); + rect("sliderValue:light.exposure"); + for (id, r) in widgets.iter().filter(|(id, _)| id.starts_with("slider")) { + assert!(r.right() <= width, "{id} {r:?} is cut off by the {width} pt panel"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..34820cfcf 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -621,8 +621,15 @@ fn star_lit(i: u8, rating: u8, hover: Option<u8>) -> bool { /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { + dropdown_within(ui, id, text, font, color, f32::INFINITY) +} + +/// [`dropdown`] at most `max` wide, chevron included: a longer `text` is cut short with …, and +/// shown in full on hover. +pub fn dropdown_within(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32, max: f32) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), font, color); + let galley = one_line(ui.painter(), text, font, color, max - 20.0); + let elided = galley.elided; let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0)); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text)); @@ -630,7 +637,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c); - resp + if elided { resp.on_hover_text(text) } else { resp } } #[cfg(test)] From 18e808c08fc6d99647845c580ad8f30388dc0bb8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Wed, 14 Oct 2026 13:37:48 -0400 Subject: [PATCH 188/315] LR-BEHAV-EMPTY-STATES: an empty library leads to Import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library with no photos opened on the Detail view, whose empty state said "Choose a photo in the grid or filmstrip" while the grid was empty and hidden: the only screen that mentioned import was off screen, and Import itself was nowhere but the File menu and its shortcut. - An empty library (no photos, or only deleted or browsed ones) opens on the Photo Grid with the sidebar (Square Grid stays as it is), in the desktop app and in `lightcraft-cli snapshot`. The desktop app switches before it notes what its settings file holds, so the switch alone isn't saved. - The grid and the Detail view show "Your library is empty" with Import Photos… (primary) and Import Lightroom Catalog… (not on the web) as buttons, and a line saying photos and folders can be dropped into the window, with the key Import Photos… is bound to now (`⌘⇧I` on macOS, `Ctrl+Shift+I` elsewhere, or a remapped one). Not while a folder is browsed under Local or Recently Deleted is shown: their photos aren't in the library but are on screen. - The right-hand panel says "No photos yet / Import photos to get started" there instead of "Select a photo to edit". - The top bar has an Import button beside Export in every library. The centred search field's reserve grows by both sides' share (620, and a 190 minimum), so the filter icon stays clear of the icons on the right at every window width, also with macOS's title bar in the window. The top bar's icons are named for screen readers, and a refused click says why in a toast. - `empty_state` is `empty_message` with buttons that do the next step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/lightcraft-cli/src/main.rs | 2 + apps/lightcraft/src/main.rs | 5 + crates/ui-egui/locales/de.json | 5 + crates/ui-egui/locales/es.json | 5 + crates/ui-egui/locales/fr.json | 5 + crates/ui-egui/locales/ja.json | 5 + crates/ui-egui/locales/pt-br.json | 5 + crates/ui-egui/locales/ru.json | 5 + crates/ui-egui/locales/uk.json | 5 + crates/ui-egui/locales/zh-hans.json | 5 + crates/ui-egui/locales/zh-hant.json | 5 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/detail.rs | 6 +- crates/ui-egui/src/panels/empty_library.rs | 76 +++++++++ crates/ui-egui/src/panels/grid.rs | 2 + crates/ui-egui/src/panels/mod.rs | 70 ++++++++ crates/ui-egui/src/panels/right.rs | 7 +- crates/ui-egui/src/panels/topbar.rs | 28 ++- crates/ui-egui/src/tests_empty_library.rs | 188 +++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 + 21 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/panels/empty_library.rs create mode 100644 crates/ui-egui/src/tests_empty_library.rs diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..a4057fbd9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -791,6 +791,8 @@ fn snapshot(args: &[String]) -> Result<(), String> { // before the first frame can start background adapter discovery. // This UI state is session-local: the CLI neither loads nor saves desktop ui.json. app.ui.settings.gpu = false; + // as the desktop app opens it: an empty library on the grid, where Import is offered + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); let mut h = Headless::new(app, size, scale); let timeout = Duration::from_secs(60); let mut shots = 0usize; diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 1c70e19ea..4d81891ef 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -871,6 +871,11 @@ fn main() -> eframe::Result { app.notices.extend(launch_notices); app.notices.extend(window_notice); app.gpu_notice = true; + // an empty library opens where Import is offered (a grid, with the sidebar); before + // the snapshot below, so this alone isn't written to the settings file + if problem.is_none() && files.is_empty() { + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); + } // what's on disk now: only changes are written let writer = PrefsWriter { path: if in_memory { None } else { prefs_path() }, diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..ccca86bc2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -956,6 +956,11 @@ "No new presets": "Keine neuen Vorgaben", "No photo selected": "Kein Foto ausgewählt", "No photos": "Keine Fotos", + "Your library is empty": "Deine Bibliothek ist leer", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.", + "or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster", + "Import photos to get started": "Importiere Fotos, um loszulegen", + "or drop photos into this window": "oder ziehe Fotos in dieses Fenster", "No photos found": "Keine Fotos gefunden", "No photos in this folder": "Keine Fotos in diesem Ordner", "No photos match the active filters": "Keine Fotos entsprechen den aktiven Filtern", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..6b1ce1ff0 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -931,6 +931,11 @@ "No new presets": "No hay ajustes preestablecidos nuevos", "No photo selected": "No hay ninguna foto seleccionada", "No photos": "Sin fotos", + "Your library is empty": "Tu biblioteca está vacía", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Las fotos que importes aparecen aquí. No se copia ni se mueve nada a menos que lo elijas.", + "or drop photos and folders into this window": "o arrastra fotos y carpetas a esta ventana", + "Import photos to get started": "Importa fotos para empezar", + "or drop photos into this window": "o arrastra fotos a esta ventana", "No photos found": "No se encontraron fotos", "No photos in this folder": "No hay fotos en esta carpeta", "No photos match the active filters": "Ninguna foto coincide con los filtros activos", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..d6b4458fb 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -908,6 +908,11 @@ "No new presets": "Aucun nouveau préréglage", "No photo selected": "Aucune photo sélectionnée", "No photos": "Aucune photo", + "Your library is empty": "Votre bibliothèque est vide", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Les photos importées apparaissent ici. Rien n’est copié ni déplacé, sauf si vous le choisissez.", + "or drop photos and folders into this window": "ou déposez des photos et des dossiers dans cette fenêtre", + "Import photos to get started": "Importez des photos pour commencer", + "or drop photos into this window": "ou déposez des photos dans cette fenêtre", "No photos found": "Aucune photo trouvée", "No photos in this folder": "Aucune photo dans ce dossier", "No photos match the active filters": "Aucune photo ne correspond aux filtres actifs", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..2485c5e09 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -956,6 +956,11 @@ "No new presets": "新しいプリセットはありません", "No photo selected": "写真が選択されていません", "No photos": "写真がありません", + "Your library is empty": "ライブラリは空です", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "読み込んだ写真はここに表示されます。選択しない限り、コピーや移動は行われません。", + "or drop photos and folders into this window": "または写真やフォルダーをこのウィンドウにドロップ", + "Import photos to get started": "写真を読み込んで始めましょう", + "or drop photos into this window": "または写真をこのウィンドウにドロップ", "No photos found": "写真が見つかりません", "No photos in this folder": "このフォルダに写真はありません", "No photos match the active filters": "現在のフィルターに一致する写真がありません", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..c62dc59f9 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -920,6 +920,11 @@ "No new presets": "Nenhuma predefinição nova", "No photo selected": "Nenhuma foto selecionada", "No photos": "Sem fotos", + "Your library is empty": "Sua biblioteca está vazia", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "As fotos que você importar aparecem aqui. Nada é copiado ou movido, a menos que você escolha.", + "or drop photos and folders into this window": "ou arraste fotos e pastas para esta janela", + "Import photos to get started": "Importe fotos para começar", + "or drop photos into this window": "ou arraste fotos para esta janela", "No photos found": "Nenhuma foto encontrada", "No photos in this folder": "Nenhuma foto nesta pasta", "No photos match the active filters": "Nenhuma foto corresponde aos filtros ativos", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..3710e66a0 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -760,6 +760,11 @@ "No new presets": "Новых пресетов нет", "No photo selected": "Фото не выбрано", "No photos": "Нет фото", + "Your library is empty": "Библиотека пуста", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.", + "or drop photos and folders into this window": "или перетащите фото и папки в это окно", + "Import photos to get started": "Импортируйте фото, чтобы начать", + "or drop photos into this window": "или перетащите фото в это окно", "No photos found": "Фото не найдены", "No photos in this folder": "В этой папке нет фото", "No photos match the active filters": "Нет фото, соответствующих активным фильтрам", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..2904a94f5 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1093,6 +1093,11 @@ "No new presets": "Немає нових наборів налаштувань", "No photo selected": "Фото не вибрано", "No photos": "Немає фото", + "Your library is empty": "Бібліотека порожня", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Імпортовані фото з’являться тут. Нічого не копіюється й не переміщується, доки ви самі цього не оберете.", + "or drop photos and folders into this window": "або перетягніть фото й теки в це вікно", + "Import photos to get started": "Імпортуйте фото, щоб почати", + "or drop photos into this window": "або перетягніть фото в це вікно", "No photos found": "Фото не знайдено", "No photos in this folder": "У цій теці немає фото", "No photos match the active filters": "Жодне фото не відповідає активним фільтрам", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..a1684a4fe 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -978,6 +978,11 @@ "No new presets": "没有新预设", "No photo selected": "未选择照片", "No photos": "没有照片", + "Your library is empty": "图库是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "导入的照片会显示在这里。除非你选择,否则不会复制或移动任何文件。", + "or drop photos and folders into this window": "或将照片和文件夹拖放到此窗口", + "Import photos to get started": "导入照片以开始使用", + "or drop photos into this window": "或将照片拖放到此窗口", "No photos found": "未找到照片", "No photos in this folder": "此文件夹中没有照片", "No photos match the active filters": "没有照片符合当前筛选条件", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..7baabe717 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -978,6 +978,11 @@ "No new presets": "沒有新預設集", "No photo selected": "尚未選取照片", "No photos": "沒有照片", + "Your library is empty": "照片庫是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "匯入的照片會顯示在這裡。除非你選擇,否則不會複製或移動任何檔案。", + "or drop photos and folders into this window": "或將照片和資料夾拖放到此視窗", + "Import photos to get started": "匯入照片以開始使用", + "or drop photos into this window": "或將照片拖放到此視窗", "No photos found": "找不到照片", "No photos in this folder": "此資料夾沒有照片", "No photos match the active filters": "沒有照片符合目前的篩選條件", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..fa32e02f5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -65,6 +65,8 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_empty_library; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..c487059a8 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -370,7 +370,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/empty_library.rs b/crates/ui-egui/src/panels/empty_library.rs new file mode 100644 index 000000000..d420ee1a0 --- /dev/null +++ b/crates/ui-egui/src/panels/empty_library.rs @@ -0,0 +1,76 @@ +//! A library with no photos yet: what the grid and the Detail view show instead of a dead end, +//! with Import Photos… and Import Lightroom Catalog… as buttons, and the view it opens on. + +use egui::Rect; +use serde_json::json; + +use super::EmptyAction; +use crate::LightcraftApp; +use crate::shortcuts::Keymap; +use crate::state::ViewMode; + +/// The library holds no photos: none, or only ones in Recently Deleted or browsed under Local. +pub fn is_empty(session: &lightcraft_engine::Session) -> bool { + !session.catalog.photos().any(|p| p.in_library()) +} + +/// What is shown is the empty library: not a folder browsed under Local nor Recently Deleted, +/// whose photos aren't in the library but are on screen. +pub fn shown(session: &lightcraft_engine::Session) -> bool { + use lightcraft_engine::LibrarySource; + !matches!(session.source, LibrarySource::Folder | LibrarySource::RecentlyDeleted) && is_empty(session) +} + +/// At launch, an empty library opens on a grid with the sidebar, where the way in is (Detail had +/// only "Choose a photo in the grid" to say). Square Grid stays as it is; a library with photos +/// opens as left. The desktop host calls this before it notes what its settings file holds, so +/// the switch isn't saved for its own sake. +pub fn open_on_empty_library(app: &mut LightcraftApp) { + if is_empty(&app.session) { + if app.ui.view != ViewMode::SquareGrid { + app.ui.view = ViewMode::PhotoGrid; + } + app.ui.left_panel = true; + } +} + +/// The line under the buttons: dropping works anywhere in the window, and the shortcut is the +/// one Import Photos… is bound to now, written as menus write it. +pub fn hint(keymap: &Keymap, mac: bool) -> String { + // the browser build reads dropped files, not folders + let drop = if cfg!(target_arch = "wasm32") { + crate::i18n::tr("or drop photos into this window") + } else { + crate::i18n::tr("or drop photos and folders into this window") + }; + match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") { + Some(sc) => format!("{drop} · {}", crate::menubar::shortcut_text(sc, mac)), + None => drop.to_string(), + } +} + +const IMPORT_PHOTOS: EmptyAction = EmptyAction { command: "file.addPhotos", label: "Import Photos…", widget: "button:emptyImportPhotos" }; +const IMPORT_LIGHTROOM: EmptyAction = + EmptyAction { command: "file.importLightroom", label: "Import Lightroom Catalog…", widget: "button:emptyImportLightroom" }; + +/// The empty library's message and buttons, centred in `rect`. A click runs the command as its +/// menu item does; a refusal shows as a toast. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect) { + // the browser build can't read a Lightroom catalog from disk: only offer what works there + let actions: &[EmptyAction] = if cfg!(target_arch = "wasm32") { &[IMPORT_PHOTOS] } else { &[IMPORT_PHOTOS, IMPORT_LIGHTROOM] }; + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let hint = hint(&app.ui.settings.keymap, mac); + let clicked = super::empty_state( + ui, + rect, + "Your library is empty", + "Photos you import show up here. Nothing is copied or moved unless you choose to.", + actions, + Some(&hint), + ); + if let Some(command) = clicked + && let Err(e) = app.run(command, json!({})) + { + app.toast_error(ui.ctx(), e); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..24ae0020f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -192,6 +192,8 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); + } else if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, ui.max_rect()); } else { super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here"); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..3331fa3d3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod denoise; pub mod detail; pub mod dialogs; pub mod edit; +pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; @@ -99,3 +100,72 @@ pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) { p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label); p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim); } + +/// A button under an empty state's message: the command it runs, its label, and the id it is +/// registered under (`button:…`) for scripts and tests. +pub struct EmptyAction { + pub command: &'static str, + pub label: &'static str, + pub widget: &'static str, +} + +/// [`empty_message`] with the next step under it: a row of buttons (the first is the primary +/// one, filled with the accent) and an optional dimmed hint line below them. The block is +/// centred in `rect` as one. Returns the command of the button clicked this frame. +pub fn empty_state(ui: &mut egui::Ui, rect: Rect, title: &str, body: &str, actions: &[EmptyAction], hint: Option<&str>) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let wrap = (rect.width() - 48.0).max(80.0); + let centred = |ui: &egui::Ui, text: &str, font: egui::FontId, color: egui::Color32| { + let mut job = egui::text::LayoutJob::simple(text.to_string(), font, color, wrap); + job.halign = egui::Align::Center; + ui.painter().layout_job(job) + }; + let title = centred(ui, crate::i18n::tr(title), t.semibold(18.0), t.text_label); + let body = centred(ui, crate::i18n::tr(body), t.font(13.0), t.text_dim); + let hint = hint.map(|h| centred(ui, h, t.font(12.0), t.text_dim)); + // the buttons' widths, and whether they fit side by side (else one above the other) + const BUTTON_H: f32 = 30.0; + const GAP: f32 = 8.0; + let widths: Vec<f32> = + actions.iter().map(|a| ui.painter().layout_no_wrap(crate::i18n::tr(a.label).to_string(), t.semibold(13.0), t.text).size().x + 32.0).collect(); + let row_w = widths.iter().sum::<f32>() + GAP * widths.len().saturating_sub(1) as f32; + let side_by_side = row_w <= wrap; + let buttons_h = match (actions.len(), side_by_side) { + (0, _) => 0.0, + (_, true) => BUTTON_H, + (n, false) => n as f32 * BUTTON_H + (n - 1) as f32 * GAP, + }; + let gap_above_buttons = if actions.is_empty() { 0.0 } else { 20.0 }; + let hint_h = hint.as_ref().map_or(0.0, |g| 12.0 + g.size().y); + let block = title.size().y + 6.0 + body.size().y + gap_above_buttons + buttons_h + hint_h; + let top = rect.center().y - block / 2.0; + let p = ui.painter().clone(); + let body_top = top + title.size().y + 6.0; + let buttons_top = body_top + body.size().y + gap_above_buttons; + p.galley(pos2(rect.center().x, top), title, t.text_label); + p.galley(pos2(rect.center().x, body_top), body, t.text_dim); + let mut clicked = None; + let mut x = rect.center().x - row_w / 2.0; + for (i, (a, w)) in actions.iter().zip(&widths).enumerate() { + let r = if side_by_side { + let r = Rect::from_min_size(pos2(x, buttons_top), vec2(*w, BUTTON_H)); + x += w + GAP; + r + } else { + let w = widths.iter().copied().fold(0.0, f32::max).min(wrap); + Rect::from_min_size(pos2(rect.center().x - w / 2.0, buttons_top + i as f32 * (BUTTON_H + GAP)), vec2(w, BUTTON_H)) + }; + let label = egui::RichText::new(crate::i18n::tr(a.label)).font(t.semibold(13.0)); + let button = + if i == 0 { egui::Button::new(label.color(egui::Color32::WHITE)).fill(t.accent) } else { egui::Button::new(label.color(t.text)) }; + let resp = ui.put(r, button); + crate::widgets::register(ui.ctx(), a.widget, resp.rect); + if resp.clicked() { + clicked = Some(a.command); + } + } + if let Some(g) = hint { + p.galley(pos2(rect.center().x, buttons_top + buttons_h + 12.0), g, t.text_dim); + } + clicked +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..139676670 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -29,7 +29,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; } let r = ui.max_rect(); - super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + // every tab (Edit, Info, Versions…) says it: nothing to show until photos come in + if super::empty_library::shown(&app.session) { + super::empty_message(ui, r, "No photos yet", "Import photos to get started"); + } else { + super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + } return; }; egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 03810aca9..f9e55a159 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,12 +53,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - // room for the filter icon and the six icons on the right - let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); + // room for the filter icon and the seven icons on the right + // (the field is centred, so each icon on the right takes its width from both sides) + let mut sw = 640.0f32.min(full.width() - 620.0).max(190.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; - sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + sw = sw.min(2.0 * (full.center().x - menus_right)).max(190.0); } let search_left = full.center().x - sw / 2.0; ui.horizontal_centered(|ui| { @@ -119,6 +120,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // filter icon right of the search field let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Filter bar"))); register(ui.ctx(), "icon:filter", fr); let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( @@ -161,15 +163,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ) }); let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; for (id, icon, tip, cmd) in [ ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), ("cloud", Icon::Cloud, cloud_tip, ""), ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), ("bell", Icon::Bell, "Activity", "panel.activity"), ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + // named for screen readers, as the icon buttons on the left are + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { @@ -186,8 +197,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); register(ui.ctx(), "indicator:unsaved", r); } - if resp.clicked() && !cmd.is_empty() { - let _ = app.run(cmd, json!({})); + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); } x -= 40.0; } @@ -195,7 +210,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip.clone())); register(ui.ctx(), "icon:appearance", r); paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); if resp.clicked() { diff --git a/crates/ui-egui/src/tests_empty_library.rs b/crates/ui-egui/src/tests_empty_library.rs new file mode 100644 index 000000000..2207875b9 --- /dev/null +++ b/crates/ui-egui/src/tests_empty_library.rs @@ -0,0 +1,188 @@ +//! LR-BEHAV-EMPTY-STATES: a library with no photos leads to Import. It opens on the Photo Grid +//! with the sidebar, the grid and the Detail view offer Import Photos… and Import Lightroom +//! Catalog… as buttons, and the top bar has an Import button for every library. + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::empty_library; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn app(session: lightcraft_engine::Session) -> LightcraftApp { + LightcraftApp::new(session, Services::default()) +} + +/// A picker that records that it was asked and gives no files (the person cancelled). +fn counting_picker(h: &mut Headless) -> Rc<Cell<usize>> { + let asked = Rc::new(Cell::new(0)); + let seen = asked.clone(); + h.app.services.pick_files = Some(Box::new(move || { + seen.set(seen.get() + 1); + Vec::new() + })); + asked +} + +#[test] +fn an_empty_library_opens_on_the_grid_with_the_sidebar() { + // Given a library with no photos, left in the Detail view with the sidebar hidden + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + // When it opens at launch + empty_library::open_on_empty_library(&mut a); + // Then it shows the Photo Grid and the sidebar, where Import is offered + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn an_empty_library_left_on_the_square_grid_stays_there() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::SquareGrid; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn a_library_with_photos_opens_as_it_was_left() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::Detail); + assert!(!a.ui.left_panel); +} + +#[test] +fn deleted_photos_leave_the_library_empty() { + let mut s = lightcraft_engine::Session::with_demo(); + assert!(!empty_library::is_empty(&s)); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); + assert!(empty_library::is_empty(&s), "photos in Recently Deleted are not in the library"); +} + +#[test] +fn the_empty_grid_offers_import_as_buttons() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::PhotoGrid; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); + #[cfg(not(target_arch = "wasm32"))] + assert!(has(&h, "button:emptyImportLightroom")); + // a click asks for the photos, as File ▸ Import Photos… does + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:emptyImportPhotos"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_empty_detail_view_offers_import_instead_of_the_grid() { + // the dead end the critique found: Detail pointed at a grid with nothing in it + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn detail_with_photos_but_none_selected_keeps_its_message() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.app.session.selection.ids.clear(); + h.app.session.selection.active = None; + h.step(); + h.step(); + assert!(h.app.session.active().is_none()); + assert!(!has(&h, "button:emptyImportPhotos"), "a library with photos needs no import prompt"); +} + +#[test] +fn the_top_bar_imports_in_every_library() { + let mut h = Headless::new(app(lightcraft_engine::Session::with_demo()), [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "icon:import")); + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:import"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_hint_shows_the_shortcut_as_bound() { + let mut keymap = crate::state::AppSettings::default().keymap; + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⇧I"); + assert_eq!(empty_library::hint(&keymap, false), "or drop photos and folders into this window · Ctrl+Shift+I"); + // a remapped shortcut is the one shown + crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+I")).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⌥I"); + // and none at all leaves the key out + crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window"); +} + +#[test] +fn a_folder_browsed_under_local_is_not_the_empty_library() { + // its photos aren't in the library, but they are what is on screen: no import prompt + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.session.source = lightcraft_engine::LibrarySource::Folder; + assert!(empty_library::is_empty(&a.session)); + assert!(!empty_library::shown(&a.session)); + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(!has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn recently_deleted_is_not_the_empty_library() { + // the deleted photos are on screen there: no import prompt beside them + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); + s.source = lightcraft_engine::LibrarySource::RecentlyDeleted; + assert!(empty_library::is_empty(&s)); + assert!(!empty_library::shown(&s)); +} + +#[test] +fn the_import_button_leaves_the_filter_and_appearance_icons_apart() { + // the search field is centred: a seventh icon on the right must not push it into them + // with the menus in the window (Windows, Linux, web) and in the native menu bar (macOS) + for (width, native_menu) in [900.0, 906.0, 1024.0, 1200.0, 1600.0].into_iter().flat_map(|w| [(w, false), (w, true)]) { + let mut a = app(lightcraft_engine::Session::with_demo()); + // a native menu bar comes with the title bar drawn in the window (macOS): a wider left margin + a.native_menu = native_menu; + a.integrated_titlebar = native_menu; + let mut h = Headless::new(a, [width, 800.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} at {width}")); + let (filter, appearance, import) = (rect("icon:filter"), rect("icon:appearance"), rect("icon:import")); + assert!(!filter.intersects(appearance), "{width} (native menu {native_menu}): filter {filter:?} meets appearance {appearance:?}"); + assert!(!filter.intersects(import), "{width} (native menu {native_menu}): filter {filter:?} meets import {import:?}"); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..bbc33de41 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -470,7 +470,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | -| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0b76073fb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-14 +### An empty library leads to Import +- A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a + Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app, + Import Lightroom Catalog… as buttons, and say that photos (and, in the desktop app, folders) can be dropped into + the window. The shortcut they show is the one Import Photos… has now. +- The top bar has an Import button, beside Export, in every library. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From bb9d3d4022eb108ed92e4108d08de705300974fb Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 13:13:19 -0500 Subject: [PATCH 189/315] Catalog and downloads: Don't write through a link at a temp file's name (#1502) --- crates/catalog/src/store.rs | 40 +++++++++++++++++++++++++++++++++++-- crates/fetch/src/lib.rs | 22 ++++++++++++++++---- crates/fetch/src/tests.rs | 22 ++++++++++++++++++++ docs/whats-new.md | 5 +++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index a672d00e7..2f3dc4912 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -73,6 +73,16 @@ impl FsStore { } } +/// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first +/// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link +/// planted at that name into another file (issue #1502). +fn create_staging(tmp: &std::path::Path) -> io::Result<std::fs::File> { + if std::fs::symlink_metadata(tmp).is_ok() { + std::fs::remove_file(tmp)?; + } + std::fs::OpenOptions::new().write(true).create_new(true).open(tmp) +} + impl Store for FsStore { fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> { match std::fs::read(self.path(name)) { @@ -89,7 +99,7 @@ impl Store for FsStore { } let tmp = self.path(&format!("{name}.tmp")); { - let mut f = std::fs::File::create(&tmp)?; + let mut f = create_staging(&tmp)?; f.write_all(data)?; f.sync_all()?; } @@ -105,7 +115,7 @@ impl Store for FsStore { } let tmp = self.path(&format!("{name}.tmp")); let written = (|| { - let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 }; + let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, create_staging(&tmp)?), n: 0 }; fill(&mut w)?; w.flush()?; let f = w.inner.into_inner().map_err(|e| e.into_error())?; @@ -231,3 +241,29 @@ impl Store for MemStore { self.background.then(|| Box::new(self.clone()) as Box<dyn Store>) } } + +#[cfg(test)] +mod tests_staging { + use super::*; + + /// A link planted at a staging name (`<name>.tmp`) is removed, never followed: the file it points to keeps its + /// bytes and the save still lands (issue #1502). + #[cfg(unix)] + #[test] + fn a_link_at_the_staging_name_is_never_written_through() { + let dir = std::env::temp_dir().join(format!("lc-staging-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut store = FsStore::open(&dir).unwrap(); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join("catalog.snap.tmp")).unwrap(); + store.write_atomic("catalog.snap", b"{}").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert_eq!(std::fs::read(dir.join("catalog.snap")).unwrap(), b"{}"); + std::os::unix::fs::symlink(&victim, dir.join("catalog.log.tmp")).unwrap(); + store.write_atomic_with("catalog.log", &mut |w| w.write_all(b"[1]")).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs index c5c96a902..12daff541 100644 --- a/crates/fetch/src/lib.rs +++ b/crates/fetch/src/lib.rs @@ -314,6 +314,15 @@ fn sha256_file(path: &Path) -> Result<String, String> { Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect()) } +/// A new, empty partial download at `part`, created exclusively after removing whatever was there (a link itself, +/// never its target). +fn new_part(part: &Path) -> std::io::Result<File> { + if std::fs::symlink_metadata(part).is_ok() { + std::fs::remove_file(part)?; + } + OpenOptions::new().write(true).create_new(true).open(part) +} + fn part_path(dir: &Path, name: &str) -> PathBuf { dir.join(format!("{name}.part")) } @@ -324,9 +333,10 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & let part = part_path(dir, f.name); let limit = f.size.unwrap_or(f.max); let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display())); - // resume only a pinned file (an unpinned one can't be checked after resuming) - let mut have = match std::fs::metadata(&part) { - Ok(m) if f.size.is_some() && m.len() <= limit => m.len(), + // resume only a pinned file (an unpinned one can't be checked after resuming), and only a plain file: anything + // else at that name (a link, which `metadata` would follow) is removed, never written through (issue #1502) + let mut have = match std::fs::symlink_metadata(&part) { + Ok(m) if m.is_file() && f.size.is_some() && m.len() <= limit => m.len(), Ok(_) => { std::fs::remove_file(&part).map_err(disk)?; 0 @@ -368,7 +378,7 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & return Err(Fail::NextMirror(format!("the server's file has the wrong size ({:?} bytes)", resp.content_length()))); } have = 0; - File::create(&part).map_err(disk)? + new_part(&part).map_err(disk)? } 206 if have > 0 => { let range = resp.header("content-range").unwrap_or_default(); @@ -376,6 +386,10 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & if start != have || total.is_some_and(|t| Some(t) != f.size) { return Err(Fail::NextMirror(format!("the server resumed at the wrong place ({range})"))); } + // (still the plain file checked above: a link put there meanwhile is not appended to) + if !std::fs::symlink_metadata(&part).is_ok_and(|m| m.is_file()) { + return Err(Fail::Retry("the partial download changed; starting over".into())); + } OpenOptions::new().append(true).open(&part).map_err(disk)? } 416 => { diff --git a/crates/fetch/src/tests.rs b/crates/fetch/src/tests.rs index 4a828c6d6..08f869dcc 100644 --- a/crates/fetch/src/tests.rs +++ b/crates/fetch/src/tests.rs @@ -210,6 +210,28 @@ fn resumes_partial_files_and_restarts_bad_ones() { let _ = std::fs::remove_dir_all(&dir); } +/// A link planted at the partial download's name is removed, never written or appended through: the file it points +/// to keeps its bytes and the download completes (issue #1502). +#[cfg(unix)] +#[test] +fn a_link_at_the_partial_name_is_never_written_through() { + let srv = server(); + let dir = tmp("part-link"); + std::fs::create_dir_all(&dir).unwrap(); + let victim = dir.join("victim.txt"); + for target_len in [10usize, 0] { + std::fs::write(&victim, vec![b'k'; target_len.max(1)]).unwrap(); + let before = std::fs::read(&victim).unwrap(); + let _ = std::fs::remove_file(dir.join("model.bin")); + let _ = std::fs::remove_file(part_path(&dir, "model.bin")); + std::os::unix::fs::symlink(&victim, part_path(&dir, "model.bin")).unwrap(); + run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), before, "the linked file is untouched"); + assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload()); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_416_or_a_bad_complete_part_starts_over() { let srv = server(); diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..5db5d90dd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Reliability +- Saving the catalog and resuming a model download no longer write through a link left at the temporary file's + name (`catalog.snap.tmp`, `model.onnx.part`): the link is removed and a new file made in its place, so the file it + pointed to is never changed (issue #1502). + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 2df471b14299122b9583d7d8141b8096c20901b3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 20:19:21 +0200 Subject: [PATCH 190/315] Top bar: in full screen the left buttons move into the traffic lights' space (fixes #1763) --- crates/ui-egui/src/panels/topbar.rs | 8 +++++++- crates/ui-egui/src/tests_titlebar.rs | 18 ++++++++++++++++++ docs/parity-checklist.md | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 03810aca9..939668ac2 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -44,7 +44,13 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let left = if app.integrated_titlebar { 78 } else { 10 }; + // room for the traffic lights; in full screen they are gone, and the sidebar button sits as far + // from the left edge as the Discord button from the right (12 margin + 4) + let left = match (app.integrated_titlebar, app.window_is_fullscreen) { + (true, false) => 78, + (true, true) => 16, + (false, _) => 10, + }; egui::Panel::top("top_bar") .exact_size(t.top_bar_h) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index 40eba4b13..8e44b19ec 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -115,3 +115,21 @@ fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { let bar = h.app.widgets.iter().find(|(w, _)| w == "region:titlebar"); assert!(bar.is_none()); } + +// Scenario: in full screen the traffic lights are gone, so the bar's buttons move into their space +#[test] +fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() { + let mut h = integrated(); + let window_right = 1200.0; + let windowed = widget(&h, "icon:sidebar").left(); + assert_eq!(windowed, 78.0, "windowed, the bar leaves room for the traffic lights"); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), T); + play(&mut h); + assert!(h.app.window_is_fullscreen); + let right_padding = window_right - widget(&h, "icon:discord").right(); + assert_eq!(widget(&h, "icon:sidebar").left(), right_padding, "My Photos as far from the left edge as Discord is from the right"); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), T); + play(&mut h); + assert!(!h.app.window_is_fullscreen); + assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..cd2ca3d86 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -480,7 +480,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | -| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | ## Y. Menus From 43521479108438f23221cc252d9c372fdabbec58 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 13:19:22 -0500 Subject: [PATCH 191/315] Faces: Drop faces made from a photo's old file when it is replaced (#1524) --- crates/engine/src/cmd/face_recognize.rs | 40 +++++++++-- crates/engine/src/faces_index.rs | 90 ++++++++++++++++++++++--- crates/engine/src/faces_worker.rs | 8 ++- crates/engine/src/lib.rs | 7 +- docs/whats-new.md | 5 ++ 5 files changed, 135 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..e6e850119 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -275,7 +275,7 @@ mod imp { } let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?; let preview = s.scan_preview_of(&photo); - Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector }) + Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector }) } /// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a @@ -299,13 +299,16 @@ mod imp { } } - /// Take a finished photo into the index (ignored when it was made for another model or another library). Returns - /// (embedded, failed). + /// Take a finished photo into the index (ignored when it was made for another model or another library, or from + /// a file the photo no longer has: issue #1524). Returns (embedded, failed). fn ingest(s: &mut Session, d: Done) -> (usize, usize) { s.faces.in_flight.remove(&d.id); if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag { return (0, 0); } + if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) { + return (0, 0); + } if d.detect_wanted { match &d.found { Some(found) => { @@ -830,7 +833,8 @@ mod imp { } fn finished(s: &Session, id: PhotoId, found: Option<Vec<Rect>>, results: Vec<(Rect, Option<Vec<f32>>, Rect)>) -> Done { - Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found } + let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default(); + Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found } } #[test] @@ -915,6 +919,34 @@ mod imp { let _ = std::fs::remove_dir_all(&d); } + /// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from + /// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is + /// searched again (issue #1524). + #[test] + fn faces_made_from_a_replaced_file_are_dropped() { + let (mut s, d) = loaded("replaced"); + let (a, b) = two_photos(&s); + let rect = region(0.2, None).rect; + s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)])); + s.faces.scanned.insert(a.0); + s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)])); + s.faces.scanned.insert(b.0); + let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]); + let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap(); + let replace = Op::SetContent { id: a, width, height, file_size: 1234, content_hash: Some("edited".into()), preview_only: None }; + s.commit("Reload Changed Files", replace).unwrap(); + assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten"); + assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again"); + assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs"); + assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped"); + assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0)); + // what is made from the new file is taken in + let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]); + assert_eq!(ingest(&mut s, new), (1, 0)); + assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some()); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn the_unnamed_faces_that_look_like_a_person_come_best_first() { let (mut s, d) = loaded("similar"); diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index fd4d9f2e7..83e86b099 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -55,6 +55,8 @@ pub(crate) struct Index { skipped: std::collections::HashSet<Key>, /// The cache file on disk belongs to this model, so new records can be appended to it. file_matches: bool, + /// Records were dropped ([`Index::forget_photo`]): the cache file is written fresh at the next save. + rewrite: bool, pub path: Option<PathBuf>, } @@ -87,6 +89,16 @@ impl Index { self.skipped.insert((photo, region_key(rect))); } + /// Drop every face of `photo` (its file was replaced, so they were made from other pixels): they are embedded + /// again, and the cache file is rewritten without them at the next save. + pub fn forget_photo(&mut self, photo: u64) { + let before = self.entries.len(); + self.entries.retain(|k, _| k.0 != photo); + self.unsaved.retain(|(k, _)| k.0 != photo); + self.skipped.retain(|k| k.0 != photo); + self.rewrite |= self.entries.len() != before; + } + #[cfg(test)] pub fn insert(&mut self, photo: u64, rect: &Rect, embedding: Vec<f32>) { self.insert_with_view(photo, rect, embedding, rect); @@ -102,7 +114,7 @@ impl Index { /// Whether there are embeddings not yet written to the cache file. pub fn has_unsaved(&self) -> bool { - !self.unsaved.is_empty() + !self.unsaved.is_empty() || self.rewrite } /// Start over for `tag` (`dim` numbers per face), loading what the cache file at `path` holds for it. What the @@ -116,6 +128,7 @@ impl Index { self.unsaved.clear(); self.skipped.clear(); self.file_matches = false; + self.rewrite = false; if let Some(p) = self.path.clone() { self.load(&p); } @@ -175,17 +188,19 @@ impl Index { r } - /// Write what was added since the last save: appended when the file already belongs to this model, else the - /// whole index is written fresh (atomically). A failure is returned, the records stay pending. + /// Write what was added since the last save: appended when the file already belongs to this model, else (or when + /// records were dropped) the whole index is written fresh (atomically). A failure is returned, the records stay + /// pending. pub fn save(&mut self) -> std::io::Result<()> { let Some(path) = self.path.clone() else { self.unsaved.clear(); + self.rewrite = false; return Ok(()); }; - if self.unsaved.is_empty() { + if self.unsaved.is_empty() && !self.rewrite { return Ok(()); } - if self.file_matches { + if self.file_matches && !self.rewrite { let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let mut buf = Vec::new(); for (k, e) in &self.unsaved { @@ -201,6 +216,7 @@ impl Index { std::fs::write(&part, &buf)?; std::fs::rename(&part, &path)?; self.file_matches = true; + self.rewrite = false; } self.unsaved.clear(); Ok(()) @@ -228,6 +244,8 @@ pub(crate) struct Scanned { unsaved: Vec<u64>, path: Option<PathBuf>, file_matches: bool, + /// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save. + rewrite: bool, } impl Scanned { @@ -249,8 +267,16 @@ impl Scanned { } } + /// Search `photo` again (its file was replaced). + pub fn remove(&mut self, photo: u64) { + if self.set.remove(&photo) { + self.unsaved.retain(|p| *p != photo); + self.rewrite = true; + } + } + pub fn has_unsaved(&self) -> bool { - !self.unsaved.is_empty() + !self.unsaved.is_empty() || self.rewrite } /// Start over with the record at `path` (written first: what was not yet saved is not lost). @@ -259,6 +285,7 @@ impl Scanned { self.set.clear(); self.unsaved.clear(); self.file_matches = false; + self.rewrite = false; self.path = path; if let Some(p) = self.path.clone() { self.load(&p); @@ -292,12 +319,13 @@ impl Scanned { pub fn save(&mut self) -> std::io::Result<()> { let Some(path) = self.path.clone() else { self.unsaved.clear(); + self.rewrite = false; return Ok(()); }; - if self.unsaved.is_empty() { + if self.unsaved.is_empty() && !self.rewrite { return Ok(()); } - if self.file_matches { + if self.file_matches && !self.rewrite { let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); f.write_all(&buf)?; @@ -308,6 +336,7 @@ impl Scanned { std::fs::write(&part, &buf)?; std::fs::rename(&part, &path)?; self.file_matches = true; + self.rewrite = false; } self.unsaved.clear(); Ok(()) @@ -406,6 +435,43 @@ mod tests { assert_eq!(b.len(), 0, "a non-finite vector is not loaded back"); let _ = std::fs::remove_file(path); } + + /// The faces and the search of a photo whose file was replaced are dropped from the files too, not only from memory: + /// they don't come back at the next launch (issue #1524). + #[test] + fn a_forgotten_photo_stays_forgotten_after_reloading() { + let dir = std::env::temp_dir().join(format!("lc-faces-forget-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin")); + let mut a = Index::default(); + a.reset("m@abc", 2, Some(emb.clone())); + a.insert(1, &rect(0.1), vec![1.0, 0.0]); + a.insert(2, &rect(0.1), vec![0.0, 1.0]); + a.save().unwrap(); + a.forget_photo(1); + assert!(a.has_unsaved()); + a.save().unwrap(); + a.insert(3, &rect(0.1), vec![1.0, 1.0]); + a.save().unwrap(); + let mut b = Index::default(); + b.reset("m@abc", 2, Some(emb)); + assert!(!b.contains(1, &rect(0.1)) && b.contains(2, &rect(0.1)) && b.contains(3, &rect(0.1))); + let mut s = Scanned::default(); + s.reset(Some(scan.clone())); + s.insert(1); + s.insert(2); + s.save().unwrap(); + s.remove(1); + assert!(s.has_unsaved()); + s.save().unwrap(); + s.insert(3); + s.save().unwrap(); + let mut t = Scanned::default(); + t.reset(Some(scan)); + assert!(!t.contains(1) && t.contains(2) && t.contains(3)); + let _ = std::fs::remove_dir_all(&dir); + } } /// What the recognition commands keep between calls: the loaded model and the embeddings made with it. @@ -456,4 +522,12 @@ impl FacesState { self.saved_at = None; self.epoch = self.epoch.wrapping_add(1); } + + /// Photo `id`'s file was replaced by other content: the faces made from the old pixels are dropped and the photo is + /// searched again (issue #1524). What a worker is still making from the old file is dropped when it comes back. + pub fn content_replaced(&mut self, id: lightcraft_catalog::PhotoId) { + self.index.forget_photo(id.0); + self.scanned.remove(id.0); + self.scan_failed.remove(&id.0); + } } diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 81ff6adae..96bd4f124 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -134,6 +134,9 @@ pub(crate) struct Prepared { /// The session's face epoch when this was prepared: another library has been opened since if it differs. pub epoch: u64, pub id: PhotoId, + /// The photo's content key ([`crate::media::content_key`]) when this was prepared: its file was replaced (Reload + /// after an outside edit) since if it differs. + pub content: String, pub job: RenderJob, /// A raw's embedded preview, tried before the render job (much faster than decoding the raw). pub preview: Option<(String, PreviewLoader)>, @@ -150,6 +153,7 @@ pub(crate) struct Done { pub tag: String, pub epoch: u64, pub id: PhotoId, + pub content: String, /// Each face's region, its embedding (`None` when it could not be made) and the box to show it by. pub results: Vec<(Rect, Option<Vec<f32>>, Rect)>, /// Faces were to be looked for in this photo. @@ -177,7 +181,7 @@ fn face_rect(f: &Face) -> Option<Rect> { /// Do the work for one photo. Never panics: whatever goes wrong, its faces come back as `None`. pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { - let Prepared { tag, epoch, id, job, preview, todo, detect, detector } = p; + let Prepared { tag, epoch, id, content, job, preview, todo, detect, detector } = p; let fallback = |todo: Vec<Rect>| todo.into_iter().map(|r| (r, None, r)).collect::<Vec<_>>(); let rects = todo.clone(); let run = catch_unwind(AssertUnwindSafe(|| { @@ -238,7 +242,7 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { (results, new_faces) })); let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None)); - Done { tag, epoch, id, results, detect_wanted: detect, found } + Done { tag, epoch, id, content, results, detect_wanted: detect, found } } type Job = (Arc<Embedder>, Prepared); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..1e8a3aaba 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -510,7 +510,12 @@ impl Session { let mut pending = vec![&op]; while let Some(op) = pending.pop() { match op { - Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), + Op::SetContent { id, .. } => { + self.media.forget(*id); + #[cfg(not(target_arch = "wasm32"))] + self.faces.content_replaced(*id); + } + Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), Op::AddPhoto { photo } => self.media.forget(photo.id), Op::Batch { ops } => pending.extend(ops), _ => {} diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..998ff70bb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Reliability +- A photo whose file was replaced (Reload Changed Files after an outside edit) no longer keeps the faces recognition + made from the old pixels: they are made again from the new file, and a scan still busy with the old file when it + changed doesn't put its faces on the new one (issue #1524). + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From ef3642c39fd2b4ac74ada97964f056cc97cfcfb2 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 13:38:14 -0500 Subject: [PATCH 192/315] LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the columns past the image (#1627) --- crates/raw/src/vendor/nef.rs | 90 ++++++++++++++++++++++++++++++++---- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 7 +-- docs/whats-new.md | 7 +++ 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 7d5b24a4f..62926aee5 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -59,7 +59,8 @@ fn default_crop(mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { /// A body that records no CropArea, measured on its own files: the image area inside the sensor data (where the /// camera's full-size JPEG sits when the two are aligned) and, when the data's own top would be wrong, the level -/// where its channels clip. +/// where its channels clip. Without that level, the white is measured inside the image area only, so what lies +/// beyond it can't pass for saturation. struct MeasuredBody { model: &'static str, /// Sensor data size and sample depth of the measured files. @@ -87,17 +88,49 @@ struct MeasuredBody { /// the masked-column test keeps them and they develop as a pink band on the right. The camera's 4288 × 2848 JPEG /// sits at (10, 10). Green saturates at 3840–3851 while red and blue run on to 4095; one level of 3830 keeps /// clipped highlights white. +/// * **NIKON D5000** (issue #1627; 2 CC0 compressed 12-bit NEFs from raw.pixls.us): the D90's sensor, with the +/// same 4352 × 2868 data and the same flat ~255 filler from column 4310, which shows as a band on the right. Both +/// samples had Auto Distortion Control on, which warps their JPEGs around the centre, so only the centre aligns: +/// a 200 px patch there sits where the D90's area puts it, (10, 10), on both. Green tops out at the D90's 3840. +/// * **NIKON D600 and D610** (issue #1627; 4 CC0 NEFs of each from raw.pixls.us, 12- and 14-bit, lossless and +/// lossy): the data is 6080 × 4028. Columns 6034–6041 are filler at a flat 150 (12-bit) or 601 (14-bit), +/// 6042–6047 hold noise up to the top of the range, and only the last 32 are dark, so the masked-column test keeps +/// the first 14 and they develop as a bright band on the right. The camera's 6016 × 4016 JPEG sits at (8, 6) in +/// all 8 files. The noise columns also read as a saturation plateau: the white measured from the whole data came +/// out at 3174–3182 (12-bit) and 12707–12729 (14-bit) on the D600's files while their pictures reach 3450–3731 and +/// 14902–16308, so their brightest samples were cut off as clipped. Measured inside the image area it is the +/// data's top, or the real plateau where the picture clips (4080 and 16320 on the D610's). const MEASURED_BODIES: &[MeasuredBody] = &[ MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) }, MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None }, MeasuredBody { model: "NIKON D7100", size: (4812, 3212), bits: 14, crop: (6, 6, 4800, 3200), white: None }, MeasuredBody { model: "NIKON D90", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) }, + MeasuredBody { model: "NIKON D5000", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) }, + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, ]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) } +/// The samples of the image area `crop`, every few rows (an odd step, so both rows of the CFA are in), for measuring +/// the white level where the picture is. +fn area_samples(d: &[u16], w: usize, crop: Rect) -> Vec<u16> { + let step = (crop.width.saturating_mul(crop.height) / 2_000_000).max(1) | 1; + (crop.y..crop.y.saturating_add(crop.height)) + .step_by(step) + .filter_map(|y| { + let at = y.checked_mul(w)?.checked_add(crop.x)?; + d.get(at..at.checked_add(crop.width)?) + }) + .flatten() + .copied() + .collect() +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -180,16 +213,18 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { let model = ifd0.string(t::MODEL).unwrap_or_default(); let body = measured_body(&model, w, h, bits); let active_w = trailing_masked_columns(samples, w, bits); - // the white level is measured from the whole image (or known for a measured body); the probe (no samples in its - // result) takes the nominal one - let white = match (body.and_then(|b| b.white), mode) { - (Some(white), _) => white, - (None, Mode::Full) => white_from_data(samples, bits), - (None, Mode::Header) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, + let measured = match body { + Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Some(Rect::new(x, y, cw, ch)), + _ => None, }; - let crop = match body { - Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), - _ => default_crop(mn.as_ref(), active_w, h), + let crop = measured.unwrap_or_else(|| default_crop(mn.as_ref(), active_w, h)); + // the white level is measured from the whole image (only its measured area, for a body with one), or known for a + // measured body; the probe (no samples in its result) takes the nominal one + let white = match (body.and_then(|b| b.white), mode, measured) { + (Some(white), _, _) => white, + (None, Mode::Full, Some(area)) => white_from_data(&area_samples(samples, w, area), bits), + (None, Mode::Full, None) => white_from_data(samples, bits), + (None, Mode::Header, _) => ((1u32 << bits.clamp(1, 16)) - 1) as f32, }; let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); @@ -505,6 +540,41 @@ mod tests { assert_eq!((d90.metadata.width, d90.metadata.height), (Some(4288), Some(2848))); } + /// Issue #1627: the D600's and D610's data runs 14 columns of filler and noise past the image before the dark + /// ones; they develop as a bright band on the right unless the measured area leaves them out, and the noise must + /// not read as the white level. The D5000 has the D90's filler. + #[test] + fn measured_d600_d610_and_d5000_drop_the_columns_past_the_image() { + let file = |model: &str, (w, h): (usize, usize), bits: u16, row: &dyn Fn(&mut [u16])| { + let mut px = vec![0u16; w * h]; + px.chunks_mut(w).for_each(row); + let mut nef = nef(1, bits, vec![px.iter().flat_map(|v| v.to_be_bytes()).collect()], w as u32, h as u32, h as u32); + let at = nef.windows(11).position(|b| b == b"NIKON TEST\0").unwrap(); + nef[at..at + 11].copy_from_slice(format!("{model:\0<11}").as_bytes()); + crate::decode(&nef).unwrap() + }; + for (model, bits, fill, top) in [("NIKON D610", 12, 150, 4095), ("NIKON D610", 14, 601, 16383), ("NIKON D600", 14, 601, 16383)] { + let d = file(model, (6080, 4028), bits, &|row| { + row[..6034].fill(fill * 2); + row[6034..6042].fill(fill); + // the noise columns: often at the top of the range, which made a saturation plateau of their own + row[6042..6048].fill(top); + }); + assert_eq!(d.crop, Rect::new(8, 6, 6016, 4016), "{model} {bits}"); + assert_eq!((d.metadata.width, d.metadata.height), (Some(6016), Some(4016))); + assert_eq!(d.white, vec![top as f32], "{model} {bits}: the white is measured inside the image, which isn't clipped"); + let developed = d.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (6016, 4016)); + } + let d5000 = file("NIKON D5000", (4352, 2868), 12, &|row| { + row.fill(40); + row[4310..].fill(255); + }); + assert_eq!(d5000.crop, Rect::new(10, 10, 4288, 2848)); + assert_eq!(d5000.white, vec![3830.0]); + assert_eq!(file("NIKON D5100", (4352, 2868), 12, &|row| row.fill(40)).crop, Rect::new(0, 0, 4352, 2868)); + } + /// Issue #708: the active width is judged from the first [`MASK_ROWS`] rows in both modes, so the header probe /// (which reads only those rows' strips) and the full decode agree whether the masked columns are there or not. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..b44462014 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-IMP-CAMERA-COVERAGE, Nikon D600 / D610 / D5000 image area, #1627; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue), and the D600, D610 and D5000 on 4, 4 and 2 CC0 NEFs (filler and noise columns on the right that developed as a band, issue #1627).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the filler and noise columns on the right (a band), and the D600 and D610 measure their white level inside the image (#1627); status unchanged (🟡) | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 811353a70..d50cb0c52 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Nikon Z 6II / Z 8 / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Nikon D600, D610 and D5000: the columns past the image no longer develop as a band, issue #1627; Nikon Z 6II / Z 8 / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -122,7 +122,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded | | CR3 | dual-pixel raw | ⬜ | never substituted | | CRW | all | ⬜ | preview | -| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG | +| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4, D90, D5000, D600 and D610 image area measured against the camera JPEG (white level too for the D4, D90 and D5000) | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | | ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) | @@ -178,7 +178,7 @@ sample files. A pointer for contributors, not legal advice. | Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed); CR2 white level taken from saturated masked rows (#1272, fixed) | | Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | -| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | +| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90, D5000, D600, D610) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 | | Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye | | AI denoise | ✅ Bayer and X-Trans, model bundled | Bayer only, user-installed weights, pure-Rust CPU / GPU | 🟡 no X-Trans, no shippable weights | @@ -272,6 +272,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Nikon D600 and D610 image area (6016 × 4016 at 8, 6) measured on 4 CC0 NEFs of each, and the D90's area and white level applied to the D5000 (same sensor and filler, 2 CC0 NEFs): the filler and noise columns on the right no longer develop as a band, and for these bodies the white level is measured inside the image area, issue #1627 | | 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | | 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | | 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..c18b55313 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-14 +### Nikon D600, D610 and D5000 +- Raws from the Nikon D600, D610 and D5000 no longer show a bright line down the right edge (issue #1627). Their + sensor data runs a few columns of filler past the picture, which stayed in the image; these bodies now develop + at the camera JPEG's size (6016 × 4016 for the D600 and D610, 4288 × 2848 for the D5000, as the D90 already + did). On the D600 and D610 the white level is also measured inside the picture, so the filler can't pass for + clipped highlights. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From af0c1638fe6070c6e3849e3ef9c60fe3d15c5665 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Wed, 14 Oct 2026 02:33:26 +0400 Subject: [PATCH 193/315] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Georgian (ქართული, code ka, system tags ka / ka-GE / ka_GE) with complete plain and formats catalogs, the Edit > Language command and the Settings choice. Sentence-cased messages go through i18n::upper_first, which leaves Georgian letters alone (upper-casing Mkhedruli would turn it into Mtavruli). The Settings language choices wrap now that eleven endonyms no longer fit on one line, and the face-model Remove button goes through tr(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- README.md | 2 +- ROADMAP.md | 5 +- assets/ATTRIBUTION.md | 1 + crates/ui-egui/locales/ka-formats.json | 251 ++ crates/ui-egui/locales/ka.json | 2721 +++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 52 + crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/panels/denoise.rs | 4 +- crates/ui-egui/src/panels/faces.rs | 6 +- crates/ui-egui/src/panels/keyword_list.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 6 +- docs/localization-ka.md | 77 + docs/localization-parity.md | 8 +- docs/localization.md | 9 +- docs/parity-checklist.md | 4 +- docs/roadmap.md | 2 +- 16 files changed, 3132 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/locales/ka-formats.json create mode 100644 crates/ui-egui/locales/ka.json create mode 100644 docs/localization-ka.md diff --git a/README.md b/README.md index f041111a1..5dc19fe73 100644 --- a/README.md +++ b/README.md @@ -405,7 +405,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, ქართული) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder diff --git a/ROADMAP.md b/ROADMAP.md index 3f02a1199..38e3413ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Georgian interface language) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc | Korean | ko | 0% | none (no Hangul face) | | Vietnamese | vi | 0% | none | -Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Georgian (100%). ## Upcoming @@ -163,6 +163,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..9f3edd4ba 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -26,6 +26,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | | craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | +| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json new file mode 100644 index 000000000..719aee83f --- /dev/null +++ b/crates/ui-egui/locales/ka-formats.json @@ -0,0 +1,251 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} რამდენიმე მსგავსი ფაილით: გამოიყენეთ „მოძებნა“)", + " ({} in Recently Deleted)": " ({} ახლახან წაშლილებში)", + " · {:.0}% clipped": " · {:.0}% მოკვეთილია", + " · {failed} couldn't be rendered": " · ვერ დარენდერდა: {failed}", + " · {queued} waiting": " · რიგშია: {queued}", + " · {skipped} skipped (file exists)": " · გამოტოვებულია: {skipped} (ფაილი უკვე არსებობს)", + " · {} duplicate{} skipped": " · გამოტოვებული დუბლიკატი: {}{:.0}", + " · {} failed: {}": " · ვერ მოხერხდა: {}: {}", + " · {} moved": " · გადატანილია: {}", + " · {} not readable": " · ვერ იკითხება: {}", + " · {} original{} left at the source": " · წყაროში დატოვებული ორიგინალი: {}{:.0}", + " · {} restored from Recently Deleted": " · ახლახან წაშლილებიდან აღდგენილია: {}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One ვერ შემოიტანს სიას, სადაც საკვანძო სიტყვა შეიცავს ; , < ან > სიმბოლოს: {names}", + " — left out, as a keyword list can't hold them: {names}": " — გამოტოვებულია, რადგან საკვანძო სიტყვების სია მათ ვერ დაიტევს: {names}", + " — not carried over: {}{}": " — არ გადავიდა: {}{}", + "+{n} more": "+ კიდევ {n}", + "+{} more": "+ კიდევ {}", + ", {failed} file{} not readable": ", ვერ იკითხება ფაილი: {failed}{:.0}", + ", {} label": ", იარლიყი: {}", + ", {} star{}": ", ვარსკვლავი: {}{:.0}", + "1 task is still running: {names}. Quitting stops it.": "ჯერ კიდევ მიმდინარეობს 1 ამოცანა: {names}. გასვლა მას შეწყვეტს.", + "; {kept} already had a location": "; მდებარეობა უკვე ჰქონდა: {kept}", + "; {n} gained synonyms": "; სინონიმები დაემატა: {n}", + "; {outside} outside its time range": "; მარშრუტის დროის დიაპაზონს გარეთაა: {outside}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, კომიტი: {}, სტრიქონი: +{} / −{} (Δ {}), ბინარული ფაილი: +{} / −{}, {} – {}", + "Add {local_n} to My Photos": "ჩემს ფოტოებში დამატება: {local_n}", + "Add {} Photo{}": "ფოტოს დამატება: {}{:.0}", + "Add {} Photo{}, restore {}": "ფოტოს დამატება: {}{:.0}, აღდგენა: {}", + "Add “{}” to Favorites": "„{}“ რჩეულებში დამატება", + "Added {n} keyword{}": "დაემატა საკვანძო სიტყვა: {n}{:.0}", + "Added {n} photo{} to My Photos": "ჩემს ფოტოებს დაემატა ფოტო: {n}{:.0}", + "Added {n} photo{} to “{}”": "„{1}“-ს დაემატა ფოტო: {n}{0:.0}", + "Added {what} to {name}": "{what}: დაემატა {name}-ს", + "Added “{keyword}” to {n} photo{}": "„{keyword}“ დაემატა ფოტოს: {n}{:.0}", + "Adding photos… {} of {}": "ფოტოების დამატება… {} / {}", + "All {n} selected photos move by the same amount.": "მონიშნული ფოტოები ({n}) ერთი და იმავე სიდიდით გადაინაცვლებს.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "უკვე ბიბლიოთეკაშია, ახლახან წაშლილებში: მონიშნულია ფოტო: {}{:.0} · მარჯვენა დაწკაპუნება → აღდგენა, ან სამუდამოდ წაშლა თავიდან იმპორტისთვის", + "Already in the library: {} photo{} selected in All Photos": "უკვე ბიბლიოთეკაშია: „ყველა ფოტოში“ მონიშნულია ფოტო: {}{:.0}", + "Auto ({})": "ავტომატური ({})", + "Auto Import: added {} photo{}": "ავტოიმპორტი: დაემატა ფოტო: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "ავტოსინქრონიზაცია: ცვლილებები ვრცელდება ფოტოებზე: {n}", + "Automatic ({} MB)": "ავტომატური ({} MB)", + "Build 1:1 Previews ({scope})": "1:1 წინასწარი ხედების შექმნა ({scope})", + "Build Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების შექმნა ({scope})", + "Build Standard-Sized Previews ({scope})": "სტანდარტული ზომის წინასწარი ხედების შექმნა ({scope})", + "Building previews for {} photos…": "იქმნება წინასწარი ხედები ფოტოებისთვის: {}…", + "Click {}:1": "დაწკაპუნება {}:1", + "Couldn't open the editor: {e}": "რედაქტორი ვერ გაიხსნა: {e}", + "Create Keyword Tag Inside “{name}”…": "საკვანძო სიტყვის შექმნა „{name}“-ში…", + "Create {n} Virtual Copies": "ვირტუალური ასლების შექმნა: {n}", + "Creates {} stacks from {} of {scope}": "შეიქმნება დასტა: {} — {}-დან ({scope})", + "Decoder: {reason}": "დეკოდერი: {reason}", + "Delete \"{label}\"": "„{label}“-ის წაშლა", + "Delete \"{label}\" (and the mask)": "„{label}“-ის წაშლა (ნიღაბთან ერთად)", + "Delete the keyword “{name}”?": "წაიშალოს საკვანძო სიტყვა „{name}“?", + "Delete {n} Photos": "ფოტოების წაშლა: {n}", + "Delete “{current}”": "„{current}“-ის წაშლა", + "Denoising {name}: {done} of {total} tiles{}": "{name}-ის ხმაურის მოცილება: ფრაგმენტი {done} / {total}{}", + "Discard Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების მოცილება ({scope})", + "Done ({what}): {done} photo{plural}": "მზადაა ({what}): ფოტო: {done}{plural:.0}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "ჩამოიტვირთება {host}-დან. მიღებისა და შემოწმების შემდეგ LightCraft მას დააყენებს, {after}.", + "Duplicate \"{label}\"": "„{label}“-ის დუბლირება", + "Example: {example}": "მაგალითი: {example}", + "Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}", + "Exported {n} photo{}": "ექსპორტირებულია ფოტო: {n}{:.0}", + "Exported {ok} of {total} photo{}": "ექსპორტირებულია ფოტო: {ok} / {total}{:.0}", + "Exported {} point curve preset{}": "ექსპორტირებულია წერტილოვანი მრუდის პრესეტი: {}{:.0}", + "Exported {} preset{}": "ექსპორტირებულია პრესეტი: {}{:.0}", + "Exporting {} of {total}": "ექსპორტი: {} / {total}", + "Filter bar — {active} active filter{}": "ფილტრების ზოლი — აქტიური ფილტრი: {active}{:.0}", + "Filtered: {label}": "გაფილტრულია: {label}", + "Focus {:.0}": "ფოკუსი {:.0}", + "Folder “{}” (and its subfolders)": "საქაღალდე „{}“ (ქვესაქაღალდეებით)", + "Found {n} missing photo{}; {left} still missing{unsure}": "ნაპოვნია დაკარგული ფოტო: {n}{:.0}; კვლავ აკლია: {left}{unsure}", + "Healed {n} dust spot{}": "აღდგენილია მტვრის ლაქა: {n}{:.0}", + "Import cancelled · {} photo{} added": "იმპორტი გაუქმდა · დაემატა ფოტო: {}{:.0}", + "Import failed: {e}": "იმპორტი ვერ მოხერხდა: {e}", + "Import warnings ({n}):\n{warnings}": "იმპორტის გაფრთხილებები ({n}):\n{warnings}", + "Imported {n} point curve preset{}": "იმპორტირებულია წერტილოვანი მრუდის პრესეტი: {n}{:.0}", + "Imported {n} preset{}": "იმპორტირებულია პრესეტი: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "იმპორტირებულია პრესეტი: {n}{:.0}, ვერ იკითხება ფაილი: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "იმპორტირებულია პროფილი: {profiles}{:.0} (პროფილების ბრაუზერი → მათი ჯგუფები)", + "Imported {} photo{}": "იმპორტირებულია ფოტო: {}{:.0}", + "In line for AI Denoise ({ahead} ahead)…": "AI ხმაურის მოცილების რიგში (წინ არის: {ahead})…", + "Insert at the cursor (also written {})": "კურსორის ადგილას ჩასმა (ასევე იწერება როგორც {})", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "მოიხსნება ფოტოებიდან ({count}){:.0} მის ქვემოთ მყოფ საკვანძო სიტყვებთან ერთად. დაბრუნება მას აღადგენს.", + "Label Set: {name}": "იარლიყების ნაკრები: {name}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ჯერ ვერ დეკოდირებს RAW-ის ამ ვარიანტს ({}). თქვენ არედაქტირებთ კამერის ჩაშენებულ JPEG-ხედს, რომელიც უკვე შეიცავს კამერის გამოსახულების სტილს (მაგ., მონოქრომულს) და თეთრის ბალანსს.", + "LightCraft could not open its window: {e}": "LightCraft-მა ფანჯარა ვერ გახსნა: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft-მა ფანჯარა ვერ გახსნა: {e}\n\nდეტალები ჟურნალის ფაილშია: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-ის კატალოგი შემოწმდა: ფოტო: {photos}, კოლექცია: {collections}, აკლია: {missing}", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Guided Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; შენახული გარდაქმნა იგნორირებულია, რადგან Upright გამორთულია.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; {mode} ხელახლა გამოითვლება.", + "Lightroom import complete: {imported} photos added": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}, ვერ მოხერხდა: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-იდან იმპორტი დასრულდა: დაემატა ფოტო: {imported}; ინდექსის გაფრთხილება: {warning}", + "Lightroom import failed: {e}{warning}": "Lightroom-იდან იმპორტი ვერ მოხერხდა: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Lightroom-ის შემოწმება ვერ მოხერხდა: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. შრიფტები: {} (OFL). ხატულები: ორიგინალური.", + "Making the denoised picture… {}%": "იქმნება ხმაურმოცილებული სურათი… {}%", + "Matches: {}": "დამთხვევა: {}", + "Merging… {stage} {:.0}%": "გაერთიანება… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "გადავიდეს {what} ახლახან წაშლილებში?", + "Moved {} photo(s)": "გადატანილია ფოტო: {}", + "Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}", + "Named ({named_n})": "სახელიანი ({named_n})", + "New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში", + "On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე", + "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“-ის მინიჭება: დაწკაპეთ ფოტოებზე მის დასამატებლად ან მოსახსნელად · Esc ასრულებს", + "Paste into {n} photo{} — only settings that were copied are pasted": "ჩასმა ფოტოებში: {n}{:.0} — ჩაისმება მხოლოდ კოპირებული პარამეტრები", + "Preset: {name}": "პრესეტი: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "მხოლოდ წინასწარი ხედი — რედაქტირდება კამერის ჩაშენებული JPEG ({})", + "Preview only — {}": "მხოლოდ წინასწარი ხედი — {}", + "Previews ready for {done} photo{plural}": "წინასწარი ხედები მზადაა ფოტოებისთვის: {done}{plural:.0}", + "Profile: {}": "პროფილი: {}", + "Proof Copy ({})": "საცდელი ასლი ({})", + "Proof Preview · {}": "საცდელი ჩვენება · {}", + "Put inside “{parent}”": "„{parent}“-ში მოთავსება", + "Quick Develop · {n} photos": "სწრაფი დამუშავება · ფოტო: {n}", + "Quick Develop: {name}": "სწრაფი დამუშავება: {name}", + "Rated {}": "შეფასება: {}", + "Reading Lightroom catalog… {done} of {total}": "იკითხება Lightroom-ის კატალოგი… {done} / {total}", + "Reading photos… {done} of {total}": "იკითხება ფოტოები… {done} / {total}", + "Reading photos… {} of {}": "იკითხება ფოტოები… {} / {}", + "Redo {}": "{}-ის გამეორება", + "Remove “{name}”": "„{name}“-ის მოშორება", + "Remove “{name}” and its {count} photo{} from the library?": "მოშორდეს „{name}“ და მისი ფოტოები ({count}){:.0} ბიბლიოთეკიდან?", + "Remove “{}” from Favorites": "„{}“ რჩეულებიდან ამოშლა", + "Removed from {}": "ამოიშალა {}-დან", + "Removed {what} from {name}": "{what}: ამოიშალა {name}-დან", + "Rename {n} Photos…": "ფოტოების გადარქმევა: {n}…", + "Rename “{name}”": "„{name}“-ის გადარქმევა", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "„{from}“ გადაერქმევა ფოტოებზე ({n}){:.0} (მის ქვემოთ მყოფ საკვანძო სიტყვებზეც). დონეებისთვის გამოიყენეთ |, მაგ., მოგზაურობა|იტალია. არსებული სახელის შემთხვევაში ორივე გაერთიანდება.", + "Rendering on the CPU: {why}": "რენდერი CPU-ზე: {why}", + "Replace {} with:": "{}-ის ჩანაცვლება:", + "Reset {name}": "ჩამოყრა: {name}", + "Reset {} Channel": "არხის ჩამოყრა: {}", + "Restore {}": "აღდგენა: {}", + "Restore {} Photo{}": "ფოტოს აღდგენა: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "ახლახან წაშლილებიდან აღდგა ფოტო: {}{:.0}", + "Running on the processor: {}.": "მუშაობს პროცესორზე: {}.", + "Running on {}.": "მუშაობს: {}.", + "Saved export preset “{}”": "ექსპორტის პრესეტი „{}“ შენახულია", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "აფასებს ფოკუსსა და ექსპოზიციას ({scope}) და პოულობს ერთმანეთისგან რამდენიმე წამში გადაღებულ მსგავს კადრებს (სერიებს).", + "Show {hidden} hidden location{}": "დამალული მდებარეობების ჩვენება: {hidden}{:.0}", + "Showing {n} of {}": "ნაჩვენებია {n} / {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "აჩვენებს მოდელის პირობებს, შემდეგ ჩამოტვირთავს {}-ს {host}-დან, დააყენებს და დაიწყებს მის გამოყენებას. სხვა არაფერი იგზავნება.", + "Smart album: {}": "სმარტ-ალბომი: {}", + "Tagged {n} photo{} from the tracklog": "მარშრუტის ჟურნალით მოინიშნა ფოტო: {n}{:.0}", + "The SAM 3 download failed: {e}": "SAM 3-ის ჩამოტვირთვა ვერ მოხერხდა: {e}", + "There is a keyword set “{name}” already": "საკვანძო სიტყვების ნაკრები „{name}“ უკვე არსებობს", + "There is a “{name}” at the top level already. Merge the two?": "ზედა დონეზე „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?", + "Trained on: {provenance}": "გაწვრთნილია: {provenance}", + "Undo {}": "დაბრუნება: {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "უცნობი ტეგი{s:.0} {} რჩება როგორც აიკრიფა{:.0} (იხ. „ტეგები“)", + "Updated “{name}”": "„{name}“ განახლდა", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "ვერსია {} — სუფთა ოთახის მეთოდით შექმნილი, სუფთა Rust-ზე დაწერილი ფოტობიბლიოთეკა და RAW-დამმუშავებელი.", + "Version: {}": "ვერსია: {}", + "Virtual copy “{name}” of {of}": "{of}-ის ვირტუალური ასლი „{name}“", + "Working on {what} for {} photos…": "{what}: მუშავდება ფოტოები ({})…", + "XMP adjustments were not imported for {path}: {fields}.": "{path}-ისთვის XMP-ის კორექციები არ შემოვიდა: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "{path}-ისთვის XMP პროფილი „{profile}“ არ გამოიყენება; ფერები შეიძლება განსხვავდებოდეს Lightroom-ისგან.", + "none (CPU rendering): {why}": "არცერთი (რენდერი CPU-ზე): {why}", + "not added to Apple Photos: {e}": "არ დაემატა Apple Photos-ს: {e}", + "the {n} selected photos": "მონიშნული ფოტოები ({n})", + "the {} photos in view": "ხედში არსებული ფოტოები ({})", + "these {count} photos": "ეს ფოტოები ({count})", + "{:.1} MB": "{:.1} MB", + "{count} smart preview{} · {:.1} MB": "ჭკვიანი წინასწარი ხედი: {count}{:.0} · {:.1} MB", + "{done} of {total}": "{done} / {total}", + "{done} of {total} MB": "{done} / {total} MB", + "{file} · {size}": "{file} · {size}", + "{f} folders (and their subfolders)": "საქაღალდე: {f} (ქვესაქაღალდეებით)", + "{label} Grading": "გრადაცია: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} ყველა მონიშნულ ფოტოზე", + "{label} {index} of {n}": "{label} {index} / {n}", + "{label}, {n} photos": "{label}, ფოტო: {n}", + "{matching} of {t} photos": "ფოტო: {matching} / {t}", + "{matching} photos": "ფოტო: {matching}", + "{mode} spot {} of {}": "{mode}: ლაქა {} / {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}.{year}", + "{msg} ({failed} failed)": "{msg} (ვერ მოხერხდა: {failed})", + "{name} Label": "იარლიყი: {name}", + "{name} Off": "{name}: გამორთულია", + "{name} On": "{name}: ჩართულია", + "{name} opened for editing; it is stacked with the original": "{name} გაიხსნა რედაქტირებისთვის; ორიგინალთან ერთ დასტაშია", + "{name} opened in the other editor": "{name} გაიხსნა სხვა რედაქტორში", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} — ორმაგი დაწკაპუნებით ჩამოყრა", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "გამოტოვებულია ლოკალური ფოტო: {n}{0:.0} — ალბომებში მხოლოდ ბიბლიოთეკის ფოტოებია (ჯერ დაამატეთ ჩემს ფოტოებში)", + "{n} added to Apple Photos": "დაემატა Apple Photos-ს: {n}", + "{n} added to Apple Photos album “{album}”": "დაემატა Apple Photos-ის ალბომს „{album}“: {n}", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "დისკზე ვერ ჩაიწერა ცვლილება: {n}{0:.0}: {e}\nთუ ახლა გახვალთ, ისინი დაიკარგება.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "ცვლილება შენახულია მეხსიერებაში, მაგრამ დისკზე არ ჩაწერილა: {n}{:.0}: {e}\nLightCraft ავტომატურად ცდილობს ხელახლა; ახლა გასვლით ისინი დაიკარგება.{:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "ცვლილება შენახულია მეხსიერებაში, მაგრამ დისკზე არ ჩაწერილა: {n}{:.0}: {e} — LightCraft ხელახლა სცდის", + "{n} found · {sel} selected": "ნაპოვნია: {n} · მონიშნულია: {sel}", + "{n} originals opened in the other editor": "სხვა რედაქტორში გახსნილი ორიგინალები: {n}", + "{n} photos": "ფოტო: {n}", + "{n} photos in {} are hidden by the filters above": "ზემოთ მოცემული ფილტრები მალავს „{}“-ის ფოტოებს: {n}", + "{n} photos opened for editing; each is stacked with its original": "რედაქტირებისთვის გაიხსნა ფოტო: {n}; თითოეული თავის ორიგინალთან ერთ დასტაშია", + "{n} photo{}": "ფოტო: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "შესაბამისი ფოტო: {n}{:.0} · ავტომატურად განახლდება ფოტოების ცვლილებისას", + "{n} photo{} now · updates automatically as photos change": "ახლა ფოტო: {n}{:.0} · ავტომატურად განახლდება ფოტოების ცვლილებისას", + "{n} similar photo{} · View ▸ Clear Filters to see all": "მსგავსი ფოტო: {n}{:.0} · ყველას სანახავად: ხედი → ფილტრების გასუფთავება", + "{n} tasks are still running: {names}. Quitting stops them.": "ჯერ კიდევ მიმდინარეობს ამოცანები ({n}): {names}. გასვლა მათ შეწყვეტს.", + "{n} {noun}": "{noun}: {n}", + "{pct} %": "{pct} %", + "{pid} (missing)": "{pid} (აკლია)", + "{sel_n} selected · {counted}": "მონიშნულია: {sel_n} · {counted}", + "{size} GB": "{size} GB", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} (ფოტო: {n}){:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}", + "{what} merge added": "{what}: გაერთიანება დაემატა", + "{what} merge cancelled": "{what}: გაერთიანება გაუქმდა", + "{what} merge failed: {e}": "{what}: გაერთიანება ვერ მოხერხდა: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "{{date:…}} დირექტივები: {}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "{} and higher": "{} და მეტი", + "{} contributors · {} commits": "მონაწილე: {} · კომიტი: {}", + "{} failed": "ვერ მოხერხდა: {}", + "{} files": "ფაილი: {}", + "{} files and {f} folder{}": "ფაილი: {}, საქაღალდე: {f}{:.0}", + "{} item{}": "ელემენტი: {}{:.0}", + "{} label (click more labels to show any of them)": "იარლიყი „{}“ (დაწკაპეთ სხვა იარლიყებზე, რომ ნებისმიერი მათგანის მქონე ფოტო გამოჩნდეს)", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "დამხმარე ხაზი: {} / 4 — გაავლეთ ხაზებზე, რომლებიც ვერტიკალური ან ჰორიზონტალური უნდა იყოს.", + "{} of {} from {host}": "{} / {} — {host}-დან", + "{} photos": "ფოტო: {}", + "{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}", + "{} sample{}": "ნიმუში: {}{:.0}", + "{} sec": "{} წმ", + "{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. ინახება ბიბლიოთეკის „denoise“ საქაღალდეში; ლიმიტის მიღწევისას პირველად იშლება ყველაზე ძველი, არცერთი ფოტოს მიერ გამოუყენებელი ფაილები.", + "{} — show their photos": "{} — მისი ფოტოების ჩვენება", + "· {dups} already in the library": "· უკვე ბიბლიოთეკაშია: {dups}", + "· {in_source} identical copies": "· იდენტური ასლები: {in_source}", + "· {n} photo{}": "· ფოტო: {n}{:.0}", + "· {trashed} in Recently Deleted": "· ახლახან წაშლილებში: {trashed}", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“-ში „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?", + "• {assumption}": "• {assumption}", + "… and {more} more": "… და კიდევ {more}" +} diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json new file mode 100644 index 000000000..cfb845f56 --- /dev/null +++ b/crates/ui-egui/locales/ka.json @@ -0,0 +1,2721 @@ +{ + " (library default)": " (ბიბლიოთეკის ნაგულისხმევი)", + " (inverted)": " (ინვერსიული)", + " · best of its burst": " · საუკეთესო თავის სერიაში", + " · in a burst": " · სერიაში", + "\"Albums\" header takes it back to the top level; Esc cancels; holding a dragged album or photos near the top or bottom edge of the sidebar scrolls it). Drop on the top or bottom half of another album,": "„ალბომების“ სათაური მას ზედა დონეზე აბრუნებს; Esc აუქმებს; გადათრეული ალბომის ან ფოტოების გვერდითი ზოლის ზედა ან ქვედა კიდესთან დაჭერა მას აგორებს). ჩააგდეთ სხვა ალბომის ზედა ან ქვედა ნახევარზე,", + "\"File exists\".": "„File exists“.", + "\"HEIC/HEIF support isn't included in this build\" for each `.heic`/`.heif` at import.": "იმპორტისას ყოველ `.heic`/`.heif`-ზე იტყობინება: „HEIC/HEIF-ის მხარდაჭერა ამ აწყობაში არ შედის“.", + "\"Rendering…\" on the photo": "„რენდერდება…“ ფოტოზე", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„მეხსიერებაში შენახულია, მაგრამ დისკზე არ ჩაწერილა“, ზედა ზოლი აჩვენებს გაფრთხილებას და LightCraft ცდილობს ხელახლა, სანამ", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): DNG მოწმდება RAW მონაცემებთან, იწერება დროებით ფაილში, სინქრონიზდება და ხელახლა იკითხება, სანამ მიიღებს", + "% of all commits": "ყველა კომიტის %", + "& Containing": "და შემცველი", + "(\"Failed to associate portal window with parent window\" in the portal's log): tiling compositors put it beside the": "(„Failed to associate portal window with parent window“ პორტალის ჟურნალში): ფილისებრი კომპოზიტორები მას", + "(Esc keeps the old angle; a decimal comma works too), or click the Straighten value; hovering any slider's value now says it can be typed.": "(Esc ძველ კუთხეს ტოვებს; ათწილადი მძიმეც მუშაობს), ან დაწკაპეთ გასწორების მნიშვნელობაზე; ნებისმიერი სლაიდერის მნიშვნელობაზე მიტანისას ახლა ჩანს, რომ მისი აკრეფა შეიძლება.", + "(Space, G, X, ⌫) and ⌘ shortcuts were not affected.": "(Space, G, X, ⌫) და ⌘ მალსახმობებზე ეს გავლენას არ ახდენდა.", + "(`album.setRules` takes `name`).": "(`album.setRules` იღებს `name`-ს).", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), დახმარება → სისტემის ინფორმაცია და პარამეტრები → წარმადობა განმარტავს, რატომ არ არის GPU", + "(`id`, `parent?`, `before?`) and `album.sort`.": "(`id`, `parent?`, `before?`) და `album.sort`-ს.", + "(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a": "(`render in.raw -o out.jxl`). ხარისხი — როგორც JPEG-ისთვის, ან უდანაკარგო; ძალისხმევა (სწრაფი / ჩვეულებრივი) დროს ცვლის", + "(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.": "(შემოწმებულია ერთ CC0 iPhone 12 Pro ProRAW-ზე). მატების გარეშე ფოტოები უცვლელია.", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(მაგ., D750, D780, D850, D7500, Z 50) აღარ გამოისახება თითქმის შავად ან ჩახშობილი ჩრდილებით: მათი შავის დონე", + "(folders open on click) or finds them as you type part of a name or folder (↑ / ↓ and Enter to pick); albums that": "(საქაღალდეები დაწკაპუნებით იხსნება), ან პოულობს მათ, როცა სახელის ან საქაღალდის ნაწილს აკრეფთ (↑ / ↓ და Enter ასარჩევად); ალბომები, რომელთა", + "(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile": "(ხარვეზი #256): ფაილში კამერის მიერ ჩაწერილი კორექცია გამოიყენება ობიექტივის კორექციები → პროფილის", + "(issue #706; before, the import skipped it as \"already in the library\" while the grid showed it deleted). The": "(ხარვეზი #706; ადრე იმპორტი მას გამოტოვებდა როგორც „უკვე ბიბლიოთეკაშია“, ბადე კი წაშლილად აჩვენებდა).", + "(issue #735).": "(ხარვეზი #735).", + "(issue #782).": "(ხარვეზი #782).", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(ხარვეზი #94): ვებგვერდიდან გაგზავნილ HTTP მოთხოვნას ტანში ბრძანების გადაცემა აღარ შეუძლია. სტრიქონები შეზღუდულია", + "(issues #187, #298). Importing files that are in Recently Deleted asks whether to leave them there, restore them": "(ხარვეზები #187, #298). ახლახან წაშლილში მყოფი ფაილების იმპორტისას კითხულობს, დარჩეს ისინი იქ, აღდგეს", + "(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports": "(macOS, Windows, Linux, FreeBSD, Nix) ახლა მართლაც HEIC-ის მხარდაჭერით არის აწყობილი; მის გარეშე აწყობა", + "(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.": "(მათი ცვლილებებით) თუ ახლად იმპორტირდეს; ნაგვის ხედის ფოტოს მენიუს აქვს ახლახან წაშლილის გასუფთავება.", + "(~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms.": "(~0.6 წმ სლაიდერის თითო ბიჯზე); ახლა სია მხოლოდ ცვლილების შემდეგ ინახება და სლაიდერის ბიჯი ~2 მწმ-ს იკავებს.", + ")": ")", + "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-ის კატალოგის იმპორტი…** ამ კატალოგით, ან გაუქმებას. `lightcraft-cli --library` ასეთ საქაღალდეებზეც უარს ამბობს.", + "+ Group": "+ ჯგუფი", + "+ Rule": "+ წესი", + ", picked": ", რჩეული", + ", rejected": ", უარყოფილი", + "0: nothing is rejected.": "0: არაფერი უარიყოფა.", + "1 day": "1 დღე", + "1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so": "1200D (Rebel T5) და 100D (Rebel SL1) კამერების CR2 ფაილებში ახლა მოკვეთილად ითვლება (ხარვეზი #1272). ადრე ისინი თეთრის 81–98%-ზე ჩერდებოდა, ამიტომ", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 studio-mac-ზე)“) იმის ნაცვლად, რომ ორივემ ჩაწეროს და შეუმჩნევლად წაშალოს ერთმანეთის ცვლილებები. ავარია არასოდეს ტოვებს", + "12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off": "12–32 მმ კიტ-ზუმი ადრე კუთხეებში გამოსახულების სიგანის დაახლოებით 5 %-ით სცდებოდა. კორექციის გამორთვით გადაღებული ფაილები", + "16-bit Rec. 2020 PQ. Not in the web version yet.": "16-ბიტიანი Rec. 2020 PQ. ვებ-ვერსიაში ჯერ არ არის.", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 წინასწარი ხედი და იმპორტირდება მეტამონაცემებით: გადაღების დრო, კამერა, ობიექტივი, ექსპოზიცია, GPS და XMP. მათი RAW", + "2026-04 or 2026-04-12": "2026-04 ან 2026-04-12", + "2026-10-02": "2026-10-02", + "2026-10-05": "2026-10-05", + "2026-10-07": "2026-10-07", + "2026-10-08": "2026-10-08", + "2026-10-09": "2026-10-09", + "2026-10-10": "2026-10-10", + "2026-10-11": "2026-10-11", + "2026-10-12": "2026-10-12", + "2026-10-13": "2026-10-13", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 ახალი ჩაშენებული პრესეტი: პორტრეტი, პეიზაჟი, ქალაქი, საკვები, სეზონები, ვინტაჟი და შავ-თეთრი ტონირება.", + "3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative": "3396 K-ს, „ჩრდილის“ პრესეტით გადაღებული — 7493 K-ს. როგორც #730-ში, იცვლება მხოლოდ რიცხვები: ძველ ფარდობით", + "4 MiB and connections at 16.": "4 MiB-ით, ხოლო კავშირები — 16-ით.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630 დრაივერები): Windows-ზე ფანჯარა და GPU რენდერირება მხოლოდ DirectX 12-ს იყენებს და Vulkan-ის დრაივერს არ ტვირთავს, თუ", + "= exactly": "= ზუსტად", + "A Folders section in the sidebar lists the folders your photos were imported from; choose one to see its photos.": "გვერდით ზოლში საქაღალდეების სექცია ჩამოთვლის საქაღალდეებს, საიდანაც ფოტოები იმპორტირდა; აირჩიეთ ერთი მისი ფოტოების სანახავად.", + "A blank template keeps the original names.": "ცარიელი შაბლონი ორიგინალ სახელებს ინარჩუნებს.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "ცვლილება, რომლის დისკზე შენახვაც ვერ ხერხდება (სავსე ან გამორთული დისკი), აღარ რჩება შეუმჩნეველი: ბრძანება იტყობინება", + "A dialog taller than the window no longer runs off it (issue #781): on a 1366×768 screen the Export dialog's title": "ფანჯარაზე მაღალი დიალოგი აღარ გადის მის საზღვრებს გარეთ (ხარვეზი #781): 1366×768 ეკრანზე ექსპორტის დიალოგის სათაური", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "ფილტრი მალავს ამ ხედის ფოტოებს: შეცვალეთ ან გაასუფთავეთ იგი (ხედი → ფილტრების გასუფთავება)", + "A folder is already being synchronized": "საქაღალდე უკვე სინქრონიზდება", + "A folder is being synchronized": "საქაღალდე სინქრონიზდება", + "A keyword set needs a name": "საკვანძო სიტყვების ნაკრებს სახელი სჭირდება", + "A label follows its folder when it is renamed or moved in LightCraft, together with the labels of the folders": "იარლიყი თავის საქაღალდეს მიჰყვება, როცა ის LightCraft-ში გადაერქმევა ან გადაიტანება, მასში მყოფი საქაღალდეების", + "A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB": "ბიბლიოთეკა ათასობით პრესეტით (მთლიანად იმპორტირებული Lightroom-ის კოლექცია: 11 000 პრესეტი, 42 MB-იანი", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "ბიბლიოთეკა ერთდროულად მხოლოდ ერთ პროგრამაშია გახსნილი (ხარვეზი #99): ბიბლიოთეკის გახსნა, რომელიც LightCraft-ს ან `lightcraft-cli`-ს უკვე", + "A one-time download of about": "ერთჯერადი ჩამოტვირთვა, დაახლოებით", + "A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails": "ფოტო, რომელიც რენდერისას LightCraft-ის ხარვეზს აწყდება (RAW დეკოდერში ან რედაქტირების კონვეიერში), ახლა ისე ვერ იხსნება,", + "A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW": "პრესეტი, რომლის ობიექტივის პროფილის გადამრთველი გამორთულია, აღარ თიშავს DNG-ში ჩაშენებულ ობიექტივის კორექციებს (iPhone ProRAW", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "შენახვა, რომელიც შუა გზაზე წყდება (სავსე დისკი, გათიშული ქსელური საქაღალდე), აღარ ჰგავს დაზიანებულ კატალოგს", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "მძინარე NAS, გათიშული ქსელური საქაღალდე ან ამუშავების პროცესში მყოფი USB დისკი ფანჯარას აღარ ყინავს (ხარვეზი #104):", + "A smart album can include or exclude another smart album: \"Keywords contain travel\" and \"Album isn't Excluded Photos\"": "სმარტ-ალბომს შეუძლია სხვა სმარტ-ალბომის ჩართვა ან გამორიცხვა: „საკვანძო სიტყვები შეიცავს travel“ და „ალბომი არ არის Excluded Photos“", + "A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip": "სმარტ-ალბომი, რომლის წესებიც აღარ მოწმდება (მათ მიერ შემოწმებული ალბომი წაიშალა), გვერდით ზოლში ⚠-ით აღინიშნება; მისი მინიშნება", + "A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file": "დეტალურ ხედში გახსნილი ფოტოს ესკიზი მისი უკვე დეკოდირებული წინასწარი ხედიდან კეთდება, ნაცვლად იმისა, რომ ფაილი", + "A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no": "გადიდებული ხედი ორიგინალის GPU ასლს ინარჩუნებს, სანამ ის ეკრანზეა: გადაადგილებებს შორის რამდენიმეწამიანი პაუზა", + "AI Denoise": "AI ხმაურის მოცილება", + "AI Denoise Model": "AI ხმაურის მოცილების მოდელი", + "AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working": "AI ხმაურის მოცილება და დაკარგული ფოტოების მოძებნა. თითოეულ სტრიქონს აქვს ამოცანის სახელი, პროგრესის ზოლი, რაოდენობა და ის, რაზეც", + "AI Denoise is enabled on the requesting photo": "AI ხმაურის მოცილება ჩართულია მომთხოვნ ფოტოზე", + "AI Denoise is off: press Use on a model.": "AI ხმაურის მოცილება გამორთულია: მოდელზე დააჭირეთ „გამოყენებას“.", + "AI Denoise needs a model. Install one in Settings.": "AI ხმაურის მოცილებას მოდელი სჭირდება. დააინსტალირეთ პარამეტრებში.", + "AI Mask Model Status": "AI ნიღბების მოდელის მდგომარეობა", + "AI RAW denoise": "RAW-ის AI ხმაურის მოცილება", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "AI ნიღბები SAM 3-ით (ობიექტი და აღწერა ნიღბვის პანელში): დაწკაპეთ ობიექტზე მის მოსანიშნად (⌥-დაწკაპუნება ტოვებს", + "ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come": "ARW) ფოტოების გაერთიანება გაერთიანებულ DNG-ს აძლევს იმ ფერს, რომელიც თითო ფოტოს ცალკე გახსნისას აქვს (ხარვეზი #965). CR2-ების HDR გაერთიანება ადრე", + "About": "პროგრამის შესახებ", + "About LightCraft": "LightCraft-ის შესახებ", + "Accept & Download": "მიღება და ჩამოტვირთვა", + "Active": "აქტიური", + "Activity": "აქტივობა", + "Activity stack": "აქტივობების დასტა", + "Actual size": "რეალური ზომა", + "Add": "დამატება", + "Add Catchlight": "თვალის ანარეკლის დამატება", + "Add Face Model…": "სახეების მოდელის დამატება…", + "Add Folder to Local": "საქაღალდის ლოკალურში დამატება", + "Add Mask": "ნიღბის დამატება", + "Add Object Click": "ობიექტის დაწკაპუნების დამატება", + "Add Point Color Sample": "წერტილოვანი ფერის ნიმუშის დამატება", + "Add Red Eye Correction": "წითელი თვალების კორექციის დამატება", + "Add Remove Spot": "მოშორების ლაქის დამატება", + "Add Selected Photos": "მონიშნული ფოტოების დამატება", + "Add a Face Recognition Model": "სახის ამოცნობის მოდელის დამატება", + "Add a rule (⌥-click: a group)": "წესის დამატება (⌥-დაწკაპუნება: ჯგუფი)", + "Add in place": "ადგილზე დამატება", + "Add in place: the library references the files where they are; nothing is copied or moved.": "ადგილზე დამატება: ბიბლიოთეკა ფაილებს მათ ამჟამინდელ ადგილას მიმართავს; არაფერი კოპირდება ან გადაიტანება.", + "Add keyword": "საკვანძო სიტყვის დამატება", + "Add number": "რიცხვის დამატება", + "Add to": "დამატება:", + "Add to Album": "ალბომში დამატება", + "Add to All Selected Photos": "ყველა მონიშნულ ფოტოზე დამატება", + "Add to Apple Photos": "Apple Photos-ში დამატება", + "Add to Apple Photos (macOS)": "Apple Photos-ში დამატება (macOS)", + "Add to Favorites": "რჩეულებში დამატება", + "Add to Local": "ლოკალურში დამატება", + "Add to My Photos": "ჩემს ფოტოებში დამატება", + "Add to My Photos First": "ჯერ ჩემს ფოტოებში დამატება", + "Add to Selected Photos": "მონიშნულ ფოტოებზე დამატება", + "Add to Target Album": "სამიზნე ალბომში დამატება", + "Add to favorites": "რჩეულებში დამატება", + "Add to this mask": "ამ ნიღაბზე დამატება", + "Add/Subtract/Intersect Mask": "ნიღბის დამატება/გამოკლება/გადაკვეთა", + "Added to photos you import that don't already have it.": "ემატება იმპორტირებულ ფოტოებს, რომლებსაც ის ჯერ არ აქვთ.", + "Adding": "დამატება", + "Adding Lightroom photos…": "Lightroom-ის ფოტოების დამატება…", + "Adding photos…": "ფოტოების დამატება…", + "Adding to Apple Photos": "Apple Photos-ში დამატება", + "Adding to Apple Photos…": "Apple Photos-ში დამატება…", + "Advanced search…": "გაფართოებული ძიება…", + "After": "შემდეგ", + "After 30 days": "30 დღის შემდეგ", + "After 7 days": "7 დღის შემდეგ", + "After 90 days": "90 დღის შემდეგ", + "After a year": "ერთი წლის შემდეგ", + "After installation, AI Denoise will turn on for the requesting photo.": "დაინსტალირების შემდეგ AI ხმაურის მოცილება მომთხოვნ ფოტოზე ჩაირთვება.", + "Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`.": "აგენტები ამოცანებს ჩამოთვლიან და აჩერებენ `activity.list`-ით და `activity.cancel`-ით; `ui.inspect` მათ `activity`-ად აბრუნებს.", + "Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`.": "აგენტები იყენებენ `app.newLibrary {path, name}`, `app.recentLibraries` და `app.clearRecentLibraries`.", + "Agents: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` for files already on disk, and": "აგენტები: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` დისკზე უკვე არსებული ფაილებისთვის, და", + "Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.": "აგენტები: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` იღებს `sidebar`-ს.", + "Album": "ალბომი", + "Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.": "ალბომების საქაღალდეები იკეცება იმავე სამკუთხედით, როგორც ლოკალური, თარიღით, საქაღალდეები და საკვანძო სიტყვები.", + "Album rule is \"is\" or \"isn't\" an album, picked with the album picker.": "ალბომის წესი არის „არის“ ან „არ არის“ ალბომი, რომელიც ალბომის ამრჩევით აირჩევა.", + "Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for": "გადარქმევის, ახალი სმარტ-ალბომის, საკვანძო სიტყვის გადარქმევის, საკვანძო სიტყვების გაერთიანების და მსგავსი დიალოგების სახელის ველზე, ან უსახელო სახეების ქვეშ სახელის ველზე", + "Albums": "ალბომები", + "Albums hold library photos: Add to My Photos first": "ალბომებში ბიბლიოთეკის ფოტოებია: ჯერ დაამატეთ ჩემს ფოტოებში", + "Albums tree": "ალბომების ხე", + "All": "ყველა", + "All Metadata": "ყველა მეტამონაცემი", + "All Metadata…": "ყველა მეტამონაცემი…", + "All Photos": "ყველა ფოტო", + "All rights reserved": "ყველა უფლება დაცულია", + "All the photos imported from this disk are included.": "ამ დისკიდან იმპორტირებული ყველა ფოტო შედის.", + "All types": "ყველა ტიპი", + "Already installed. Remove it before installing a replacement.": "უკვე დაინსტალირებულია. ჩანაცვლების დაინსტალირებამდე წაშალეთ.", + "Alt Text": "ალტერნატიული ტექსტი", + "Always": "ყოველთვის", + "Amount": "სიძლიერე", + "An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports": "შეუკუმშავი Leaf Aptus 22 MOS აღარ იხსნება მისი სენსორის მოზაიკის მუქ, უფერო სურათად: ის იმპორტდება", + "Analyzing the photo for AI masks…": "ფოტოს ანალიზი AI ნიღბებისთვის…", + "Angle": "კუთხე", + "Any Searchable Text": "ნებისმიერი საძიებო ტექსტი", + "Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.": "ნებისმიერი საძიებო ტექსტი ასევე პოულობს შტატს / პროვინციას, ალტერნატიულ ტექსტს, ადამიანებსა და ვირტუალური ასლის სახელს.", + "Any camera": "ნებისმიერი კამერა", + "Any keyword": "ნებისმიერი საკვანძო სიტყვა", + "Any lens": "ნებისმიერი ობიექტივი", + "Aperture": "დიაფრაგმა", + "Appearance": "იერსახე", + "Appearance Mode": "იერსახის რეჟიმი", + "Appearance Settings…": "იერსახის პარამეტრები…", + "Appearance lists the modes and themes. On Linux, Auto follows the desktop portal's colour scheme": "იერსახე კი რეჟიმებსა და თემებს ჩამოთვლის. Linux-ზე ავტომატური მიჰყვება დესკტოპის პორტალის ფერთა სქემას", + "Apple Photos Imports": "Apple Photos-ში დამატებები", + "Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is": "Apple ProRAW-ის მოგების ცხრილის რუკა (მისი ლოკალური ტონალური ასახვა, `ProfileGainTableMap`) ახლა იკითხება და ინახება, როცა ფოტო", + "Application": "აპლიკაცია", + "Apply": "გამოყენება", + "Apply Color Label Set": "ფერადი იარლიყების ნაკრების გამოყენება", + "Apply Filter Preset": "ფილტრის პრესეტის გამოყენება", + "Apply Metadata Preset": "მეტამონაცემების პრესეტის გამოყენება", + "Apply Point Curve Preset": "წერტილოვანი მრუდის პრესეტის გამოყენება", + "Apply Preset": "პრესეტის გამოყენება", + "Apply Settings JSON": "JSON პარამეტრების გამოყენება", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "RAW-ში შენახული ლოკალური ტონალური ასახვის გამოყენება (Apple ProRAW), როგორც კამერა ასახავს", + "Approved": "დამტკიცებულია", + "Aqua": "ზღვისფერი", + "Aqua Hue": "ზღვისფერის ფერის ტონი", + "Aqua Luminance": "ზღვისფერის სიკაშკაშე", + "Aqua Saturation": "ზღვისფერის გაჯერება", + "Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text": "არაბული სახელები და სხვა არაბული ტექსტი სათაურებში, არჩეულ ჩანართებსა და სხვა მუქ ადგილებში ახლა მუქად იხატება, როგორც მათ გვერდით", + "Arrange Panel": "პანელის დალაგება", + "Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line": "დაალაგეთ მარცხენა გვერდითი ზოლი თქვენებურად: გადაათრიეთ ალბომები, ლოკალური, თარიღის მიხედვით, საქაღალდეები ან საკვანძო სიტყვები სათაურით (ხაზი", + "ArtCraft Website": "ArtCraft-ის ვებსაიტი", + "ArtCraft — more creative apps": "ArtCraft — სხვა შემოქმედებითი აპლიკაციები", + "As Shot": "როგორც გადაღებულია", + "As shot": "როგორც გადაღებულია", + "Ascending": "ზრდადობით", + "Aspect": "თანაფარდობა", + "Aspect Ratio": "თანაფარდობა", + "Aspect Ratio (a 24 MP photo cropped to a square is 16 MP).": "თანაფარდობა (24 MP ფოტო, კვადრატად ჩამოჭრილი, 16 MP-ია).", + "Assisted Culling": "დამხმარე გადარჩევა", + "Assisted Culling…": "დამხმარე გადარჩევა…", + "Auto": "ავტომატური", + "Auto Advance": "ავტომატური გადასვლა", + "Auto Advance Off": "ავტომატური გადასვლის გამორთვა", + "Auto Advance On": "ავტომატური გადასვლის ჩართვა", + "Auto Advance after rating or flagging": "ავტომატური გადასვლა შეფასების ან დროშის დასმის შემდეგ", + "Auto Advance: move to the next photo after rating or flagging": "ავტომატური გადასვლა: შეფასების ან დროშის დასმის შემდეგ შემდეგ ფოტოზე გადასვლა", + "Auto Align": "ავტოსწორება", + "Auto B&W Mix": "ავტომატური შავ-თეთრი მიქსი", + "Auto Crop": "ავტომატური ჩამოჭრა", + "Auto Import": "ავტოიმპორტი", + "Auto Import Now": "ავტოიმპორტი ახლავე", + "Auto Import Settings": "ავტოიმპორტის პარამეტრები", + "Auto Mask": "ავტონიღაბი", + "Auto Select": "ავტომატური მონიშვნა", + "Auto Settings": "ავტომატური პარამეტრები", + "Auto Straighten": "ავტომატური გასწორება", + "Auto Sync": "ავტოსინქრონიზაცია", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "ავტოსინქრონიზაცია: ცვლილებები ყველა მონიშნულ ფოტოზე ვრცელდება. ავტომატური შავ-თეთრი მიქსი. ავტომატური ვერსიები.", + "Auto settings applied": "ავტომატური პარამეტრები გამოყენებულია", + "Auto use the same model. Custom white balances on these photos render slightly differently than before.": "ავტომატური იმავე მოდელს იყენებს. ამ ფოტოებზე თეთრის მორგებული ბალანსი ადრინდელისგან ოდნავ განსხვავებულად რენდერდება.", + "Auto-Stack by Capture Time": "ავტომატური დასტებად დაჯგუფება გადაღების დროით", + "Auto-Stack by Capture Time…": "ავტომატური დასტებად დაჯგუფება გადაღების დროით…", + "Auto-Tag Photos from Tracklog": "ფოტოებისთვის მდებარეობის მინიჭება მარშრუტის ჟურნალიდან", + "Auto-Tag from Tracklog": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან: GPS მდებარეობები თქვენი ფოტოებისთვის GPX მარშრუტის ჟურნალიდან, გადაღების დროის მიხედვით.", + "Auto-Tag from Tracklog…": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან…", + "Automatic": "ავტომატური", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "ავტომატური რეჟიმი დეტალურ ხედს ასახავს იმ ზომით, რომლითაც ის ჩანს, ფოტოს საკუთარ პიქსელებამდე. ზღვრისთვის აირჩიეთ ზომა: პატარა უფრო სწრაფია.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "ავტომატური რეჟიმი იყენებს ვიდეობარათს, როცა ის პროცესორზე სწრაფია: ორივეს დრო ამ კომპიუტერზე იზომება, როცა ფოტო პირველად მზადდება.", + "Automatically Write Changes into XMP": "ცვლილებების XMP-ში ავტომატურად ჩაწერა", + "Automatically write changes into XMP sidecars": "ცვლილებების XMP თანმხლებ ფაილებში ავტომატურად ჩაწერა", + "Autumn Gold": "შემოდგომის ოქრო", + "B&W": "შავ-თეთრი", + "B&W Mix": "შავ-თეთრი მიქსი", + "B&W Mixer": "შავ-თეთრის მიქშერი", + "Back": "უკან", + "Back Up Library…": "ბიბლიოთეკის სარეზერვო ასლი…", + "Back to Edit": "რედაქტირებაზე დაბრუნება", + "Back to Edit (E)": "რედაქტირებაზე დაბრუნება (E)", + "Back to Grid": "ბადეზე დაბრუნება", + "Back to this person": "ამ ადამიანთან დაბრუნება", + "Background": "ფონი", + "Background Tasks": "ფონური ამოცანები", + "Balance": "ბალანსი", + "Basic": "საბაზო", + "Before": "მანამდე", + "Before/After (Y)": "მანამდე/შემდეგ (Y)", + "Before/After Settings": "მანამდე/შემდეგ პარამეტრები", + "Before/After Split": "მანამდე/შემდეგ, გაყოფილი", + "Before/After Split Top/Bottom": "მანამდე/შემდეგ, გაყოფილი ზემოთ/ქვემოთ", + "Before/After Top/Bottom": "მანამდე/შემდეგ ზემოთ/ქვემოთ", + "Begin Interaction": "ურთიერთქმედების დაწყება", + "Best of Similar Shots": "მსგავსი კადრებიდან საუკეთესო", + "Binary assets added": "დამატებული ბინარული ფაილები", + "Binary assets removed": "წაშლილი ბინარული ფაილები", + "Bit depth": "ბიტური სიღრმე", + "Black & White": "შავ-თეთრი", + "Blacks": "შავები", + "Bleach Bypass": "გაუთეთრებელი დამუშავება", + "Bleached": "გაუფერულებული", + "Blending": "შერევა", + "Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),": "გადაჭარბებით განათებული ღია ტონები Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),", + "Blue": "ლურჯი", + "Blue Hour Boost": "ლურჯი საათის გაძლიერება", + "Blue Hue": "ლურჯის ფერის ტონი", + "Blue Luminance": "ლურჯის სიკაშკაშე", + "Blue Primary": "ლურჯი ძირითადი", + "Blue Saturation": "ლურჯის გაჯერება", + "Blue channel": "ლურჯი არხი", + "Blue label": "ლურჯი იარლიყი", + "Blue/Yellow Fringe": "ლურჯი/ყვითელი შარავანდი", + "Blurry photos below the score are flagged as rejects.": "ზღვარზე დაბალი შეფასების ბუნდოვან ფოტოებს უარყოფილის დროშა ედება.", + "Bold text in other scripts": "მუქი ტექსტი სხვა დამწერლობებში", + "Boundary Warp": "საზღვრების დეფორმაცია", + "Bridge and Photo Supreme (its Formatted Vocabulary File) use the same files, so keyword lists move between them and": "Bridge და Photo Supreme (მისი Formatted Vocabulary File) იმავე ფაილებს იყენებს, ასე რომ საკვანძო სიტყვების სიები მათსა და", + "Bright & Airy": "ნათელი და ჰაეროვანი", + "Bring All to Front": "ყველას წინა პლანზე გადატანა", + "Bring the library up to date with this folder and the folders inside it:": "ბიბლიოთეკის განახლება ამ საქაღალდისა და მასში არსებული საქაღალდეების მიხედვით:", + "Browse Folder": "საქაღალდის დათვალიერება", + "Browse Folder…": "საქაღალდის დათვალიერება…", + "Browse Profiles": "პროფილების დათვალიერება", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "ბრაუზერის ვერსია (ექსპერიმენტული), ბიბლიოთეკის დაცვა (ხარვეზი #107): ფაილი → ბიბლიოთეკის სარეზერვო ასლი… ჩამოტვირთავს კატალოგს", + "Browse…": "არჩევა…", + "Brush": "ფუნჯი", + "Brush Settings": "ფუნჯის პარამეტრები", + "Brush Stroke": "ფუნჯის მონასმი", + "Build Previews": "წინასწარი ხედების შექმნა", + "Build Smart Previews": "ჭკვიანი წინასწარი ხედების შექმნა", + "Building previews": "წინასწარი ხედების შექმნა", + "Building smart previews": "ჭკვიანი წინასწარი ხედების შექმნა", + "By Date": "თარიღით", + "By day (YYYY/YYYY-MM-DD)": "დღის მიხედვით (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "თვის მიხედვით (YYYY/YYYY-MM)", + "C-RAWs imported earlier, Photo ▸ Reload from Disk picks up the raw data.": "R7 C-RAW-ებისთვის ფოტო → დისკიდან ხელახლა ჩატვირთვა RAW მონაცემებს წაიკითხავს.", + "CPU threads": "CPU ნაკადები", + "Cache": "ქეში", + "Calibration": "კალიბრაცია", + "Camera": "კამერა", + "Camera Info": "კამერის ინფორმაცია", + "Camera local tone mapping": "კამერის ლოკალური ტონალური ასახვა", + "Camera make and model": "კამერის მწარმოებელი და მოდელი", + "Camera-look deep shadows": "ღრმა ჩრდილები კამერის იერში", + "Cameras and Cards": "კამერები და ბარათები", + "Cancel": "გაუქმება", + "Cancel AI Mask Model Download": "AI ნიღბების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Background Task": "ფონური ამოცანის გაუქმება", + "Cancel Denoise": "ხმაურის მოცილების გაუქმება", + "Cancel Denoise Model Download": "ხმაურის მოცილების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Download": "ჩამოტვირთვის გაუქმება", + "Cancel Face Model Download": "სახეების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Interaction": "ურთიერთქმედების გაუქმება", + "Cancel Pending Photo Action": "ფოტოს მოლოდინში მყოფი მოქმედების გაუქმება", + "Cancel Preview Build": "წინასწარი ხედების შექმნის გაუქმება", + "Cancel pending action": "მოლოდინში მყოფი მოქმედების გაუქმება", + "Candidate": "კანდიდატი", + "Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF": "Canon CR2 და Pentax PEF RAW-ები კამერის საკუთარ JPEG-ზე მორგებულ იმავე საწყის იერს იღებს, როგორც ARW, NEF, RW2, RAF", + "Canon CR2 highlights": "Canon CR2-ის ღია ტონები", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Canon CR2 ფოტოები EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II და 1D Mark IV კამერებიდან (და სხვა", + "Canon CR2 raws no longer open too dark with blocked-up shadows: the black level was measured over border columns": "Canon CR2 RAW-ები აღარ იხსნება ზედმეტად მუქად ჩაკეტილი ჩრდილებით: შავის დონე იზომებოდა კიდის სვეტებზე,", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Canon CR3 ფაილები აჩვენებს სრული ზომის ჩაშენებულ JPEG-ს (მაგ., 6960 × 4640 EOS R6 Mark III-ზე) ნაცვლად", + "Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS": "Canon CR3 RAW-ები ახლა სენსორის მონაცემებიდან მუშავდება: უდანაკარგო RAW და C-RAW, ნიმუშ-ნიმუშ შემოწმებული EOS", + "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS and Epson ERF files that LightCraft can't decode yet": "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS და Epson ERF ფაილები, რომელთა დეკოდირება LightCraft-ს ჯერ არ შეუძლია,", + "Canon EOS R5 Mark II": "Canon EOS R5 Mark II", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Canon EOS R5 Mark II C-RAW ფაილები, რომლებიც „მხოლოდ წინასწარი ნახვის“ რეჟიმში იხსნებოდა (ნახევარზე მეტი,", + "Canon EOS R7 C-RAW files develop from their sensor data too, instead of opening from their embedded JPEG. For R7": "Canon EOS R7-ის C-RAW ფაილებიც სენსორის მონაცემებიდან მუშავდება, ჩაშენებული JPEG-იდან გახსნის ნაცვლად. ადრე იმპორტირებული", + "Canon PowerShot G9 raws open with the camera's white balance (White Balance ▸ As Shot); it used to start from a": "Canon PowerShot G9-ის RAW ფაილები კამერის თეთრის ბალანსით იხსნება (თეთრის ბალანსი → როგორც გადაღებულია); ადრე ისინი", + "Caption": "წარწერა", + "Capture Date": "გადაღების თარიღი", + "Capture date and time in your own format (directives below)": "გადაღების თარიღი და დრო თქვენივე ფორმატით (დირექტივები ქვემოთ)", + "Capture date as YYYYMMDD": "გადაღების თარიღი YYYYMMDD ფორმატით", + "Captured": "გადაღებულია", + "Captured from": "გადაღებულია დან", + "Captured until": "გადაღებულია მდე", + "Catalog Statistics": "კატალოგის სტატისტიკა", + "Catchlight": "თვალის ანარეკლი", + "Change": "შეცვლა", + "Change Component Mode": "კომპონენტის რეჟიმის შეცვლა", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ფაილის სახელებში დაუშვებელი სიმბოლოები (/ \\ : * ? \" < > |) იცვლება -ით; არსებულ სახელს ემატება -1, -2….", + "Charcoal": "ნახშირი", + "Check All": "ყველას შემოწმება", + "Check Output Path": "გამოსატანი მისამართის შემოწმება", + "Check the files of this type": "ამ ტიპის ფაილების შემოწმება", + "Checking and installing the model…": "მოდელის შემოწმება და დაინსტალირება…", + "Checking names…": "სახელების შემოწმება…", + "Checking…": "შემოწმება…", + "Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.": "ჩინურის (გამარტივებული და ტრადიციული), იაპონურის და ბრაზილიური პორტუგალიურის გვერდით.", + "Choice values read as words in every language: \"Public Domain\", \"Picked\", \"Black & White\" rather than ids like": "არჩევანის მნიშვნელობები ყველა ენაზე სიტყვებად იკითხება: „საზოგადოებრივი საკუთრება“, „რჩეული“, „შავ-თეთრი“ და არა იდენტიფიკატორები, როგორიცაა", + "Choose Another Library…": "სხვა ბიბლიოთეკის არჩევა…", + "Choose Denoise Model": "ხმაურის მოცილების მოდელის არჩევა", + "Choose Edit Settings to Copy": "დასაკოპირებელი რედაქტირების პარამეტრების არჩევა", + "Choose Edit Settings to Copy…": "დასაკოპირებელი რედაქტირების პარამეტრების არჩევა…", + "Choose Face Recognition Model": "სახის ამოცნობის მოდელის არჩევა", + "Choose Folder…": "საქაღალდის არჩევა…", + "Choose Monitor Profile": "მონიტორის პროფილის არჩევა", + "Choose Profile…": "პროფილის არჩევა…", + "Choose a model below (Use) to start.": "დასაწყებად ქვემოთ აირჩიეთ მოდელი („გამოყენება“).", + "Choose a photo in the grid or filmstrip": "აირჩიეთ ფოტო ბადეში ან კადრების ზოლში", + "Choose an album…": "აირჩიეთ ალბომი…", + "Choose…": "არჩევა…", + "Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /": "Lightroom Classic-ის კატალოგის საქაღალდის არჩევა მენიუში ფაილი → ბიბლიოთეკის გახსნა… (ან მისი `Previews.lrdata` /", + "Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts": "თარიღის არჩევა „თარიღით“ განყოფილებაში ან საკვანძო სიტყვისა „საკვანძო სიტყვებში“ ამ ფოტოებს „ყველა ფოტოდან“ აჩვენებს, როგორც მათი რაოდენობა", + "Cinematic": "კინემატოგრაფიული", + "City": "ქალაქი", + "Clarity": "სიცხადე", + "Clean Studio": "სუფთა სტუდია", + "Clear": "გასუფთავება", + "Clear Cache": "ქეშის გასუფთავება", + "Clear Denoise Cache": "ხმაურის მოცილების ქეშის გასუფთავება", + "Clear Filters": "ფილტრების გასუფთავება", + "Clear Guides": "დამხმარე ხაზების გასუფთავება", + "Clear History": "ისტორიის გასუფთავება", + "Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება", + "Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება", + "Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება", + "Clear Reference": "ეტალონის გასუფთავება", + "Clear all": "ყველას გასუფთავება", + "Clear cache": "ქეშის გასუფთავება", + "Clear form": "ფორმის გასუფთავება", + "Clear rating": "შეფასების მოხსნა", + "Clear rating and advance": "შეფასების მოხსნა და გადასვლა", + "Cleared History": "ისტორია გასუფთავებულია", + "Click Zoom Ratio": "დაწკაპუნების მასშტაბი", + "Click a shortcut, then press the new keys (Esc cancels).": "დაწკაპეთ მალსახმობზე, შემდეგ დააჭირეთ ახალ კლავიშებს (Esc აუქმებს).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "დაწკაპეთ ჭდეზე მის ჩასასმელად. მაგალითები ეხება IMG_0042.CR3-ს, გადაღებულს 2026 წლის 14 იანვარს, 05:58:48-ზე.", + "Click photos in the grid to toggle the keyword; Esc stops": "დაწკაპეთ ფოტოებზე ბადეში საკვანძო სიტყვის ჩასართავად/გამოსართავად; Esc აჩერებს", + "Click the object to select it · ⌥-click leaves a part out": "დაწკაპეთ ობიექტზე მის მოსანიშნად · ⌥-დაწკაპუნება ნაწილს გამორიცხავს", + "Click the photo to pick a colour · ⇧-click adds more": "დაწკაპეთ ფოტოზე ფერის ასარჩევად · ⇧-დაწკაპუნება მეტს ამატებს", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "დაწკაპეთ ფოტოზე ფერის ასარჩევად; ⇧-დაწკაპუნება მეტს ამატებს (5-მდე)", + "Click to clear these filters": "დაწკაპეთ ამ ფილტრების გასასუფთავებლად", + "Click to restore · double-click to rename": "დაწკაპეთ აღსადგენად · ორჯერ დაწკაპეთ სახელის გადასარქმევად", + "Click to type a value": "დაწკაპეთ მნიშვნელობის შესაყვანად", + "Clone": "კლონირება", + "Close": "დახურვა", + "Close Panel": "პანელის დახურვა", + "Close Window": "ფანჯრის დახურვა", + "Close all": "ყველას დახურვა", + "Close file": "ფაილის დახურვა", + "Close panel": "პანელის დახურვა", + "Closing a library releases its lock explicitly (issue #680). On Linux and macOS the lock used to go with the file": "ბიბლიოთეკის დახურვა მის ბლოკს ცხადად ათავისუფლებს (ხარვეზი #680). Linux-სა და macOS-ზე ბლოკი ადრე ფაილის", + "Cloudy": "მოღრუბლული", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, ფოტოს ინარჩუნებს და მის ცვლილებებს აუქმებს.)", + "Collapse": "ჩაკეცვა", + "Collapse All Stacks": "ყველა დასტის ჩაკეცვა", + "Collapse Stack": "დასტის ჩაკეცვა", + "Color": "ფერი", + "Color Grading": "ფერის გრადაცია", + "Color Label": "ფერადი იარლიყი", + "Color Label Names": "ფერადი იარლიყების სახელები", + "Color Label Sets": "ფერადი იარლიყების ნაკრებები", + "Color Mixer": "ფერების მიქშერი", + "Color Noise Reduction": "ფერის ხმაურის შემცირება", + "Color Priority": "ფერის პრიორიტეტი", + "Color Range": "ფერების დიაპაზონი", + "Color label cleared": "ფერადი იარლიყი მოხსნილია", + "Color space": "ფერთა სივრცე", + "Color space: sRGB (AVIF)": "ფერთა სივრცე: sRGB (AVIF)", + "Colors": "ფერები", + "Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and a translucent footer along the bottom of the photo in Photo Grid. Label confirmations use a matching pale colour.": "ფერადი იარლიყები კვადრატულ ბადესა და დეტალური ხედის კადრების ზოლში ესკიზების გარშემოს ღებავს, ფოტოების ბადეში კი ფოტოს ქვედა კიდეზე გამჭვირვალე ზოლს ქმნის. იარლიყის დადასტურებები შესაბამის ღია ფერს იყენებს.", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "ფერადი იარლიყების ფილტრი რამდენიმე იარლიყით ერთდროულად; ლოკალურში გაშლადი საქაღალდეების ხე.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "ფერების დიაპაზონის ნიღბები: ნიმუშისთვის დაწკაპეთ ფოტოზე. სიკაშკაშის დიაპაზონები: დიაპაზონის ზოლი, სიგლუვე, სიკაშკაშის რუკა.", + "Combine files…": "ფაილების გაერთიანება…", + "Command Journal": "ბრძანებების ჟურნალი", + "Comments panel": "კომენტარების პანელი", + "Commits": "კომიტები", + "Company": "კომპანია", + "Compare": "შედარება", + "Compare (Shift+C)": "შედარება (Shift+C)", + "Compare Before and After": "მანამდე და შემდეგ შედარება", + "Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ", + "Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება", + "Component options": "კომპონენტის პარამეტრები", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "შეკუმშული Nikon NEF-ები (უდანაკარგოდ და დანაკარგით შეკუმშული, 12- და 14-ბიტიანი — მაგ., D3200, D5100, D7000, D750, D850, Z 50)", + "Compression": "შეკუმშვა", + "Concrete Cool": "ცივი ბეტონი", + "Confirm before deleting": "წაშლამდე დადასტურება", + "Confirm before moving photos to Recently Deleted": "ფოტოების ახლახან წაშლილში გადატანამდე დადასტურება", + "Constrain Crop": "ჩამოჭრის შეზღუდვა", + "Contact Sheet PDF…": "საკონტაქტო ფურცელი PDF…", + "Content-Aware": "შიგთავსის გათვალისწინებით", + "Continue": "გაგრძელება", + "Continue Without Saving": "გაგრძელება შენახვის გარეშე", + "Contrast": "კონტრასტი", + "Contributors": "მონაწილეები", + "Convert to DNG": "DNG-ად გარდაქმნა", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "DNG-ად გარდაქმნა, DNG-ად კოპირება, ფოტოების გაერთიანება და ჭკვიანი წინასწარი ხედები აღარ წერს პირდაპირ საბოლოო ფაილში (ხარვეზი", + "Cool Fade": "ცივი მიქრობა", + "Cool Morning": "ცივი დილა", + "Copy": "კოპირება", + "Copy After's Settings to Before": "„შემდეგ“-ის პარამეტრების „მანამდე“-ში კოპირება", + "Copy Before to After": "„მანამდე“-ს „შემდეგ“-ში კოპირება", + "Copy Before's Settings to After": "„მანამდე“-ს პარამეტრების „შემდეგ“-ში კოპირება", + "Copy Edit Settings": "რედაქტირების პარამეტრების კოპირება", + "Copy History Step Settings to Before": "ისტორიის ნაბიჯის პარამეტრების „მანამდე“-ში კოპირება", + "Copy Metadata": "მეტამონაცემების კოპირება", + "Copy Name": "ასლის სახელი", + "Copy as DNG": "DNG-ად კოპირება", + "Copy into the library (else use the files where they are)": "ბიბლიოთეკაში კოპირება (თორემ ფაილები მათ ადგილას გამოიყენება)", + "Copy pages": "გვერდების კოპირება", + "Copy the files (into the library's Originals/, or a folder you choose)": "ფაილების კოპირება (ბიბლიოთეკის Originals/-ში ან თქვენ მიერ არჩეულ საქაღალდეში)", + "Copy to": "კოპირება:", + "Copy to Clipboard": "გაცვლის ბუფერში კოპირება", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "კოპირება: ფაილები კოპირდება ქვემოთ მითითებულ საქაღალდეში და ბიბლიოთეკა ასლებს იყენებს; ორიგინალები უცვლელი რჩება.", + "Copyright": "საავტორო უფლება", + "Copyright Info URL": "საავტორო უფლებების ინფორმაციის URL", + "Copyright Status": "საავტორო უფლებების სტატუსი", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "საავტორო უფლებების სტატუსი, უფლებების გამოყენების პირობები და საავტორო უფლებების ინფორმაციის URL ინფორმაციაში, მეტამონაცემების პრესეტებსა და ექსპორტში.", + "Copyrighted": "დაცულია საავტორო უფლებით", + "Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the": "კორექციების ჩართვის ქვეშ, ახლად იმპორტირებული ფოტოებისთვის ნაგულისხმევად ჩართული, კამერის JPEG-ის იმავე კადრირებით. 12 მმ-ზე", + "Country": "ქვეყანა", + "Create": "შექმნა", + "Create Album": "ალბომის შექმნა", + "Create Album…": "ალბომის შექმნა…", + "Create Folder": "საქაღალდის შექმნა", + "Create Folder…": "საქაღალდის შექმნა…", + "Create Keyword": "საკვანძო სიტყვის შექმნა", + "Create Keyword Tag": "საკვანძო სიტყვის შექმნა", + "Create New Mask": "ახალი ნიღბის შექმნა", + "Create PDF from clipboard": "PDF-ის შექმნა გაცვლის ბუფერიდან", + "Create PDF from file…": "PDF-ის შექმნა ფაილიდან…", + "Create PDF from images…": "PDF-ის შექმნა გამოსახულებებიდან…", + "Create Preset": "პრესეტის შექმნა", + "Create Preset…": "პრესეტის შექმნა…", + "Create Proof Copy": "საცდელი ასლის შექმნა", + "Create Smart Album": "სმარტ-ალბომის შექმნა", + "Create Smart Album from Filter…": "სმარტ-ალბომის შექმნა ფილტრიდან…", + "Create Smart Album…": "სმარტ-ალბომის შექმნა…", + "Create Stack": "დასტის შექმნა", + "Create Version": "ვერსიის შექმნა", + "Create Version from Step": "ვერსიის შექმნა ნაბიჯიდან", + "Create Virtual Copy": "ვირტუალური ასლის შექმნა", + "Create keywords with + (inside the one you picked, with synonyms, and given to the selection if you like), edit one": "საკვანძო სიტყვების შექმნა +-ით (არჩეულის შიგნით, სინონიმებით, და სურვილისამებრ მონიშნულისთვის მინიჭებით), რედაქტირება", + "Creator": "ავტორი", + "Creator metadata": "ავტორის მეტამონაცემები", + "Crisp Landscape": "მკვეთრი პეიზაჟი", + "Crisp Vista": "მკვეთრი ხედი", + "Crop": "ჩამოჭრა", + "Crop & Rotate": "ჩამოჭრა და შემობრუნება", + "Crop & Rotate (C)": "ჩამოჭრა და შემობრუნება (C)", + "Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and": "ჩამოჭრა (ხარვეზი #295): ჩაკეტვის გადამრთველი თანაფარდობას ყველა სახელურზე ინარჩუნებს, მორგებული თქვენს თანაფარდობას იღებს (გამოყენება), და", + "Crop Aspect": "ჩამოჭრის თანაფარდობა", + "Crop rotation": "ჩამოჭრის შემობრუნება", + "Crop, Rotate, Geometry": "ჩამოჭრა, შემობრუნება, გეომეტრია", + "Cropped": "ჩამოჭრილი", + "Cross Process": "ჯვარედინი დამუშავება", + "Culling": "გადარჩევა", + "Culling.": "გადარჩევა.", + "Curve": "მრუდი", + "Custom": "მორგებული", + "Custom template…": "მორგებული შაბლონი…", + "Custom…": "მომხმარებლის…", + "Cut": "ამოჭრა", + "Cut pages": "გვერდების ამოჭრა", + "Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in the Info panel it": "ხელმისაწვდომია ამოჭრა, კოპირება, ჩასმა და ყველაფრის მონიშვნა. Esc მათში აკრეფის დაწყებამდე არსებულ ტექსტს აბრუნებს (ინფორმაციის პანელში", + "Cycle Crop Overlay": "ჩამოჭრის ზედდების მონაცვლეობა", + "Cycle Crop Overlay Orientation": "ჩამოჭრის ზედდების ორიენტაციის მონაცვლეობა", + "Cycle Info Overlay": "ინფორმაციის ზედდების მონაცვლეობა", + "Cycle Mask Overlay Color": "ნიღბის ზედდების ფერის მონაცვლეობა", + "Cycle Mask Overlay Mode": "ნიღბის ზედდების რეჟიმის მონაცვლეობა", + "Cylindrical": "ცილინდრული", + "Dark & Moody": "მუქი და პირქუში", + "Dark Mode": "ბნელი რეჟიმი", + "Dark Theme": "ბნელი თემა", + "Darken": "ჩამუქება", + "Darks": "მუქი ტონები", + "Date": "თარიღი", + "Date Groups": "თარიღის ჯგუფები", + "Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the": "თარიღის წესებს აქვს კალენდრის ღილაკი: აირჩიეთ დღე, თვე ან მთელი წელი (წელი / თვე / დღე) თქვენს ენაზე;", + "Day": "დღე", + "Daylight": "დღის სინათლე", + "Daylight, Cloudy, Shade, Tungsten, Fluorescent and Flash presets set real temperatures (issue #730). The matrices": "პრესეტები დღის სინათლე, მოღრუბლული, ჩრდილი, ვარვარების ნათურა, ფლუორესცენტური და ფლეში ნამდვილ ტემპერატურას აყენებს (ხარვეზი #730). მატრიცები", + "Decrease Brush Feather": "ფუნჯის კიდეების შერბილების შემცირება", + "Decrease Brush Size": "ფუნჯის ზომის შემცირება", + "Decrease Rating": "შეფასების შემცირება", + "Deep Sky": "ღრმა ცა", + "Defringe": "კიდის შარავანდის მოცილება", + "Deghost Amount": "აჩრდილების მოცილების სიძლიერე", + "Dehaze": "ბურუსის მოცილება", + "Delete": "წაშლა", + "Delete (⌫)": "წაშლა (⌫)", + "Delete Album": "ალბომის წაშლა", + "Delete All Masks": "ყველა ნიღბის წაშლა", + "Delete Color Label Set": "ფერადი იარლიყების ნაკრების წაშლა", + "Delete Component": "კომპონენტის წაშლა", + "Delete Export Preset": "ექსპორტის პრესეტის წაშლა", + "Delete Filter Preset": "ფილტრის პრესეტის წაშლა", + "Delete Imported Profile": "იმპორტირებული პროფილის წაშლა", + "Delete Keyword": "საკვანძო სიტყვის წაშლა", + "Delete Keyword Set": "საკვანძო სიტყვების ნაკრების წაშლა", + "Delete Keyword…": "საკვანძო სიტყვის წაშლა…", + "Delete Mask": "ნიღბის წაშლა", + "Delete Metadata Preset": "მეტამონაცემების პრესეტის წაშლა", + "Delete Permanently": "სამუდამოდ წაშლა", + "Delete Photo": "ფოტოს წაშლა", + "Delete Photos": "ფოტოების წაშლა", + "Delete Point Color": "წერტილოვანი ფერის წაშლა", + "Delete Point Color Sample": "წერტილოვანი ფერის ნიმუშის წაშლა", + "Delete Point Curve Preset": "წერტილოვანი მრუდის პრესეტის წაშლა", + "Delete Preset": "პრესეტის წაშლა", + "Delete Red Eye": "წითელი თვალის წაშლა", + "Delete Red Eye Correction": "წითელი თვალების კორექციის წაშლა", + "Delete Spot": "ლაქის წაშლა", + "Delete Version": "ვერსიის წაშლა", + "Delete all": "ყველას წაშლა", + "Delete all spots": "ყველა ლაქის წაშლა", + "Delete mask": "ნიღბის წაშლა", + "Delete them": "მათი წაშლა", + "Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing": "წაშლილში, მეტამონაცემების განახლებების წაკითხვა. გამორთულ დისკზე არსებული საქაღალდის შესახებ ამას იტყობინება. სინქრონიზაცია", + "Denoise Model Downloads": "ხმაურის მოცილების მოდელების ჩამოტვირთვა", + "Denoise Models": "ხმაურის მოცილების მოდელები", + "Denoise Photos": "ფოტოებიდან ხმაურის მოცილება", + "Denoise Pump": "ხმაურის მოცილების რიგის გაშვება", + "Denoise Settings": "ხმაურის მოცილების პარამეტრები", + "Denoise Status": "ხმაურის მოცილების მდგომარეობა", + "Denoise all photos with an Amount": "ხმაურის მოცილება ყველა ფოტოზე, რომელსაც სიძლიერე აქვს", + "Density": "სიმკვრივე", + "Depth Range": "სიღრმის დიაპაზონი", + "Descending": "კლებადობით", + "Describe": "აღწერა", + "Describe what to keep": "აღწერეთ, რა დარჩეს", + "Describe what to leave out": "აღწერეთ, რა გამოირიცხოს", + "Describe what to select": "აღწერეთ, რა მოინიშნოს", + "Describe…": "აღწერა…", + "Description": "აღწერა", + "Deselect": "მონიშვნის მოხსნა", + "Deselect All": "ყველა მონიშვნის მოხსნა", + "Desert Heat": "უდაბნოს პაპანაქება", + "Desert Warmth": "უდაბნოს სითბო", + "Desktop": "სამუშაო მაგიდა", + "Detail": "დეტალი", + "Detail (D)": "დეტალური ხედი (D)", + "Detail has a per-photo AI Denoise switch and Amount. Bayer RAW photos use a disposable cache, with matching previews and exports and pure-Rust CPU/GPU inference.": "დეტალს აქვს ფოტოს დონის AI ხმაურის მოცილების გადამრთველი და სიძლიერე. Bayer RAW ფოტოები ერთჯერად ქეშს იყენებს, შესაბამისი წინასწარი ხედებით, ექსპორტით და სუფთა Rust-ზე CPU/GPU ინფერენსით.", + "Details": "დეტალები", + "Detect Faces": "სახეების ამოცნობა", + "Detect Faces (Photo menu) needs it: a small one-time download.": "სახეების ამოცნობას (მენიუ „ფოტო“) ეს სჭირდება: მცირე, ერთჯერადი ჩამოტვირთვა.", + "Develop": "დამუშავება", + "Diagonal": "დიაგონალური", + "Dialogs": "დიალოგები", + "Dialogs fit the window": "დიალოგები ფანჯარაში ეტევა", + "Dialogs that fit look as before.": "დიალოგები, რომლებიც ეტევა, ისევე გამოიყურება, როგორც ადრე.", + "Discard Preview Cache": "წინასწარი ხედების ქეშის წაშლა", + "Discarding smart previews": "ჭკვიანი წინასწარი ხედები იშლება", + "Display": "ეკრანი", + "Display P3": "Display P3", + "Display Profile": "ეკრანის პროფილი", + "Display name": "საჩვენებელი სახელი", + "Display profile not used": "ეკრანის პროფილი არ გამოიყენება", + "Display theme": "ინტერფეისის თემა", + "Distortion": "დეფორმაცია", + "Dock can be attached to a bug report. `LIGHTCRAFT_LOG` works as before; `RUST_LOG` takes env_logger-style": "შეცდომის ანგარიშს შეიძლება დაერთოს. `LIGHTCRAFT_LOG` ისე მუშაობს, როგორც ადრე; `RUST_LOG` იღებს env_logger-ის სტილის", + "Document properties…": "დოკუმენტის თვისებები…", + "Don't enlarge": "გადიდების გარეშე", + "Done": "მზადაა", + "Download": "ჩამოტვირთვა", + "Download AI Mask Model": "AI ნიღბების მოდელის ჩამოტვირთვა", + "Download Denoise Model": "ხმაურის მოცილების მოდელის ჩამოტვირთვა", + "Download Face Model": "სახეების მოდელის ჩამოტვირთვა", + "Download Place Names (GeoNames)": "ადგილების სახელების ჩამოტვირთვა (GeoNames)", + "Download the SAM 3 Model?": "ჩამოიტვირთოს SAM 3 მოდელი?", + "Downloaded and checked. Installing…": "ჩამოტვირთულია და შემოწმებულია. ინსტალირდება…", + "Downloading model": "მოდელი იტვირთება", + "Downloading place names": "ადგილების სახელები იტვირთება", + "Downloads": "ჩამოტვირთვები", + "Drag Crop Handle": "ჩამოჭრის სახელურის გადათრევა", + "Drag a keyword onto another to nest it, or onto the list's title to bring it back to the top level; photos dragged": "გადაათრიეთ საკვანძო სიტყვა მეორეზე მის შიგნით მოსათავსებლად, ან სიის სათაურზე ზედა დონეზე დასაბრუნებლად; ბადიდან", + "Drag along the horizon; double-click for Auto": "გაატარეთ ჰორიზონტის გასწვრივ; ორმაგი დაწკაპუნება — ავტომატურად", + "Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a": "გადაათრიეთ ალბომი ან საქაღალდე საქაღალდეზე მის იქ გადასატანად (დახურული საქაღალდე იხსნება, თუ მაჩვენებელი მასზე 0.6 წმ გაჩერდება;", + "Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated": "მონიშნული რადიალური გრადიენტის ელიფსის შიგნით ნებისმიერ ადგილას გადათრევით ის გადაადგილდება, მათ შორის შემობრუნებული", + "Drag over an eye on the photo; the pupil inside is found automatically.": "გაატარეთ ფოტოზე თვალის თავზე; შიგნით გუგა ავტომატურად მოიძებნება.", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "გადაათრიეთ ფერის ტონისა და გაჯერების დასაყენებლად. დააჭირეთ Shift-ს ფერის ტონის შესანარჩუნებლად, Cmd/Ctrl-ს — გაჯერების შესანარჩუნებლად, Alt/Option-ს — ზუსტი რეგულირებისთვის. ორჯერ დაწკაპეთ ჩამოსაყრელად.", + "Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you": "სლაიდერის გადათრევისას რედაქტირებული ფოტოს ესკიზი კადრების ზოლში აღარ რენდერდება ყოველ კადრზე; ის განახლდება, როცა", + "Draw Guides": "დამხმარე ხაზების დახატვა", + "Drop here for the top level": "ჩააგდეთ აქ ზედა დონისთვის", + "Duplicate": "დუბლირება", + "Duplicate Component": "კომპონენტის დუბლირება", + "Duplicate Mask": "ნიღბის დუბლირება", + "Duplicate and Invert Mask": "ნიღბის დუბლირება და ინვერსია", + "E-1, E-400 and XZ-2 corpus files: all accepted), with white balance relative to the as-shot look as for the other": "E-1, E-400 და XZ-2 კორპუსის ფაილები: ყველა მიღებულია), თეთრის ბალანსით გადაღებისას იერთან მიმართებით, როგორც სხვა", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "ყოველი / საქაღალდის ახალ დონეს იწყებს; ტეგები თითოეული ფოტოსთვის ივსება (დონე, რომელსაც მეტამონაცემები აკლია, არის \"unknown\").", + "Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until": "ადრე ყოველი ასეთი ხარვეზი რენდერის ერთ ნაკადს თან იყოლიებდა, ამიტომ რამდენიმე მათგანის შემდეგ არცერთი ფოტო არ რენდერდებოდა,", + "Edge": "კიდე", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "კიდის პარამეტრი და ფონურ რეჟიმში უფრო მკვეთრ, გადიდებულ დამუშავებას იღებენ. მოდელი LightCraft-ის შიგნით, სუფთა Rust-ზე მუშაობს", + "Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white).": "გვერდი, მოკლე გვერდი, თანაფარდობა (ჰორიზონტალური / ვერტიკალური / კვადრატული), ჩამოჭრილი და დამუშავება (ფერადი / შავ-თეთრი).", + "Edit": "რედაქტირება", + "Edit (E)": "რედაქტირება (E)", + "Edit Capture Time": "გადაღების დროის რედაქტირება", + "Edit Capture Time…": "გადაღების დროის რედაქტირება…", + "Edit Color Label Names": "ფერადი იარლიყების სახელების რედაქტირება", + "Edit Color Label Names…": "ფერადი იარლიყების სახელების რედაქტირება…", + "Edit Copy for External Editor": "ასლის რედაქტირება გარე რედაქტორში", + "Edit Date": "რედაქტირების თარიღი", + "Edit In": "რედაქტირება სხვა პროგრამაში", + "Edit Info": "ინფორმაციის რედაქტირება", + "Edit Keyword": "საკვანძო სიტყვის რედაქტირება", + "Edit Keyword Set": "საკვანძო სიტყვების ნაკრების რედაქტირება", + "Edit Keyword Tag": "საკვანძო სიტყვის რედაქტირება", + "Edit Keyword Tag…": "საკვანძო სიტყვის რედაქტირება…", + "Edit Label Names": "იარლიყების სახელების რედაქტირება", + "Edit Label Names…": "იარლიყების სახელების რედაქტირება…", + "Edit Mask": "ნიღბის რედაქტირება", + "Edit Mask Component": "ნიღბის კომპონენტის რედაქტირება", + "Edit Sections": "რედაქტირების სექციები", + "Edit Set…": "ნაკრების რედაქტირება…", + "Edit Smart Album": "სმარტ-ალბომის რედაქტირება", + "Edit Smart Album…": "სმარტ-ალბომის რედაქტირება…", + "Edit Spot": "ლაქის რედაქტირება", + "Edit in External Editor": "რედაქტირება გარე რედაქტორში", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "რედაქტირება გარე რედაქტორში (⇧⌘E): 16-ბიტიანი TIFF ასლი, დასტაში, განახლდება, როცა დაბრუნდებით.", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "რედაქტირება HDR-ში: SDR თეთრზე ღია ტონები, HDR ექსპორტისა და ეკრანებისთვის", + "Edit in Other Editor": "რედაქტირება სხვა რედაქტორში", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "„რედაქტირება სხვა რედაქტორში“ (⌥⌘E) ასლს ამ პროგრამაში ხსნის (macOS-ზე — აპის სახელი, სხვაგან — პროგრამის მისამართი).", + "Edit in PhotoCraft": "რედაქტირება PhotoCraft-ში", + "Edit settings copied": "რედაქტირების პარამეტრები დაკოპირდა", + "Edited": "შეცვლილია", + "Edited or not": "რედაქტირებული თუ არა", + "Editing": "რედაქტირება", + "Editing a smart album's name and rules is one step: both or neither, and one undo takes both back": "სმარტ-ალბომის სახელისა და წესების რედაქტირება ერთი ნაბიჯია: ან ორივე, ან არცერთი, და ერთი დაბრუნება ორივეს აბრუნებს", + "Effect": "ეფექტი", + "Effects": "ეფექტები", + "Empty Recently Deleted": "ახლახან წაშლილის გასუფთავება", + "Enable Lens Corrections": "ობიექტივის კორექციების ჩართვა", + "Enable Profile Corrections": "პროფილის კორექციების ჩართვა", + "End Interaction": "ურთიერთქმედების დასრულება", + "English": "English", + "Enter Full Screen": "სრულეკრანიან რეჟიმში გადასვლა", + "Enter a date as YYYY-MM-DD HH:MM:SS": "შეიყვანეთ თარიღი ფორმატით YYYY-MM-DD HH:MM:SS", + "Erase": "წაშლა", + "Evaluate Face Recognition": "სახეების ამოცნობის შეფასება", + "Every EXIF, GPS and XMP field in the file": "ფაილის ყველა EXIF, GPS და XMP ველი", + "Existing previews": "არსებული წინასწარი ხედები", + "Existing smart previews are refreshed when their originals are online, and cached renders are redrawn.": "არსებული ჭკვიანი წინასწარი ხედები ახლდება, როცა მათი ორიგინალები ხელმისაწვდომია, ხოლო ქეშირებული რენდერები ხელახლა იხატება.", + "Exit": "გასვლა", + "Expand": "გაშლა", + "Expand All Stacks": "ყველა დასტის ჩამოშლა", + "Expand Stack": "დასტის გაშლა", + "Expand/Collapse Stack": "დასტის გაშლა/ჩაკეცვა", + "Export": "ექსპორტი", + "Export Album with Preset": "ალბომის ექსპორტი პრესეტით", + "Export Album…": "ალბომის ექსპორტი…", + "Export As…": "ექსპორტი სხვა ფორმატით…", + "Export Contact Sheet PDF": "საკონტაქტო ფურცლის PDF-ად ექსპორტი", + "Export Containing Keywords": "მშობელი საკვანძო სიტყვების ექსპორტი", + "Export Group…": "ჯგუფის ექსპორტი…", + "Export Keywords": "საკვანძო სიტყვების ექსპორტი", + "Export Keywords…": "საკვანძო სიტყვების ექსპორტი…", + "Export Now": "ექსპორტი ახლავე", + "Export Point Curve Presets": "წერტილოვანი მრუდის პრესეტების ექსპორტი", + "Export Point Curve Presets…": "წერტილოვანი მრუდის პრესეტების ექსპორტი…", + "Export Presets": "ექსპორტის პრესეტები", + "Export Presets…": "პრესეტების ექსპორტი…", + "Export Synonyms": "სინონიმების ექსპორტი", + "Export User Presets…": "მომხმარებლის პრესეტების ექსპორტი…", + "Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: \"jxl\"` for agents and the CLI": "ექსპორტი JPEG XL-ად (ხარვეზი #247): ფორმატი → JPEG XL ექსპორტის დიალოგში, `format: \"jxl\"` აგენტებისა და CLI-სთვის", + "Export can write PSD too (flat, 8- or 16-bit).": "ექსპორტს PSD-ის ჩაწერაც შეუძლია (ერთშრიანი, 8 ან 16 ბიტი).", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "ექსპორტირებული ფაილების სახელები იყენებს ფოტოების გადარქმევის ტეგებს ({title}, {seq:2}, {date:%Y-%m-%d}…) და ახალ {num}, {folder}, {lens}, {iso}, {rating}, {creator}-ს.", + "Export stopped unexpectedly": "ექსპორტი მოულოდნელად შეწყდა", + "Export with Preset": "ექსპორტი პრესეტით", + "Export with Previous": "ექსპორტი წინა პარამეტრებით", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "ექსპორტირებული ფაილები ჯერ დროებით ფაილში იწერება და შემდეგ ადგილზე გადაერქმევა, ასე რომ სავსე დისკი ან გამორთული დისკი", + "Exporting": "ექსპორტირდება", + "Exporting contact sheet": "საკონტაქტო ფურცელი ექსპორტირდება", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "ექსპორტი და რენდერი ფოტოს ორიგინალს არასოდეს გადაწერს (ხარვეზი #93): ექსპორტი ფოტოს საკუთარ საქაღალდეში", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "ექსპორტი GPU-ის გამო აღარასოდეს არის შავი (ხარვეზი #78): GPU რენდერი, რომელსაც მოწყობილობის", + "Exports with noise reduction, Texture, Clarity, Dehaze or Highlights and Shadows can be much faster on the GPU,": "ექსპორტი ხმაურის შემცირებით, ტექსტურით, სიცხადით, ბურუსის მოცილებით ან ღია ტონებითა და ჩრდილებით GPU-ზე შეიძლება გაცილებით სწრაფი იყოს,", + "Export…": "ექსპორტი…", + "Exposure": "ექსპოზიცია", + "Exposure (shutter · aperture · ISO)": "ექსპოზიცია (ჩამკეტი · დიაფრაგმა · ISO)", + "Extended Description": "ვრცელი აღწერა", + "External Editing": "გარე რედაქტირება", + "External Editor": "გარე რედაქტორი", + "Face Boxes": "სახეების ჩარჩოები", + "Face Model Downloads": "სახეების მოდელების ჩამოტვირთვა", + "Face Models": "სახეების მოდელები", + "Face Recognition On/Off": "სახეების ამოცნობის ჩართვა/გამორთვა", + "Face boxes": "სახეების ჩარჩოები", + "Face detector": "სახეების დეტექტორი", + "Face models (ONNX)": "სახის მოდელები (ONNX)", + "Face names written to XMP by Lightroom and other apps show up here.": "სახეების სახელები, რომლებიც Lightroom-მა და სხვა აპებმა XMP-ში ჩაწერეს, აქ ჩანს.", + "Face recognition": "სახეების ამოცნობა", + "Faces": "სახეები", + "Faces of a Person": "ადამიანის სახეები", + "Faded Matte": "გაცრეცილი მატი", + "Faded Slide": "გაცრეცილი სლაიდი", + "Faded Urban": "გაცრეცილი ქალაქი", + "Failed": "ვერ მოხერხდა", + "Failed its last test": "ბოლო შემოწმება ვერ გაიარა", + "Faster GPU exports": "უფრო სწრაფი GPU ექსპორტი", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "უფრო სწრაფი ექსპორტი და ბარათიდან იმპორტი, იმავე დაცვით იმისთვის, რისი აღდგენაც შეუძლებელია (ხარვეზი #134): ექსპორტი,", + "Favorite Preset": "პრესეტის რჩეულებში დამატება", + "Favorite Profile": "პროფილის რჩეულებში დამატება", + "Favorites": "რჩეულები", + "Feather": "კიდეების შერბილება", + "File": "ფაილი", + "File & date": "ფაილი და თარიღი", + "File Extension": "ფაილის გაფართოება", + "File Format": "ფაილის ფორმატი", + "File Name": "ფაილის სახელი", + "File Path": "ფაილის მისამართი", + "File Size": "ფაილის ზომა", + "File Type": "ფაილის ტიპი", + "File extensions match whatever their letter case: `IMG_0001.JPG` and `DSC_0002.NEF` as Nikon writes them, `.Jpg`": "ფაილის გაფართოებები ემთხვევა ასოთა რეგისტრის მიუხედავად: `IMG_0001.JPG` და `DSC_0002.NEF`, როგორც Nikon წერს, `.Jpg`", + "File name": "ფაილის სახელი", + "File types:": "ფაილის ტიპები:", + "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "ფაილი → Lightroom-ის კატალოგის იმპორტი… პირდაპირ ხსნის `.lrcat`-ს, ორიგინალებზე მათ ადგილას მიმართვით.", + "File ▸ Import Keywords… and Export Keywords… read and write keyword list files as Lightroom Classic does (a keyword": "ფაილი → საკვანძო სიტყვების იმპორტი… და საკვანძო სიტყვების ექსპორტი… კითხულობს და წერს საკვანძო სიტყვების სიის ფაილებს, როგორც Lightroom Classic (თითო საკვანძო სიტყვა", + "File ▸ New Library… asks for a name, then where to put it, and opens the new library (issue #902).": "ფაილი → ახალი ბიბლიოთეკა… ითხოვს სახელს, შემდეგ მდებარეობას და ხსნის ახალ ბიბლიოთეკას (ხარვეზი #902).", + "File ▸ Open Recent Library lists the last 8 libraries; ones that are gone (moved, drive disconnected) are greyed": "ფაილი → ბოლოს გახსნილი ბიბლიოთეკის გახსნა ბოლო 8 ბიბლიოთეკას აჩვენებს; გამქრალი (გადატანილი, დისკი გამორთულია) ბიბლიოთეკები", + "Filename": "ფაილის სახელი", + "Files with the same content as another file in this import: only the first is imported.": "ფაილები, რომელთა შიგთავსი ამ იმპორტის სხვა ფაილს ემთხვევა: იმპორტდება მხოლოდ პირველი.", + "Fill": "შევსება", + "Fill Edges": "კიდეების შევსება", + "Fill Location": "მდებარეობის შევსება", + "Fill Location from GPS": "მდებარეობის შევსება GPS-ით", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "ცარიელი ქალაქის, შტატისა და ქვეყნის შევსება GPS-ით (GeoNames-ის ადგილების სახელები, ოფლაინ)", + "Film": "ფირი", + "Filmstrip": "კადრების ზოლი", + "Filmstrip (/)": "კადრების ზოლი (/)", + "Filter": "ფილტრი", + "Filter Bar": "ფილტრების ზოლი", + "Filter Keywords": "საკვანძო სიტყვების გაფილტვრა", + "Filter Presets": "ფილტრის პრესეტები", + "Filter bar": "ფილტრების ზოლი", + "Filter fields": "ფილტრის ველები", + "Find Blemishes": "ლაქების მოძებნა", + "Find Dust Spots": "მტვრის ლაქების მოძებნა", + "Find Missing Photos": "დაკარგული ფოტოების მოძებნა", + "Find Missing Photos…": "დაკარგული ფოტოების მოძებნა…", + "Find Similar Photos": "მსგავსი ფოტოების მოძებნა", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "ამ საქაღალდეში დისკზე დამატებული ან მისგან გამქრალი ფოტოების და სხვა აპების მიერ შეცვლილი XMP თანმხლები ფაილების მოძებნა", + "Find tools and commands…": "ხელსაწყოებისა და ბრძანებების ძებნა…", + "Finding faces": "სახეების ძებნა", + "Find…": "ძებნა…", + "First": "პირველი", + "First commit": "პირველი კომიტი", + "Fit": "ჩატევა", + "Fit (F)": "ჩატევა (F)", + "Fit on Screen": "ეკრანში ჩატევა", + "Fit to width": "სიგანეში ჩატევა", + "Fit visible": "ხილულში ჩატევა", + "Five stars": "ხუთი ვარსკვლავი", + "Fix the marked rules to save this album.": "ალბომის შესანახად გაასწორეთ მონიშნული წესები.", + "Fixed keys": "ფიქსირებული კლავიშები", + "Flag": "დროშა", + "Flag as Pick": "რჩეულად მონიშვნა", + "Flag as Reject": "უარყოფილად მონიშვნა", + "Flagged as Pick": "მონიშნულია რჩეულად", + "Flagged as Reject": "მონიშნულია უარყოფილად", + "Flash": "ფლეში", + "Flip H": "ჰორიზ. არეკვლა", + "Flip Horizontal": "ჰორიზონტალურად არეკვლა", + "Flip Overlay (⇧O)": "ზედდების არეკვლა (⇧O)", + "Flip V": "ვერტ. არეკვლა", + "Flip Vertical": "ვერტიკალურად არეკვლა", + "Float panel": "მცურავი პანელი", + "Flow": "ნაკადი", + "Fluorescent": "ფლუორესცენტური", + "Focal Length": "ფოკუსური მანძილი", + "Focal length": "ფოკუსური მანძილი", + "Focus (assisted culling)": "ფოკუსი (დამხმარე გადარჩევა)", + "Folder": "საქაღალდე", + "Folder colour labels": "საქაღალდის ფერადი იარლიყები", + "Folder name (renamed on disk; its photos follow)": "საქაღალდის სახელი (დისკზე გადაერქმევა; მისი ფოტოები თან მიჰყვება)", + "Folders": "საქაღალდეები", + "Folders and {date} use the capture time; a photo without one uses today's date.": "საქაღალდეები და {date} გადაღების დროს იყენებს; ფოტო მის გარეშე დღევანდელ თარიღს იყენებს.", + "Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose": "სინქრონიზაცია…. საქაღალდე და მისი ქვესაქაღალდეები ფონურად სკანირდება დისკზე დამატებული ფოტოების, ისეთი ფოტოების, რომელთა", + "Food": "საჭმელი", + "Forget Unchanged Local Photos": "უცვლელი ლოკალური ფოტოების დავიწყება", + "Forget unchanged photos": "უცვლელი ფოტოების დავიწყება", + "Form fields panel": "ფორმის ველების პანელი", + "Format": "ფორმატი", + "Formats": "ფორმატები", + "Forward": "წინ", + "Found automatically": "ავტომატურად მოიძებნა", + "Fr": "პარ", + "Frame time": "კადრის დრო", + "Frame update": "კადრის განახლება", + "Free": "თავისუფალი", + "Fresh & Bright": "სუფთა და კაშკაშა", + "Friday": "პარასკევი", + "Fujifilm RAFs from 59 camera models now get the lens correction the camera stores in the file: distortion (with the": "59 კამერის მოდელის Fujifilm RAF-ები ახლა იღებს ობიექტივის კორექციას, რომელსაც კამერა ფაილში ინახავს: დეფორმაცია (", + "Fujifilm lens corrections": "Fujifilm-ის ობიექტივის კორექციები", + "Full": "სრული", + "Full Screen Preview": "სრულეკრანიანი გადახედვა", + "Full screen mode": "სრულეკრანიანი რეჟიმი", + "Full size": "სრული ზომა", + "GPS Track Log": "GPS მარშრუტის ჟურნალი", + "GPU Rendering": "GPU რენდერირება", + "GPU rendering": "GPU რენდერირება", + "General": "ზოგადი", + "Generated demo photo": "გენერირებული სადემონსტრაციო ფოტო", + "Geometry": "გეომეტრია", + "Get Develop Settings": "დამუშავების პარამეტრების აღება", + "Global Hue": "გლობალური ფერის ტონი", + "Global Luminance": "გლობალური სიკაშკაშე", + "Global Saturation": "გლობალური გაჯერება", + "Glossy": "პრიალა", + "Go back to listing them by name": "სახელით ჩამონათვალზე დაბრუნება", + "Go to History Step": "ისტორიის ნაბიჯზე გადასვლა", + "Golden": "ოქროსფერი", + "Golden Glow": "ოქროსფერი ნათება", + "Golden Hour": "ოქროს საათი", + "Grab bag": "სხვადასხვა", + "Grain": "მარცვლოვნება", + "Graphic": "გრაფიკა", + "Graphics card": "ვიდეობარათი", + "Green": "მწვანე", + "Green Amount": "მწვანის სიძლიერე", + "Green Hue": "მწვანის ფერის ტონი", + "Green Hue High": "მწვანის ფერის ტონის ზედა ზღვარი", + "Green Hue Low": "მწვანის ფერის ტონის ქვედა ზღვარი", + "Green Luminance": "მწვანის სიკაშკაშე", + "Green Primary": "მწვანე ძირითადი", + "Green Saturation": "მწვანის გაჯერება", + "Green channel": "მწვანე არხი", + "Green label": "მწვანე იარლიყი", + "Grid": "ბადე", + "Grid Info": "ინფორმაცია ბადეში", + "Gritty Street": "უხეში ქუჩა", + "Group": "ჯგუფი", + "Group by Date: Automatic": "თარიღით დაჯგუფება: ავტომატური", + "Group by Day": "დღით დაჯგუფება", + "Group by Month": "თვით დაჯგუფება", + "Group by Year": "წლით დაჯგუფება", + "Group by date": "თარიღით დაჯგუფება", + "Group into Stack": "დასტაში გაერთიანება", + "Group name": "ჯგუფის სახელი", + "Guided": "მართვადი", + "Guided Upright": "მართვადი Upright", + "Guided Upright Guides": "მართვადი Upright-ის დამხმარე ხაზები", + "HDR": "HDR", + "HDR Headroom": "HDR მარაგი", + "HDR Merge": "HDR გაერთიანება", + "HDR Merge Preview": "HDR გაერთიანების გადახედვა", + "HDR PNG export": "HDR PNG ექსპორტი", + "HDR Panorama Merge": "HDR პანორამის გაერთიანება", + "HDR Panorama Merge Preview": "HDR პანორამის გაერთიანების გადახედვა", + "HDR Panorama with Last Settings": "HDR პანორამა ბოლო პარამეტრებით", + "HDR Panoramas": "HDR პანორამები", + "HDR Panorama…": "HDR პანორამა…", + "HDR and panorama merges of CR2 keep their colour": "CR2-ის HDR და პანორამული გაერთიანება ფერს ინარჩუნებს", + "HDR panorama": "HDR პანორამა", + "HDR panoramas": "HDR პანორამები", + "HDR with Last Settings": "HDR ბოლო პარამეტრებით", + "HDR…": "HDR…", + "HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind": "HEIC / HEIF ფოტოები (iPhone და Mac) ახლა იხსნება: არასავალდებულო `lightcraft-heif` crate (heic-rs, სუფთა Rust)", + "HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own": "HEIC-ის ფერები ახლა libheif-ს ერთი კოდური მნიშვნელობის სიზუსტით ემთხვევა (ადრე ~10-მდე სცდებოდა): HEVC ნაკადის საკუთარი", + "Has Edits": "აქვს ცვლილებები", + "Has GPS": "აქვს GPS", + "Heal": "აღდგენა", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "სენსორის მტვრის მიერ გლუვ არეებზე დატოვებული პატარა, რბილი მუქი ლაქების აღდგენა", + "Height": "სიმაღლე", + "Height (px)": "სიმაღლე (პქს)", + "Help": "დახმარება", + "Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).": "დახმარება → კლავიატურის მალსახმობები რიცხვითი კლავიშების მინიჭებებსაც შეიცავს. იხ. [ბიბლიოთეკის მალსახმობები](library-shortcuts.md).", + "Help → Open Log Folder shows that log file in the file manager (Finder, Explorer, or the folder on Linux), so it": "დახმარება → ჟურნალის საქაღალდის გახსნა ამ ჟურნალის ფაილს ფაილების მენეჯერში აჩვენებს (Finder, Explorer ან საქაღალდე Linux-ზე), ასე რომ ის", + "Hide LightCraft": "LightCraft-ის დამალვა", + "Hide Mask": "ნიღბის დამალვა", + "Hide Others": "სხვების დამალვა", + "Hide This Section": "ამ სექციის დამალვა", + "Hide the sections you don't use: View ▸ Sidebar Sections, or right-click a header ▸ Hide This Section. Right-click": "დამალეთ სექციები, რომლებსაც არ იყენებთ: ხედი → გვერდითი ზოლის სექციები, ან მარჯვენა დაწკაპუნება სათაურზე → ამ სექციის დამალვა. მარჯვენა დაწკაპუნებით", + "Hides this shortcut only; the folder and its photos stay as they are": "მხოლოდ ამ მალსახმობს მალავს; საქაღალდე და მისი ფოტოები ისე რჩება, როგორც არის", + "High": "მაღალი", + "High Contrast B&W": "მაღალკონტრასტული შავ-თეთრი", + "Highlight": "ღია ტონები", + "Highlights": "ღია ტონები", + "Highlights Hue": "ღია ტონების ფერის ტონი", + "Highlights Luminance": "ღია ტონების სიკაშკაშე", + "Highlights Saturation": "ღია ტონების გაჯერება", + "Highlights and Shadows (process 2)": "ღია ტონები და ჩრდილები (პროცესი 2)", + "Highlights and Shadows keep the fine detail of the tones they move (issue #632): pulling Highlights down on a bright": "ღია ტონები და ჩრდილები ინარჩუნებს იმ ტონების წვრილ დეტალებს, რომლებსაც ცვლის (ხარვეზი #632): ნათელ ცაზე ღია ტონების", + "Highlights split": "ღია ტონების გაყოფა", + "Histogram": "ჰისტოგრამა", + "History": "ისტორია", + "History & Activity (Y)": "ისტორია და აქტივობა (Y)", + "Home": "საშინაო საქაღალდე", + "Horizontal": "ჰორიზონტალური", + "Horizontal inset (% of short edge)": "ჰორიზონტალური შეწევა (მოკლე გვერდის %)", + "Horizontal text": "ჰორიზონტალური ტექსტი", + "Hours": "საათი", + "How to install the model": "როგორ დავაინსტალიროთ მოდელი", + "Hue": "ფერის ტონი", + "Hue Range": "ფერის ტონის დიაპაზონი", + "I have read these terms and accept them for my own use": "წავიკითხე ეს პირობები და ვეთანხმები მათ ჩემი საკუთარი გამოყენებისთვის", + "ICC Profiles": "ICC პროფილები", + "ISO Speed": "ISO მგრძნობიარობა", + "ISO speed": "ISO მგრძნობიარობა", + "If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has": "თუ LightCraft ვერ ხსნის ფოტოს (მისი ორიგინალი გამორთულ დისკზეა, ან ფაილი დაზიანებულია), მაგრამ აქვს", + "If file exists": "თუ ფაილი არსებობს", + "If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a": "თუ სამუშაო მაგიდის აპს ფანჯრის გახსნა არ შეუძლია (მაგალითად, როცა არცერთი გრაფიკული მოწყობილობის გამოყენება შეუძლებელია), ის ახლა ამას", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "თუ ბიბლიოთეკა გაშვებისას ვერ იხსნება (გახსნილია სხვა პროგრამაში, წაუკითხავია, მიუერთებელ დისკზეა,", + "Image": "გამოსახულება", + "Import": "იმპორტი", + "Import Date": "იმპორტის თარიღი", + "Import Keywords": "საკვანძო სიტყვების იმპორტი", + "Import Keywords…": "საკვანძო სიტყვების იმპორტი…", + "Import Lightroom Catalog": "Lightroom-ის კატალოგის იმპორტი", + "Import Lightroom Catalog…": "Lightroom-ის კატალოგის იმპორტი…", + "Import Photos": "ფოტოების იმპორტი", + "Import Photos…": "ფოტოების იმპორტი…", + "Import Point Curve Presets": "წერტილოვანი მრუდის პრესეტების იმპორტი", + "Import Point Curve Presets…": "წერტილოვანი მრუდის პრესეტების იმპორტი…", + "Import Presets": "პრესეტების იმპორტი", + "Import Presets…": "პრესეტების იმპორტი…", + "Import Profiles": "პროფილების იმპორტი", + "Import Profiles & Presets…": "პროფილებისა და პრესეტების იმპორტი…", + "Import as new": "იმპორტი ახლის სახით", + "Import from Device": "იმპორტი მოწყობილობიდან", + "Import from Folder…": "იმპორტი საქაღალდიდან…", + "Import new photos": "ახალი ფოტოების იმპორტი", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "ფოტოების იმპორტისთვის გამოიყენეთ ფაილი → ფოტოების იმპორტი… (Cmd+Shift+I), ან ჩააგდეთ ისინი აქ", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "პრესეტების იმპორტი სხვა რედაქტორებიდან: XMP პრესეტები, კლასიკური `.lrtemplate` ფაილები, მობილური აპების „DNG პრესეტები“ და `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "იმპორტი → კოპირება ყოველ ასლს ამოწმებს, ისევე როგორც გადატანა (ხარვეზი #96): ყოველი ფაილი ახალ ფაილად იწერება, დისკზე სინქრონიზდება და", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "იმპორტი → გადატანა: ფოტოები დანიშნულების ადგილას გადადის (იგივე საქაღალდეებითა და გადარქმევით, რაც კოპირებისას, მაგ.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "იმპორტი: კოპირება ნებისმიერ საქაღალდეში, დღეების / თვეების მიხედვით / ერთ საქაღალდეში, გადარქმევა იმპორტისას, მეტამონაცემების პრესეტი, კოპირება DNG-ად.", + "Imported": "იმპორტირებული", + "Imported Photos": "იმპორტირებული ფოტოები", + "Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay": "იმპორტირებული `.cube` LUT პროფილები ჩანს პროფილის მენიუსა და პროფილების ბრაუზერში, საქაღალდის მიხედვით დაჯგუფებული, და", + "Importing": "იმპორტი", + "Importing Lightroom Catalog": "Lightroom-ის კატალოგის იმპორტი", + "Importing Lightroom catalog": "Lightroom-ის კატალოგის იმპორტი", + "Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot": "XMP პრესეტების იმპორტი აღარ ჩამოთვლის სამსახურებრივ ველებს (`Cluster`, `SortName`, `SupportsAmount2`, გადაღებისას", + "Importing a deleted photo again": "წაშლილი ფოტოს ხელახლა იმპორტი", + "Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums": "ფაილის იმპორტი, რომლის ფოტოც ახლახან წაშლილშია, ფოტოს აბრუნებს მისი ცვლილებებით, შეფასებითა და ალბომებით", + "Importing a large collection and opening its library no longer compare every preset with every other.": "დიდი კოლექციის იმპორტი და მისი ბიბლიოთეკის გახსნა აღარ ადარებს ყველა პრესეტს ყველა დანარჩენს.", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "იმპორტი წყაროში ფოტოებს ეძებს; საქაღალდეს ლოკალურში არ ამატებს (საქაღალდეში იმპორტის გარეშე სამუშაოდ გამოიყენეთ ლოკალური → საქაღალდის დათვალიერება…).", + "In Color": "ფერადი", + "In Photo Grid and Square Grid: Flag as Pick and advance": "ფოტოების ბადესა და კვადრატულ ბადეში: რჩეულად მონიშვნა და შემდეგზე გადასვლა", + "In Photo Grid and Square Grid: expand or collapse the stack": "ფოტოების ბადესა და კვადრატულ ბადეში: დასტის გაშლა ან ჩაკეცვა", + "In Recently Deleted": "ახლახან წაშლილში", + "In Use": "გამოიყენება", + "In a Stack": "დასტაში", + "In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see": "გრძელ სექციაში სათაური გვერდითი ზოლის თავში რჩება, სანამ მის სტრიქონებს გადაახვევთ, ასე რომ ყოველთვის ხედავთ,", + "In library": "ბიბლიოთეკაში", + "In memory — nothing is saved": "მეხსიერებაში — არაფერი ინახება", + "In use": "გამოიყენება", + "In-memory session: these settings last until LightCraft quits.": "სესია მეხსიერებაში: ეს პარამეტრები LightCraft-იდან გასვლამდე მოქმედებს.", + "Include on Export": "ექსპორტისას ჩართვა", + "Include selected photos": "მონიშნული ფოტოების ჩართვა", + "Include subfolders": "ქვესაქაღალდეების ჩართვა", + "Increase Brush Feather": "ფუნჯის კიდეების შერბილების გაზრდა", + "Increase Brush Size": "ფუნჯის ზომის გაზრდა", + "Increase Rating": "შეფასების გაზრდა", + "Index Faces": "სახეების ინდექსირება", + "Index Faces in the Background": "სახეების ინდექსირება ფონურ რეჟიმში", + "Info": "ინფორმაცია", + "Info (I)": "ინფორმაცია (I)", + "Info Overlay Off": "ინფორმაციის ზედდების გამორთვა", + "Info Overlay: Exposure": "ინფორმაციის ზედდება: ექსპოზიცია", + "Info Overlay: File": "ინფორმაციის ზედდება: ფაილი", + "Info overlay": "ინფორმაციის ზედდება", + "Insert at the cursor": "კურსორის ადგილას ჩასმა", + "Inside This Keyword, Purge Unused Keywords.": "ამ საკვანძო სიტყვის შიგნით, გამოუყენებელი საკვანძო სიტყვების გაწმენდა.", + "Inspect Denoise Model": "ხმაურის მოცილების მოდელის შემოწმება", + "Inspect Face Model File": "სახეების მოდელის ფაილის შემოწმება", + "Inspect Lightroom Catalog": "Lightroom-ის კატალოგის შემოწმება", + "Inspect Photo": "ფოტოს შემოწმება", + "Inspecting Lightroom Catalog": "Lightroom-ის კატალოგი მოწმდება", + "Install Denoise Model": "ხმაურის მოცილების მოდელის ინსტალაცია", + "Install Face Model": "სახეების მოდელის ინსტალაცია", + "Install a denoise model to enable AI Denoise on this photo": "ამ ფოტოზე AI ხმაურის მოცილების ჩასართავად დააინსტალირეთ ხმაურის მოცილების მოდელი", + "Install from file…": "ინსტალაცია ფაილიდან…", + "Install the SAM 3 Model": "SAM 3 მოდელის ინსტალაცია", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "დააინსტალირეთ საკუთარი ONNX მოდელი, გვერდით denoise-model.json ფაილით. ეს ვერსია მოდელების ჩამოტვირთვას არ გთავაზობთ.", + "Instant ’70s": "მყისიერი ’70-იანები", + "Intent:": "განზრახვა:", + "Interface": "ინტერფეისი", + "Interface language": "ინტერფეისის ენა", + "Intersect": "გადაკვეთა", + "Intersect with": "გადაკვეთა:", + "Into one folder": "ერთ საქაღალდეში", + "Invert": "ინვერსია", + "Invert Component": "კომპონენტის ინვერსია", + "Invert Mask": "ნიღბის ინვერსია", + "It tests this album, so testing it back would loop": "ის ამ ალბომს ამოწმებს, ამიტომ მისი უკუშემოწმება ჩაიციკლებოდა", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "ხმაურმოცილებული სურათი ჯერ არ შექმნილა, ხოლო სურათები თავისით არ იქმნება (პარამეტრები, AI ხმაურის მოცილება).", + "JPEG (Large)": "JPEG (დიდი)", + "JPEG (Small)": "JPEG (პატარა)", + "JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);": "JPEG XL-ით შეკუმშული DNG-ები (DNG 1.7) ახლა იხსნება: უდანაკარგო ფილები ნიმუშ-ნიმუშ დეკოდირდება (შემოწმებულია სინთეზურ ფაილებზე);", + "JPEG XL export": "JPEG XL ექსპორტი", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG იმავე სახელით (`IMG_0001.CR3` + `IMG_0001.JPG`) აღარ იზიარებს ერთ თანმხლებ ფაილს: RAW ინარჩუნებს `IMG_0001.xmp`-ს,", + "Join the ArtCraft Discord": "შემოუერთდით ArtCraft-ის Discord-ს", + "Join the ArtCraft Discord…": "შემოუერთდით ArtCraft-ის Discord-ს…", + "Join the ArtCraft community on Discord": "შემოუერთდით ArtCraft-ის საზოგადოებას Discord-ზე", + "Keep in Local": "ლოკალურში დატოვება", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "შეინახეთ თავისუფალი ადგილის მქონე დისკზე: ჭკვიანი წინასწარი ხედი დაახლოებით 1 MB-ია თითო ფოტოზე. ესკიზების ქეში ბიბლიოთეკაში რჩება.", + "Kelvin white balance for more Sony cameras": "თეთრის ბალანსი კელვინებში მეტი Sony კამერისთვის", + "Kelvin white balance for raws whose camera has colour matrices": "თეთრის ბალანსი კელვინებში იმ RAW-ებისთვის, რომელთა კამერასაც ფერთა მატრიცები აქვს", + "Keyboard Shortcuts": "კლავიატურის მალსახმობები", + "Keyboard shortcuts": "კლავიატურის მალსახმობები", + "Keyword": "საკვანძო სიტყვა", + "Keyword Count": "საკვანძო სიტყვების რაოდენობა", + "Keyword Info": "საკვანძო სიტყვის ინფორმაცია", + "Keyword List": "საკვანძო სიტყვების სია", + "Keyword Lists": "საკვანძო სიტყვების სიები", + "Keyword Name": "საკვანძო სიტყვის სახელი", + "Keyword Painter": "საკვანძო სიტყვების ფუნჯი", + "Keyword Set": "საკვანძო სიტყვების ნაკრები", + "Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it (or saves the edits as a new": "საკვანძო სიტყვების ნაკრები → ნაკრების რედაქტირება… არედაქტირებს ნაკრების ცხრა საკვანძო სიტყვას ადგილზე, თითო ველი თითო ⌥ კლავიშზე, და გადაარქმევს მას (ან ცვლილებებს ახალ", + "Keyword Sets": "საკვანძო სიტყვების ნაკრებები", + "Keyword Suggestions": "შემოთავაზებული საკვანძო სიტყვები", + "Keyword Tag Options": "საკვანძო სიტყვის პარამეტრები", + "Keyword lists": "საკვანძო სიტყვების სიები", + "Keyword sets": "საკვანძო სიტყვების ნაკრებები", + "Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export": "საკვანძო სიტყვის პარამეტრები განსაზღვრავს, რას შეიცავს ექსპორტირებული ფაილები: ექსპორტში ჩართვა, მშობელი საკვანძო სიტყვების ექსპორტი,", + "Keyword to paint": "მისანიჭებელი საკვანძო სიტყვა", + "Keywording box": "საკვანძო სიტყვების ველი", + "Keywords": "საკვანძო სიტყვები", + "Keywords & People": "საკვანძო სიტყვები და ადამიანები", + "Keywords (K)": "საკვანძო სიტყვები (K)", + "Keywords (⌥1–⌥9)": "საკვანძო სიტყვები (⌥1–⌥9)", + "Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.": "საკვანძო სიტყვების სათაურს აქვს +, როგორც ალბომებს: საკვანძო სიტყვის შექმნა საკვანძო სიტყვების სიის გაუხსნელად.", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "თქვენ მიერ დამატებული საკვანძო სიტყვები აქ ჩნდება; ⌥1–⌥9 მათ მიანიჭებს.", + "LUT-based": "LUT-ზე დაფუძნებული", + "Label": "იარლიყი", + "Label Set": "იარლიყების ნაკრები", + "Landscape": "პეიზაჟი", + "Landscape (wide)": "ჰორიზონტალური (ფართო)", + "Language": "ენა", + "Languages": "ენები", + "Large preset collections": "პრესეტების დიდი კოლექციები", + "Last": "ბოლო", + "Last GPU fallback": "GPU-დან ბოლო გადართვა", + "Last commit": "ბოლო კომიტი", + "Last loupe render": "ლუპის ბოლო რენდერი", + "Last view": "ბოლო ხედი", + "Lateral Chromatic Aberration": "განივი ქრომატული აბერაცია", + "Layer": "შრე", + "Layers": "შრეები", + "Leaf and Hasselblad raws": "Leaf-ისა და Hasselblad-ის RAW ფაილები", + "Leave them": "მათი დატოვება", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "მარცხნივ: სტანდარტული (SDR) დიაპაზონი SDR თეთრამდე. მარჯვნივ: HDR დიაპაზონი, თითო ზოლი SDR თეთრზე ზემოთ თითო საფეხურზე (ნარინჯისფერი ხაზი: HDR მარაგი). როცა HDR დიაპაზონის ვიზუალიზაცია ჩართულია, ზოლები მის ფერებს იღებს.", + "Lens": "ობიექტივი", + "Lens Blur": "ლინზის ბუნდოვნება", + "Lens Corrections": "ობიექტივის კორექციები", + "Level": "დონე", + "Library": "ბიბლიოთეკა", + "Library Folders": "ბიბლიოთეკის საქაღალდეები", + "Library Info": "ბიბლიოთეკის ინფორმაცია", + "Library Originals": "ბიბლიოთეკის ორიგინალები", + "Library Preferences": "ბიბლიოთეკის პარამეტრები", + "Library State": "ბიბლიოთეკის მდგომარეობა", + "Library and views": "ბიბლიოთეკა და ხედები", + "Library keyboard culling": "ბიბლიოთეკაში კლავიატურით გადარჩევა", + "Library name": "ბიბლიოთეკის სახელი", + "Library saved": "ბიბლიოთეკა შენახულია", + "Library sorting": "ბიბლიოთეკის სორტირება", + "Licence:": "ლიცენზია:", + "Light": "სინათლე", + "Light Mode": "ღია რეჟიმი", + "Light Theme": "ღია თემა", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 და უფრო ძველი ვერსიები მას არ იღებს (\"unsupported format … v2\").", + "LightCraft Default": "LightCraft-ის ნაგულისხმევი", + "LightCraft Help": "LightCraft-ის დახმარება", + "LightCraft Preset": "LightCraft-ის პრესეტი", + "LightCraft Presets": "LightCraft-ის პრესეტები", + "LightCraft Website": "LightCraft-ის ვებსაიტი", + "LightCraft could not start": "LightCraft ვერ გაეშვა", + "LightCraft couldn't find where to keep your library.": "LightCraft-მა ვერ იპოვა, სად შეინახოს თქვენი ბიბლიოთეკა.", + "LightCraft couldn't open the library at": "LightCraft-მა ვერ გახსნა ბიბლიოთეკა მისამართზე", + "LightCraft does not know this model, so it assumed:": "LightCraft-მა ეს მოდელი არ იცის, ამიტომ ივარაუდა:", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft აღარ ითიშება გაშვებისას Windows კომპიუტერებზე, რომელთა Vulkan დრაივერი გაუმართავია (ხარვეზი #136, მაგ. ზოგიერთი Intel UHD", + "LightCraft on GitHub": "LightCraft GitHub-ზე", + "LightCraft program\" although nothing had it open. Nothing in the shipped app is known to hit the window today; it": "LightCraft-ის პროგრამაში“, თუმცა არაფერს ჰქონდა ის გახსნილი. გავრცელებული აპი, რამდენადაც ცნობილია, დღეს ამ ფანჯარას ვერ ხვდება;", + "LightCraft the copy shows what you saved.": "LightCraft-ში, ასლი აჩვენებს იმას, რაც შეინახეთ.", + "LightCraft was restarted. The bug is still written to the log (Help ▸ Open Log Folder) for a report.": "სანამ LightCraft ხელახლა არ გაეშვებოდა. ხარვეზი კვლავ იწერება ჟურნალში (დახმარება → ჟურნალების საქაღალდის გახსნა) ანგარიშისთვის.", + "LightCraft website": "LightCraft-ის ვებსაიტი", + "LightCraft — Second Window": "LightCraft — მეორე ფანჯარა", + "LightCraft. Importing adds the keywords you don't have, in one undo step; a file it can't read says which line, or": "LightCraft-ს შორის გადადის. იმპორტი ამატებს საკვანძო სიტყვებს, რომლებიც არ გაქვთ, ერთ დაბრუნების ნაბიჯად; ფაილი, რომელსაც ვერ კითხულობს, ამბობს რომელ სტრიქონზე, ან", + "Lightroom Classic Catalog": "Lightroom Classic-ის კატალოგი", + "Lightroom Classic catalogs": "Lightroom Classic-ის კატალოგები", + "Lightroom import cancelled": "Lightroom-ის იმპორტი გაუქმდა", + "Lightroom inspection cancelled": "Lightroom-ის შემოწმება გაუქმდა", + "Lightroom's `crs:Temperature` for these raws is read as the Kelvin it is (sidecars, Lightroom catalogs, presets);": "Lightroom-ის `crs:Temperature` ამ RAW-ებისთვის იკითხება როგორც კელვინი (თანმხლები ფაილები, Lightroom-ის კატალოგები, პრესეტები);", + "Lights": "ნათელი ტონები", + "Lights Out": "სინათლის ჩაქრობა", + "Limit": "ზღვარი", + "Limit file size (KB, 0 = off)": "ფაილის ზომის შეზღუდვა (KB, 0 = გამორთული)", + "Line delta": "სტრიქონების სხვაობა", + "Linear": "წრფივი", + "Linear Gradient": "წრფივი გრადიენტი", + "Lines +/−": "სტრიქონები +/−", + "Lines added": "დამატებული სტრიქონები", + "Lines deleted": "წაშლილი სტრიქონები", + "List Albums": "ალბომების სია", + "List Develop Controls": "დამუშავების მართვის ელემენტების სია", + "List History": "ისტორიის სია", + "List Presets": "პრესეტების სია", + "List Profiles": "პროფილების სია", + "List this folder in Local from now on": "ამიერიდან ამ საქაღალდის ჩვენება ლოკალურში", + "Loading preview…": "წინასწარი ხედი იტვირთება…", + "Local": "ლოკალური", + "Local folders": "ლოკალური საქაღალდეები", + "Local library — no cloud account needed": "ლოკალური ბიბლიოთეკა — ღრუბლოვანი ანგარიში საჭირო არ არის", + "Locate Missing Files…": "დაკარგული ფაილების მოძებნა…", + "Locate Missing File…": "დაკარგული ფაილის მოძებნა…", + "Locate Photo": "ფოტოს მოძებნა", + "Location": "მდებარეობა", + "Lock": "ჩაკეტვა", + "Lock Crop Aspect": "ჩამოჭრის თანაფარდობის დაფიქსირება", + "Locked": "ჩაკეტილი", + "Long Edge": "გრძელი გვერდი", + "Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and": "ხანგრძლივი ამოცანები ერთ ადგილას ჩანს, ზედა მარცხნივ, ზედა ზოლის ქვეშ, როგორც Lightroom Classic-ში (ხარვეზი #345): იმპორტი და", + "Looking for changes": "ცვლილებების ძებნა", + "Looking for photos…": "ფოტოების ძებნა…", + "Looking up places": "ადგილების ძებნა", + "Lossless": "უდანაკარგო", + "Lossy DNG files and Smart Previews open as raw photos.": "დანაკარგიანი DNG ფაილები და ჭკვიანი წინასწარი ხედები იხსნება როგორც raw ფოტოები.", + "Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in": "დანაკარგიანი JPEG XL ფაილები Display P3-ში ან Rec. 2020-ში ახლა ნამდვილი ფერებით იხსნება; გაჯერებული ფერები ადრე", + "Low": "დაბალი", + "Luminance": "სიკაშკაშე", + "Luminance Range": "სიკაშკაშის დიაპაზონი", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar-ის სტილები: `.lmp` ფაილები და `.mplumpack` კოლექციები იმპორტირდება როგორც პრესეტები (დაჯგუფებული კოლექციის მიხედვით); სლაიდერები", + "Lush Greens": "ხასხასა სიმწვანე", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50-ზე, R100-სა და R8-ზე. CR3 ფაილები, რომლებსაც დეკოდერი ჯერ ვერ კითხულობს, კვლავ ჩაშენებული JPEG-იდან იხსნება, როგორც ადრე.", + "Magenta": "მეწამული", + "Magenta Hue": "მეწამულის ფერის ტონი", + "Magenta Luminance": "მეწამულის სიკაშკაშე", + "Magenta Saturation": "მეწამულის გაჯერება", + "Make Candidate the Select": "კანდიდატის მონიშნულად დაყენება", + "Make it now": "ახლავე შექმნა", + "Make the denoised picture of the photos I am looking at": "ხმაურმოცილებული სურათის შექმნა იმ ფოტოებისთვის, რომლებსაც ვათვალიერებ", + "Manual": "ხელით", + "Marquee zoom": "მასშტაბირება ჩარჩოთი", + "Mask Adjustment": "ნიღბის კორექცია", + "Masking": "ნიღბვა", + "Masking (M)": "ნიღბვა (M)", + "Masks": "ნიღბები", + "Match": "დამთხვევა", + "Match Total Exposures": "ჯამური ექსპოზიციების გათანაბრება", + "Matrix/TRC": "Matrix/TRC", + "Matte": "მატი", + "Matte Soft": "რბილი მატი", + "Med": "საშ.", + "Medium Contrast": "საშუალო კონტრასტი", + "Megapixels": "მეგაპიქსელი", + "Memory Budget": "მეხსიერების ლიმიტი", + "Memory Usage": "მეხსიერების გამოყენება", + "Memory budget": "მეხსიერების ლიმიტი", + "Memory for caches": "მეხსიერება ქეშისთვის", + "Menu": "მენიუ", + "Merge": "შერწყმა", + "Merge Keywords": "საკვანძო სიტყვების შერწყმა", + "Merge into…": "შერწყმა სხვასთან…", + "Merged": "შერწყმულია", + "Merged PRs": "გაერთიანებული PR-ები", + "Merging": "შერწყმა", + "Merging…": "გაერთიანება…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Meta-ს პირობები და არა LightCraft-ის. ჩამოტვირთვა მათ მიღებას ნიშნავს.", + "Metadata": "მეტამონაცემები", + "Metadata Preset": "მეტამონაცემების პრესეტი", + "Metadata Presets": "მეტამონაცემების პრესეტები", + "Metadata preset": "მეტამონაცემების პრესეტი", + "Metadata read": "მეტამონაცემები წაკითხულია", + "Midnight": "შუაღამე", + "Midpoint": "შუა წერტილი", + "Midtones": "შუალედური ტონები", + "Midtones Hue": "შუალედური ტონების ფერის ტონი", + "Midtones Luminance": "შუალედური ტონების სიკაშკაშე", + "Midtones Saturation": "შუალედური ტონების გაჯერება", + "Midtones split": "შუალედური ტონების გაყოფა", + "Minimize": "ჩაკეცვა", + "Missing Photos": "დაკარგული ფოტოები", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "დაკარგული მეტამონაცემები ({camera}, {title}…) ცარიელ ადგილს ტოვებს; თუ სახელი ცარიელი გამოვა, ორიგინალური სახელი რჩება.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "დაკარგული ფოტოები გადადის ახლახან წაშლილში და მათი აღდგენა შეიძლება; დისკზე არცერთ ფაილს არაფერი ეხება.", + "Misty Morning": "ნისლიანი დილა", + "Mo": "ორშ", + "Mode": "რეჟიმი", + "Model": "მოდელი", + "Models": "მოდელები", + "Models are installed separately after accepting their terms; no weights are bundled. See [setup and limitations](denoise.md).": "მოდელები ცალკე დგება მათი პირობების მიღების შემდეგ; წონები არ არის ჩაშენებული. იხ. [დაყენება და შეზღუდვები](denoise.md).", + "Modified Date": "ცვლილების თარიღი", + "Monday": "ორშაბათი", + "Monitor profile": "მონიტორის პროფილი", + "Monitor profiles": "მონიტორის პროფილები", + "Mono Red Filter": "მონოქრომული წითელი ფილტრი", + "Mono Rich": "მდიდარი მონოქრომი", + "Mono Soft": "რბილი მონოქრომი", + "Monochrome": "მონოქრომული", + "Month": "თვე", + "Moody": "პირქუში", + "Moody Portrait": "პირქუში პორტრეტი", + "More": "მეტი", + "More preset options": "პრესეტის სხვა პარამეტრები", + "Move": "გადაადგილება", + "Move Album": "ალბომის გადატანა", + "Move Down": "ჩამოწევა", + "Move Down in Stack": "დასტაში ჩამოწევა", + "Move Folder": "საქაღალდის გადატანა", + "Move Folder To…": "საქაღალდის გადატანა…", + "Move Keyword": "საკვანძო სიტყვის გადატანა", + "Move Mask": "ნიღბის გადაადგილება", + "Move Preset to Group": "პრესეტის ჯგუფში გადატანა", + "Move Preset to New Group": "პრესეტის ახალ ჯგუფში გადატანა", + "Move Selected Photos Here": "მონიშნული ფოტოების აქ გადატანა", + "Move Sidebar Section": "გვერდითი ზოლის სექციის გადატანა", + "Move Up": "აწევა", + "Move Up in Stack": "დასტაში აწევა", + "Move in Stack": "დასტაში გადატანა", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "ფაილების გადატანა (ბიბლიოთეკის Originals/-ში ან თქვენ მიერ არჩეულ საქაღალდეში) და წყაროდან წაშლა", + "Move them": "მათი გადატანა", + "Move to": "გადატანა", + "Move to Folder": "საქაღალდეში გადატანა", + "Move to Folder…": "საქაღალდეში გადატანა…", + "Move to Group": "ჯგუფში გადატანა", + "Move to group": "ჯგუფში გადატანა", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "გადატანა: ფაილები გადაიტანება ქვემოთ მითითებულ საქაღალდეში; თითოეული ორიგინალი (და მისი XMP თანმხლები ფაილი) წყაროდან მხოლოდ ასლის შემოწმების შემდეგ იშლება. დუბლიკატები და ფაილები, რომელთა გადატანაც ვერ მოხერხდა, ადგილზე რჩება.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "ამ დისკიდან იმპორტირებულ ყველა ფოტოს ახლახან წაშლილში გადაიტანს; ფაილებს არაფერი ეხება", + "Moves the files and their XMP sidecars into this folder; never overwrites": "ფაილებს და მათ XMP თანმხლებ ფაილებს ამ საქაღალდეში გადაიტანს; არასოდეს გადაწერს", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "ამ საქაღალდიდან იმპორტირებულ ფოტოებს ახლახან წაშლილში გადაიტანს; ფაილებს არაფერი ეხება", + "Muted": "მკრთალი", + "Muted Film": "მკრთალი ფირი", + "My Photos": "ჩემი ფოტოები", + "My Photos Panel": "ჩემი ფოტოების პანელი", + "Name": "სახელი", + "Name (A–Z)": "სახელი (ა–ჰ)", + "Name Face": "სახისთვის სახელის მიცემა", + "Name Faces": "სახეებისთვის სახელების მიცემა", + "Name of the folder the original is in": "საქაღალდის სახელი, რომელშიც ორიგინალია", + "Named": "სახელიანი", + "Named People": "სახელიანი ადამიანები", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "სახელები ჩანს იარლიყების მენიუში, ფილტრების ზოლსა და ინფორმაციის პანელში და იწერება XMP-ში. ცარიელი = ფერის სახელი.", + "Navigator": "ნავიგატორი", + "Needs Work": "დასახვეწია", + "Neon Dusk": "ნეონის ბინდი", + "Neon Night": "ნეონის ღამე", + "Nested group": "ჩადგმული ჯგუფი", + "Neutral": "ნეიტრალური", + "Never": "არასოდეს", + "New": "ახალი", + "New Album": "ახალი ალბომი", + "New Album…": "ახალი ალბომი…", + "New Folder on Disk": "ახალი საქაღალდე დისკზე", + "New Folder on Disk…": "ახალი საქაღალდე დისკზე…", + "New Folder…": "ახალი საქაღალდე…", + "New Group…": "ახალი ჯგუფი…", + "New Library": "ახალი ბიბლიოთეკა", + "New Library: Choose Where to Put It": "ახალი ბიბლიოთეკა: აირჩიეთ, სად განთავსდეს", + "New Library…": "ახალი ბიბლიოთეკა…", + "New Smart Album": "ახალი სმარტ-ალბომი", + "New Smart Album from Filter…": "ახალი სმარტ-ალბომი ფილტრიდან…", + "New Smart Album…": "ახალი სმარტ-ალბომი…", + "New album": "ახალი ალბომი", + "New album…": "ახალი ალბომი…", + "New blank PDF": "ახალი ცარიელი PDF", + "New fields: Keyword Count (\"2 or more\", \"exactly 2\"…), People and People Count (named faces), Shutter Speed (written": "ახალი ველები: საკვანძო სიტყვების რაოდენობა („2 ან მეტი“, „ზუსტად 2“…), ადამიანები და ადამიანების რაოდენობა (სახელდებული სახეები), ჩამკეტის სიჩქარე (იწერება", + "New name": "ახალი სახელი", + "New time": "ახალი დრო", + "New…": "ახალი…", + "Next Appearance Mode": "შემდეგი იერსახის რეჟიმი", + "Next History Step": "ისტორიის შემდეგი ნაბიჯი", + "Next Photo": "შემდეგი ფოტო", + "Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…", + "Night Blue": "ღამის ლურჯი", + "Nikon D850, Canon EOS 5D Mark III and others) now show white balance in Kelvin, as a DNG does and as Lightroom": "Nikon D850, Canon EOS 5D Mark III და სხვები), ახლა თეთრის ბალანსს კელვინებში აჩვენებს, როგორც DNG და როგორც Lightroom:", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) იყენებს intoPIX TicoRAW შეკუმშვას. LightCraft-ს ამ კოდეკის ლიცენზია ამჟამად არ აქვს, ამიტომ ხელმისაწვდომია მხოლოდ კამერის ჩაშენებული JPEG წინასწარი ხედი და არა სენსორის RAW მონაცემები. RAW მონაცემების რედაქტირებისთვის გადაიღეთ უდანაკარგოდ შეკუმშულ NEF-ში ან არსებული HE ფაილები ლიცენზირებული კონვერტერით DNG-ად გარდაქმენით.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF ფაილები, Sony ARW-ის მსგავსად, კამერის საკუთარ JPEG-ზე მორგებული ფერითა და ტონით იწყება, ნაცვლად მკრთალი,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon-ის ფაილები ორად გაყოფილი, განსხვავებულად შეკუმშული ნაწილებით, ჯერჯერობით წინასწარ ხედს იყენებს.)", + "No": "არა", + "No Camera or Card Found": "კამერა ან ბარათი ვერ მოიძებნა", + "No Date Groups": "თარიღის მიხედვით დაჯგუფების გარეშე", + "No Label": "იარლიყის გარეშე", + "No Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკები არ არის", + "No Stars": "ვარსკვლავების გარეშე", + "No Tool": "ხელსაწყოს გარეშე", + "No albums match": "შესაბამისი ალბომი არ არის", + "No albums yet": "ალბომები ჯერ არ არის", + "No automatic versions.": "ავტომატური ვერსიები არ არის.", + "No camera": "კამერის გარეშე", + "No contributor data was built into this copy.": "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული.", + "No date": "თარიღის გარეშე", + "No dust spots found": "მტვრის ლაქები ვერ მოიძებნა", + "No edits yet.": "ცვლილებები ჯერ არ არის.", + "No exposure information": "ექსპოზიციის ინფორმაცია არ არის", + "No filter presets yet": "ფილტრის პრესეტები ჯერ არ არის", + "No folders to browse": "დასათვალიერებელი საქაღალდეები არ არის", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "გრაფიკული პროცესორი ვერ მოიძებნა: LightCraft რენდერს CPU-ზე ასრულებს, რაც გაცილებით ნელია. დეტალები: დახმარება → სისტემის ინფორმაცია.", + "No imported folders yet": "იმპორტირებული საქაღალდეები ჯერ არ არის", + "No keywords are exported": "საკვანძო სიტყვები არ ექსპორტირდება", + "No keywords match": "შესაბამისი საკვანძო სიტყვა არ არის", + "No keywords yet": "საკვანძო სიტყვები ჯერ არ არის", + "No lens data embedded in this file (DNG lens opcodes only).": "ამ ფაილში ობიექტივის მონაცემები ჩაშენებული არ არის (მხოლოდ DNG-ის ობიექტივის ოპკოდები).", + "No masks yet. Choose a mask type above.": "ნიღბები ჯერ არ არის. ზემოთ აირჩიეთ ნიღბის ტიპი.", + "No matching commands": "შესაბამისი ბრძანება ვერ მოიძებნა", + "No matching photos": "შესაბამისი ფოტოები არ არის", + "No metadata found": "მეტამონაცემები ვერ მოიძებნა", + "No model credits were built into this copy.": "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული.", + "No named people in these photos": "ამ ფოტოებზე სახელიანი ადამიანები არ არის", + "No named people yet": "სახელიანი ადამიანები ჯერ არ არის", + "No new presets": "ახალი პრესეტები არ არის", + "No photo has it; the keywords below it go too.": "არცერთ ფოტოს არ აქვს; მასში ჩადგმული საკვანძო სიტყვებიც წაიშლება.", + "No photo selected": "ფოტო არ არის მონიშნული", + "No photos": "ფოტოები არ არის", + "No photos found": "ფოტოები ვერ მოიძებნა", + "No photos in this folder": "ამ საქაღალდეში ფოტოები არ არის", + "No photos match the active filters": "აქტიურ ფილტრებს არცერთი ფოტო არ შეესაბამება", + "No photos match the filters (View → Clear Filters)": "ფილტრებს არცერთი ფოტო არ შეესაბამება (ხედი → ფილტრების გასუფთავება)", + "No photos yet": "ფოტოები ჯერ არ არის", + "No raw photos yet: cameras appear here once their photos are in the library.": "raw ფოტოები ჯერ არ არის: კამერები აქ გამოჩნდება, როცა მათი ფოტოები ბიბლიოთეკაში იქნება.", + "No reference photo": "საცნობარო ფოტო არ არის", + "No usable GPU found: rendering on the CPU": "გამოსადეგი GPU ვერ მოიძებნა: რენდერი CPU-ზე", + "No versions yet. Create one to keep this look.": "ვერსიები ჯერ არ არის. ამ იერსახის შესანახად შექმენით ვერსია.", + "Noise": "ხმაური", + "Noise Reduction": "ხმაურის შემცირება", + "Non-raw photos": "არა-raw ფოტოები", + "None": "არცერთი", + "None (the display is treated as sRGB)": "არცერთი (ეკრანი sRGB-ად ითვლება)", + "Not Now": "ახლა არა", + "Not saved": "არ არის შენახული", + "Nothing already edited changes: a Custom white balance set on the old relative scale renders pixel for pixel as": "არაფერი უკვე დამუშავებული არ იცვლება: ძველ ფარდობით შკალაზე დაყენებული მორგებული თეთრის ბალანსი პიქსელ-პიქსელ ისევე რენდერდება, როგორც", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "თქვენს თანხმობამდე არაფერი ჩამოიტვირთება. მოდელი მხოლოდ ამ კომპიუტერზე ინახება; LightCraft მას არასოდეს ატვირთავს და არავის უზიარებს.", + "Nothing to compare": "შესადარებელი არაფერია", + "Nothing to survey": "მიმოსახილველი არაფერია", + "Nudge Develop Value": "დამუშავების მნიშვნელობის ბიჯით შეცვლა", + "OK": "OK", + "ONNX model": "ONNX მოდელი", + "Object": "ობიექტი", + "Object Mask": "ობიექტის ნიღაბი", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "ნიღბები „ობიექტი“ და „აღწერა“ იყენებს SAM 3-ს, Meta-ს სეგმენტაციის მოდელს. ის LightCraft-ის ნაწილი არ არის და ყველაფერი დანარჩენი მის გარეშეც მუშაობს.", + "October 2026": "ოქტომბერი 2026", + "Off": "გამორთული", + "Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`.": "ნაგულისხმევად გამორთულია (HEVC-ის პატენტები გამავრცელებლის გადასაწყვეტია, როგორც PhotoCraft-ში); ოფიციალური აწყობები `--features heif`-ს იყენებს.", + "Offset X": "X-წანაცვლება", + "Offset Y": "Y-წანაცვლება", + "Older raw cameras": "ძველი RAW კამერები", + "Olympus E-10 raws open with the camera's white balance (White Balance ▸ As Shot) and without the grey haze in": "Olympus E-10-ის RAW ფაილები კამერის თეთრის ბალანსით (თეთრის ბალანსი → როგორც გადაღებულია) და ჩრდილებში", + "Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the": "Olympus ORF RAW-ებიც კამერის საკუთარ JPEG-ზე მორგებულ საწყის იერს იღებს, ბრტყლად და ნაცრისფრად გახსნის ნაცვლად (", + "On Linux and FreeBSD, file dialogs (Import Photos…, Open Library, Export, presets, keyword lists…) are children of": "Linux-სა და FreeBSD-ზე ფაილის დიალოგები (ფოტოების იმპორტი…, ბიბლიოთეკის გახსნა, ექსპორტი, პრესეტები, საკვანძო სიტყვების სიები…)", + "On NixOS, start it from `nix develop`.": "NixOS-ზე გაუშვით `nix develop`-იდან.", + "On Windows and Linux, Ctrl+C outside a text field copies edit settings and Ctrl+V pastes them (⇧Ctrl+V: Paste": "Windows-სა და Linux-ზე Ctrl+C ტექსტური ველის გარეთ რედაქტირების პარამეტრებს აკოპირებს, Ctrl+V კი ჩასვამს (⇧Ctrl+V: მონიშნული", + "On macOS, photos are shown through the profile of the screen the window is on while no monitor profile is chosen in": "macOS-ზე ფოტოები ნაჩვენებია იმ ეკრანის პროფილით, რომელზეც ფანჯარაა, სანამ მონიტორის პროფილი არჩეული არ არის", + "On macOS, ratings `0–5`, labels `6–9` and pick/unflag `P/U` now reach the app even when shown in the native menu (issue #283; adapted from PR #261).": "macOS-ზე შეფასებები `0–5`, იარლიყები `6–9` და რჩეული/დროშის მოხსნა `P/U` ახლა აპს აღწევს მაშინაც, როცა ისინი მშობლიურ მენიუშია (ხარვეზი #283; ადაპტირებულია PR #261-დან).", + "On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,": "macOS-ზე მენიუს ზოლში ნაჩვენები ერთკლავიშიანი მალსახმობები ახლა მუშაობს: E, C, H, M, ⇧P, I, K, D, შეფასებები 0–5,", + "Only": "მხოლოდ", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "მუშავდება მხოლოდ ის ფოტოები, რომელთა AI ხმაურის მოცილების სიძლიერე 0-ზე მეტია. ექსპორტი ყოველთვის ქმნის თავისას, თუ ის აკლია.", + "Opacity": "გაუმჭვირვალობა", + "Opacity (%)": "გაუმჭვირვალობა (%)", + "Open Library": "ბიბლიოთეკის გახსნა", + "Open Library knows a Lightroom catalog": "ბიბლიოთეკის გახსნა ცნობს Lightroom-ის კატალოგს", + "Open Library…": "ბიბლიოთეკის გახსნა…", + "Open Log Folder": "ჟურნალების საქაღალდის გახსნა", + "Open Original in Other Editor (RAW, no copy)": "ორიგინალის გახსნა სხვა რედაქტორში (RAW, ასლის გარეშე)", + "Open Recent Library": "ბოლოს გახსნილი ბიბლიოთეკის გახსნა", + "Open in": "გახსნა:", + "Open in Detail": "დეტალურ ხედში გახსნა", + "Open page": "გვერდის გახსნა", + "Open the model's page": "მოდელის გვერდის გახსნა", + "Open the place in OpenStreetMap": "ადგილის გახსნა OpenStreetMap-ში", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "მოდელის საკუთარ გვერდს თქვენს ბრაუზერში ხსნის, რომ მის შესახებ წაიკითხოთ ან ფაილი თავად აიღოთ.", + "Open…": "გახსნა…", + "Optics": "ოპტიკა", + "Optics, Geometry and Calibration (issue #316).": "ოპტიკის, გეომეტრიის და კალიბრაციისთვის (ხარვეზი #316).", + "Optimize Library": "ბიბლიოთეკის ოპტიმიზაცია", + "Optional name": "არასავალდებულო სახელი", + "Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "არასავალდებულო დისტანციური SAM 3: მშობლიური რედაქტორი ლოკალურად დატოვეთ, ობიექტის, აღწერის და დეტალის", + "Options": "პარამეტრები", + "Orange": "ნარინჯისფერი", + "Orange Hue": "ნარინჯისფერის ფერის ტონი", + "Orange Luminance": "ნარინჯისფერის სიკაშკაშე", + "Orange Saturation": "ნარინჯისფერის გაჯერება", + "Orientation": "ორიენტაცია", + "Original": "ორიგინალი", + "Original + Settings": "ორიგინალი + პარამეტრები", + "Original extension without the dot": "ორიგინალური გაფართოება წერტილის გარეშე", + "Original file name without its extension": "ორიგინალური ფაილის სახელი გაფართოების გარეშე", + "Original files": "ორიგინალური ფაილები", + "Original offline · editing the smart preview": "ორიგინალი მიუწვდომელია · რედაქტირდება ჭკვიანი წინასწარი ხედი", + "Original offline · no smart preview": "ორიგინალი მიუწვდომელია · ჭკვიანი წინასწარი ხედი არ არის", + "Originals": "ორიგინალები", + "Other editor": "სხვა რედაქტორი", + "Other images (JPEG, PNG, TIFF, HEIC…)": "სხვა გამოსახულებები (JPEG, PNG, TIFF, HEIC…)", + "Other…": "სხვა…", + "Outside the crop box, where dragging rotates the photo, the pointer is a curved double arrow, and the angle is shown": "ჩამოჭრის ჩარჩოს გარეთ, სადაც გადათრევა ფოტოს აბრუნებს, მაჩვენებელი მრუდი ორმაგი ისარია, ხოლო კუთხე", + "Overlay": "ზედდება", + "Overwrite": "გადაწერა", + "Pages": "გვერდები", + "Paint": "ხატვა", + "Paint only areas like the one under the brush": "მხოლოდ ფუნჯის ქვეშ მყოფის მსგავსი არეების დახატვა", + "Paint over a distraction on the photo to remove it.": "ფოტოზე ზედმეტ დეტალს გადაუსვით ფუნჯი მის მოსაშორებლად.", + "Paint overlay": "საღებავით გადაფარვა", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Panasonic-ისა და Leica-ს raw ფაილები (RW2, RWL და ძველი RAW ფაილები, DMC-LX1-დან DC-S1R II-მდე) მუშავდება raw მონაცემებიდან", + "Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG": "Panasonic-ისა და Leica-ს RAW-ები (RW2, RWL) ახლა ობიექტივის დეფორმაციისთვის ისე სწორდება, როგორც კამერა საკუთარ JPEG-ს ასწორებს", + "Panels": "პანელები", + "Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on": "1:1-ზე გადიდებული ფოტოს გადაადგილება ან სლაიდერის მოძრაობა გადიდებისას აღარ დეკოდირებს მთელ RAW-ს ყოველ ჯერზე", + "Panorama": "პანორამა", + "Panorama Merge": "პანორამად გაერთიანება", + "Panorama Merge Preview": "პანორამად გაერთიანების გადახედვა", + "Panorama with Last Settings": "პანორამა ბოლო პარამეტრებით", + "Panoramas": "პანორამები", + "Panorama…": "პანორამა…", + "Paper": "ქაღალდი", + "Parametric curve": "პარამეტრული მრუდი", + "Paste": "ჩასმა", + "Paste Edit Settings": "რედაქტირების პარამეტრების ჩასმა", + "Paste Metadata": "მეტამონაცემების ჩასმა", + "Paste Selected Settings": "არჩეული პარამეტრების ჩასმა", + "Paste Selected Settings…": "არჩეული პარამეტრების ჩასმა…", + "Paste Settings": "პარამეტრების ჩასმა", + "Paste Settings from Previous": "წინა ფოტოს პარამეტრების ჩასმა", + "Paste from Previous": "წინადან ჩასმა", + "Paste pages": "გვერდების ჩასმა", + "Pastel Haze": "პასტელური ბურუსი", + "Pending photo action cancelled": "ფოტოს მოლოდინში მყოფი მოქმედება გაუქმდა", + "Pending photo action cancelled because its library or photo changed": "ფოტოს მოლოდინში მყოფი მოქმედება გაუქმდა, რადგან მისი ბიბლიოთეკა ან ფოტო შეიცვალა", + "Pentax raws without black borders": "Pentax-ის RAW ფაილები შავი კიდეების გარეშე", + "People": "ადამიანები", + "People Count": "ადამიანების რაოდენობა", + "Percent": "პროცენტული", + "Percentage": "პროცენტული", + "Perceptual": "აღქმითი", + "Performance": "წარმადობა", + "Person": "ადამიანი", + "Perspective": "პერსპექტივა", + "Pet Eye": "ცხოველის თვალები", + "Pet Eye Catchlight": "ცხოველის თვალის ანარეკლი", + "Photo": "ფოტო", + "Photo Above": "ზემოთ მდებარე ფოტო", + "Photo Below": "ქვემოთ მდებარე ფოტო", + "Photo Grid": "ფოტოების ბადე", + "Photo Grid (G)": "ფოტოების ბადე (G)", + "Photo Merge": "ფოტოების გაერთიანება", + "Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and": "Canon CR2-ის (და სხვა RAW ფაილების, რომელთა ფერსაც LightCraft კამერის საკუთარი JPEG-იდან არგებს, როგორიცაა NEF და", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "„ფოტო“ მენიუში. კადრების ზოლსაც აქვს ფოტოს კონტექსტური მენიუ. ახლახან წაშლილში მყოფი ფაილის ხელახლა დამატება აღარ", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "ფოტო → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (⇧⌘E, ან მარჯვენა დაწკაპუნება ფოტოზე) არენდერებს ასლს თქვენი ცვლილებებით, ორიგინალთან ერთ დასტაში აერთიანებს და PhotoCraft-ში ხსნის. იქ შენახვისას ასლი აქაც განახლდება. PSD-ის შემთხვევაში PhotoCraft პირდაპირ მასში ინახავს. მდებარეობა ცარიელი დატოვეთ, თუ პორტატულ ასლს არ იყენებთ.", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "ფოტო → რედაქტირება გარე რედაქტორში (⇧⌘E) ქმნის 16-ბიტიან TIFF ასლს, ორიგინალთან ერთად დასტაში აერთიანებს და აქ ხსნის (macOS-ზე აპის სახელი, სხვაგან — პროგრამის მისამართი).", + "Photos": "ფოტოები", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "ფოტოები თქვენი მონიტორის ICC პროფილით ნაჩვენებია, ამიტომ ფერები სწორია ფართო ფერთა გამისა და დაკალიბრებულ ეკრანებზე, ხოლო დეტალური ხედი ეკრანის მთელ ფერთა გამას იყენებს. ჰისტოგრამაზე, წინასწარი ხედების ქეშებსა და ექსპორტზე ეს გავლენას არ ახდენს.", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "ახლახან წაშლილში მყოფი ფოტოების აღდგენა შეიძლება აპიდან: მარჯვენა დაწკაპუნება → აღდგენა (ან სამუდამოდ წაშლა), აგრეთვე", + "Photos per bracket (0 = auto)": "ფოტო ექსპოზიციის სერიაში (0 = ავტომატური)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "ლოკალურში ნანახი, მაგრამ არასოდეს დამატებული ან შეცვლილი ფოტოები კატალოგიდან ამოიშლება, თუ მათი საქაღალდე ამ ხნის განმავლობაში არ დაგითვალიერებიათ (მოწმდება ბიბლიოთეკის გახსნისას). ფაილები დისკზე რჩება; საქაღალდის დათვალიერება მათ ისევ აჩვენებს.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "ფოტოები „ყველა ფოტოში“ რჩება. საქაღალდის წაშლა მის ალბომებსაც შლის. ამის დაბრუნება შეიძლება.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "ამ საქაღალდეში შემოსული ფოტოები (კამერასთან პირდაპირი კავშირი, სკანერი, სინქრონიზაციის აპი) ემატება, როგორც კი სრულად ჩაიწერება.", + "Photos that tie on the sort key (equal ratings, one import batch, a burst shot within the same second) now sort by": "ფოტოები, რომლებიც სორტირების გასაღებით თანაბარია (თანაბარი შეფასება, ერთი იმპორტის პარტია, ერთსა და იმავე წამში გადაღებული სერია), ახლა სორტირდება", + "Photos with deep shadows no longer have to extend the first measured tone in a straight line to black.": "ღრმა ჩრდილების მქონე ფოტოებში პირველი გაზომილი ტონი აღარ უნდა გაგრძელდეს სწორი ხაზით შავამდე.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "ფოტოები); იმპორტი (გადათრევითაც), დაკარგული ფოტოების მოძებნა, ჭკვიანი წინასწარი ხედების შექმნა / მოცილება, გადარქმევის გადახედვა,", + "Pick": "რჩეული", + "Pick Flag": "რჩეულის დროშა", + "Pick a colour on the photo with the eyedropper.": "ფოტოზე ფერის არჩევა პიპეტით.", + "Pick a date": "თარიღის არჩევა", + "Pick the sharpest photo of each burst": "თითოეული სერიიდან ყველაზე მკვეთრი ფოტოს არჩევა", + "Picked": "რჩეული", + "Picks": "რჩეულები", + "Pictures": "სურათები", + "Pixels": "პიქსელი", + "Place Names Status": "ადგილების სახელების სტატუსი", + "Place names download cancelled": "ადგილების სახელების ჩამოტვირთვა გაუქმდა", + "Place names downloaded": "ადგილების სახელები ჩამოტვირთულია", + "Point Color": "წერტილის ფერი", + "Point Curve": "წერტილოვანი მრუდი", + "Point Curve Presets": "წერტილოვანი მრუდის პრესეტები", + "Point curve": "წერტილოვანი მრუდი", + "Portrait": "პორტრეტი", + "Portrait (tall)": "ვერტიკალური (მაღალი)", + "Position": "პოზიცია", + "Preferences": "პარამეტრები", + "Preferences…": "პარამეტრები…", + "Prepare AI Masks": "AI ნიღბების მომზადება", + "Preset": "პრესეტი", + "Preset name": "პრესეტის სახელი", + "Presets": "პრესეტები", + "Presets & Profiles": "პრესეტები და პროფილები", + "Presets (Shift+P)": "პრესეტები (Shift+P)", + "Presets and profiles": "პრესეტები და პროფილები", + "Presets ▾": "პრესეტები →", + "Press keys…": "დააჭირეთ კლავიშებს…", + "Preview": "გადახედვა", + "Preview Build Progress": "წინასწარი ხედების შექმნის პროგრესი", + "Preview only": "მხოლოდ წინასწარი ხედი", + "Preview only: editing the camera's embedded JPEG": "მხოლოდ წინასწარი ხედი: რედაქტირდება კამერის ჩაშენებული JPEG", + "Preview or Photos. Moving the window to another screen is followed; **Use Screen Profile** goes back to it after": "Preview-სა ან Photos-ში. ფანჯრის სხვა ეკრანზე გადატანაც გაითვალისწინება; **ეკრანის პროფილის გამოყენება** მას უბრუნდება", + "Preview size": "წინასწარი ხედის ზომა", + "Preview unavailable": "წინასწარი ხედი მიუწვდომელია", + "Previews": "წინასწარი ხედები", + "Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე", + "Previous History Step": "ისტორიის წინა ნაბიჯი", + "Previous Photo": "წინა ფოტო", + "Primaries (x, y)": "ძირითადი ფერები (x, y)", + "Print": "ბეჭდვა", + "Print…": "დაბეჭდვა…", + "ProRAW the way the iPhone renders it, turn on Profile ▸ Camera local tone mapping (shown for photos that carry the": "ProRAW-ის ისე სანახავად, როგორც iPhone რენდერავს, ჩართეთ პროფილი → კამერის ლოკალური ტონალური ასახვა (ჩანს ფოტოებზე, რომლებსაც", + "Process. See [process versions](process-versions.md).": "პროცესზე განახლებას. იხ. [პროცესის ვერსიები](process-versions.md).", + "Processor": "პროცესორი", + "Profile": "პროფილი", + "Profile Browser": "პროფილების ბრაუზერი", + "Profile Distortion": "დეფორმაცია პროფილით", + "Profile Menu": "პროფილების მენიუ", + "Profile Vignetting": "ვინიეტირება პროფილით", + "Profile:": "პროფილი:", + "Profiles": "პროფილები", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "სამუშაო მაგიდის ფერის პარამეტრებში მინიჭებული პროფილები ჩვეულებრივ ~/.local/share/icc-შია.", + "Progressive": "პროგრესული", + "Projection": "პროექცია", + "Proof copy created": "საცდელი ასლი შექმნილია", + "Properties": "თვისებები", + "Public Domain": "საზოგადოებრივი საკუთრება", + "Public domain": "საზოგადოებრივი საკუთრება", + "Pupil Size": "გუგის ზომა", + "Purge Unused Keywords": "გამოუყენებელი საკვანძო სიტყვების გაწმენდა", + "Purple": "იისფერი", + "Purple Amount": "იისფერის სიძლიერე", + "Purple Hue": "იისფერის ფერის ტონი", + "Purple Hue High": "იისფერის ფერის ტონის ზედა ზღვარი", + "Purple Hue Low": "იისფერის ფერის ტონის ქვედა ზღვარი", + "Purple Luminance": "იისფერის სიკაშკაშე", + "Purple Saturation": "იისფერის გაჯერება", + "Put New Keywords Inside": "ახალი საკვანძო სიტყვების შიგნით მოთავსება", + "Put New Keywords Inside This Keyword": "ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა", + "Put the locations you removed from Local back (no files change)": "ლოკალურიდან მოშორებული მდებარეობების დაბრუნება (ფაილები არ იცვლება)", + "Quality": "ხარისხი", + "Query Photos": "ფოტოების მოთხოვნა", + "Quick Collection": "სწრაფი კოლექცია", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "სწრაფი კოლექცია და სამიზნე ალბომი (B ბადეში), საკვანძო სიტყვების ნაკრებები (⌥1–⌥9), ფერადი იარლიყების ნაკრებები.", + "Quick Develop": "სწრაფი დამუშავება", + "Quiet Green": "მშვიდი სიმწვანე", + "Quit": "გასვლა", + "Quit Anyway": "მაინც გასვლა", + "Quit LightCraft": "LightCraft-იდან გასვლა", + "Quit while tasks are running?": "გავიდეთ, სანამ ამოცანები სრულდება?", + "Quit with unsaved changes?": "გავიდეთ შეუნახავი ცვლილებებით?", + "Quitting while a task that can be stopped is running asks first (\"Quit Anyway\" stops it).": "გასვლა, როცა შეჩერებადი ამოცანა მიმდინარეობს, ჯერ კითხულობს („მაინც გასვლა“ მას აჩერებს).", + "RAW decoding": "RAW-ის დეკოდირება", + "Radial": "რადიალური", + "Radial Gradient": "რადიალური გრადიენტი", + "Radius": "რადიუსი", + "Random": "შემთხვევითი", + "Range": "დიაპაზონი", + "Rate ★": "შეფასება ★", + "Rate ★ and advance": "შეფასება ★ და გადასვლა", + "Rate ★★": "შეფასება ★★", + "Rate ★★ and advance": "შეფასება ★★ და გადასვლა", + "Rate ★★★": "შეფასება ★★★", + "Rate ★★★ and advance": "შეფასება ★★★ და გადასვლა", + "Rate ★★★★": "შეფასება ★★★★", + "Rate ★★★★ and advance": "შეფასება ★★★★ და გადასვლა", + "Rate ★★★★★": "შეფასება ★★★★★", + "Rate ★★★★★ and advance": "შეფასება ★★★★★ და გადასვლა", + "Rating": "შეფასება", + "Rating cleared": "შეფასება გასუფთავდა", + "Ratings & flags": "შეფასებები და დროშები", + "Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;": "შეფასებები, დროშები, იარლიყები, საკვანძო სიტყვები, კოლექციები/ნაკრებები, ვირტუალური ასლები და მხარდაჭერილი ცვლილებები გადმოდის;", + "Raw": "Raw", + "Raw default": "raw-ის ნაგულისხმევი", + "Raw defaults": "raw-ის ნაგულისხმევები", + "Raw files": "raw ფაილები", + "Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS": "RAW ფაილები, რომელთა RAW მონაცემები TIFF-ის კერძო ბლოკშია (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds და Kodak DCS", + "Raw photos": "raw ფოტოები", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "raw ფოტოები DNG-ად, ჩაშენებული ცვლილებებით. ზომის, ფერისა და გამოტანის პარამეტრები არ მოქმედებს.", + "Raws from 52 camera models (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus their other names) whose": "52 კამერის მოდელის RAW-ები (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm და 5 სხვა, მათი სხვა სახელებითურთ), რომელთა", + "Raws from the 52 camera models whose colour matrices LightCraft has measured (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,": "52 კამერის მოდელის RAW-ები, რომელთა ფერთა მატრიცებიც LightCraft-მა გაზომა (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,", + "Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer": "Pentax KP, KF, K-3 Mark III (და Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 და 645Z კამერების RAW ფაილები აღარ", + "Read Metadata from File": "მეტამონაცემების წაკითხვა ფაილიდან", + "Read metadata updates from XMP sidecars": "მეტამონაცემების განახლებების წაკითხვა XMP თანმხლები ფაილებიდან", + "Read mode": "კითხვის რეჟიმი", + "Read the SAM License": "SAM-ის ლიცენზიის წაკითხვა", + "Reading": "იკითხება", + "Reading Lightroom catalog": "Lightroom-ის კატალოგის კითხვა", + "Reading Lightroom catalog…": "Lightroom-ის კატალოგის კითხვა…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "თანმხლები ფაილის წაკითხვა ფოტოს შეფასებას, იარლიყებს, მეტამონაცემებსა და ცვლილებებს მასში ჩაწერილით ჩაანაცვლებს.", + "Reading folder": "საქაღალდის კითხვა", + "Reading folder…": "საქაღალდის კითხვა…", + "Reading photos…": "ფოტოების წაკითხვა…", + "Real name": "ნამდვილი სახელი", + "Recent": "ბოლოდროინდელი", + "Recent Keywords": "ბოლოდროინდელი საკვანძო სიტყვები", + "Recent Keywords is built in: choose another name": "„ბოლოდროინდელი საკვანძო სიტყვები“ ჩაშენებულია: აირჩიეთ სხვა სახელი", + "Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკები", + "Recently Added": "ბოლოს დამატებული", + "Recently Deleted": "ახლახან წაშლილი", + "Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos.": "ახლახან წაშლილი ჩამოთვლილია ყველა ფოტოსთან, ბოლოს დამატებულსა და რჩეულებთან ერთად „ჩემი ფოტოების“ ქვეშ.", + "Recognise faces (experimental)": "სახეების ამოცნობა (ექსპერიმენტული)", + "Recognition models": "ამოცნობის მოდელები", + "Red": "წითელი", + "Red Eye": "წითელი თვალები", + "Red Hue": "წითლის ფერის ტონი", + "Red Luminance": "წითლის სიკაშკაშე", + "Red Primary": "წითელი ძირითადი", + "Red Saturation": "წითლის გაჯერება", + "Red channel": "წითელი არხი", + "Red label": "წითელი იარლიყი", + "Red/Cyan Fringe": "წითელი/ცისფერი შარავანდი", + "Redo": "ხელახლა შესრულება", + "Reference": "ეტალონი", + "Reference View": "ეტალონური ხედი", + "Reference the files where they are": "ფაილებზე მიმართვა მათ ადგილას", + "Refine": "დაზუსტება", + "Refine AI Mask Detail": "AI ნიღბის დეტალების დაზუსტება", + "Refine Edges": "კიდეების დაზუსტება", + "Refine Mask Edges": "ნიღბის კიდეების დაზუსტება", + "Refine Saturation": "გაჯერების დაზუსტება", + "Refining the mask's detail…": "ნიღბის დეტალები ზუსტდება…", + "Refresh Source": "წყაროს განახლება", + "Refresh Source (/)": "წყაროს განახლება (/)", + "Reject": "უარყოფა", + "Reject below focus": "ფოკუსის ზღვარს ქვემოთ უარყოფა", + "Rejected": "უარყოფილი", + "Rejects": "უარყოფილები", + "Relative": "ფარდობითი", + "Reliability": "საიმედოობა", + "Relink Photo": "ფოტოს ხელახლა დაკავშირება", + "Relink photos whose file was renamed or moved": "იმ ფოტოების ხელახლა დაკავშირება, რომელთა ფაილი გადაერქვა ან გადაიტანეს", + "Relinked": "ხელახლა დაკავშირებულია", + "Reload Changed Files": "შეცვლილი ფაილების ხელახლა ჩატვირთვა", + "Reload from Disk": "დისკიდან ხელახლა ჩატვირთვა", + "Remove": "მოშორება", + "Remove (H)": "მოშორება (H)", + "Remove CA": "ქრომატული აბერაციის მოცილება", + "Remove Chromatic Aberration": "ქრომატული აბერაციის მოცილება", + "Remove Denoise Model": "ხმაურის მოცილების მოდელის წაშლა", + "Remove Disk from Library": "დისკის ბიბლიოთეკიდან ამოშლა", + "Remove Disk from Library…": "დისკის ბიბლიოთეკიდან ამოშლა…", + "Remove Face Box": "სახის ჩარჩოს წაშლა", + "Remove Face Model": "სახეების მოდელის წაშლა", + "Remove Folder from Library": "საქაღალდის ბიბლიოთეკიდან ამოშლა", + "Remove a filter above, or choose Clear all": "მოაშორეთ ზემოთ მოცემული ფილტრი ან აირჩიეთ „ყველას გასუფთავება“", + "Remove from Album": "ალბომიდან ამოშლა", + "Remove from Favorites": "რჩეულებიდან ამოშლა", + "Remove from Library…": "ბიბლიოთეკიდან ამოშლა…", + "Remove from Local": "ლოკალურიდან ამოშლა", + "Remove from Photo": "ფოტოდან ამოშლა", + "Remove from Selected Photos": "მონიშნული ფოტოებიდან ამოშლა", + "Remove from Stack": "დასტიდან ამოშლა", + "Remove from Target Album": "სამიზნე ალბომიდან ამოშლა", + "Remove from favorites": "რჩეულებიდან ამოშლა", + "Remove from survey": "მიმოხილვიდან ამოშლა", + "Remove location info": "მდებარეობის ინფორმაციის წაშლა", + "Remove missing photos from the library": "დაკარგული ფოტოების ბიბლიოთეკიდან ამოშლა", + "Remove shortcut": "მალსახმობის წაშლა", + "Remove this face box (undo with Edit ▸ Undo)": "ამ სახის ჩარჩოს წაშლა (დაბრუნება: რედაქტირება → დაბრუნება)", + "Remove this filter": "ამ ფილტრის მოშორება", + "Remove this group": "ამ ჯგუფის წაშლა", + "Remove this rule": "ამ წესის წაშლა", + "Removed": "ამოშლილია", + "Rename": "გადარქმევა", + "Rename Album": "ალბომის გადარქმევა", + "Rename Component": "კომპონენტის გადარქმევა", + "Rename Folder": "საქაღალდის გადარქმევა", + "Rename Folder…": "საქაღალდის გადარქმევა…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "საქაღალდის გადარქმევა… და საქაღალდის გადატანა… (ლოკალური) შეიძლება დაბრუნდეს და ხელახლა შესრულდეს (ხარვეზი #97): საქაღალდე დისკზე ბრუნდება", + "Rename Keyword": "საკვანძო სიტყვის გადარქმევა", + "Rename Keyword…": "საკვანძო სიტყვის გადარქმევა…", + "Rename Mask": "ნიღბის გადარქმევა", + "Rename Photos": "ფოტოების გადარქმევა", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "ფოტოების გადარქმევა აღარასოდეს გადააწერს სხვა ფოტოს, როცა მხოლოდ ასოების რეგისტრი იცვლება (ხარვეზი #95): რეგისტრის მიმართ მგრძნობიარე", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "ფოტოების გადარქმევა აცნობებს ფაილებს, რომელთა უკან დაბრუნებაც შეცდომის შემდეგ ვერ მოხერხდა (ხარვეზი #105), მაგ. როცა ქსელური საქაღალდე ითიშება", + "Rename Photos…": "ფოტოების გადარქმევა…", + "Rename Preset": "პრესეტის გადარქმევა", + "Rename Preview": "გადარქმევის გადახედვა", + "Rename Template Tags": "გადარქმევის შაბლონის ტეგები", + "Rename Version": "ვერსიის გადარქმევა", + "Rename…": "გადარქმევა…", + "Rendering": "რენდერირება", + "Rendering…": "რენდერდება…", + "Reorder Album": "ალბომის გადალაგება", + "Reorder Masks": "ნიღბების გადალაგება", + "Reset": "ჩამოყრა", + "Reset All": "ყველაფრის ჩამოყრა", + "Reset All Curves": "ყველა მრუდის ჩამოყრა", + "Reset All Keyboard Shortcuts": "კლავიატურის ყველა მალსახმობის ჩამოყრა", + "Reset Before to Import State": "„მანამდე“ იმპორტის მდგომარეობაზე დაბრუნება", + "Reset Control": "მართვის ელემენტის ჩამოყრა", + "Reset Crop": "ჩამოჭრის გაუქმება", + "Reset Edits": "ცვლილებების ჩამოყრა", + "Reset Panel Layout": "პანელების განლაგების ჩამოყრა", + "Reset Parametric Curve": "პარამეტრული მრუდის ჩამოყრა", + "Reset Section": "სექციის საწყისზე დაბრუნება", + "Reset Sidebar Sections": "გვერდითი ზოლის სექციების ჩამოყრა", + "Reset Tone Curve": "ტონალური მრუდის ჩამოყრა", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "ყველა მრუდის ჩამოყრა: წერტილოვანი მრუდები (ყველა არხი) და პარამეტრული (ორმაგი დაწკაპუნება არხზე მხოლოდ მას ჩამოყრის)", + "Reset all edits (Cmd+Shift+R)": "ყველა ცვლილების ჩამოყრა (Cmd+Shift+R)", + "Reshuffle": "ხელახლა არევა", + "Resize Face Box": "სახის ჩარჩოს ზომის შეცვლა", + "Resize panel window": "პანელის ფანჯრის ზომის შეცვლა", + "Resize panels": "პანელების ზომის შეცვლა", + "Resize to": "ზომის შეცვლა:", + "Resolution (ppi)": "გარჩევადობა (ppi)", + "Restore": "აღდგენა", + "Restore (no shortcut)": "აღდგენა (მალსახმობის გარეშე)", + "Restore Library from Backup…": "ბიბლიოთეკის აღდგენა სარეზერვო ასლიდან…", + "Restore Version": "ვერსიის აღდგენა", + "Restore keeps their edits; Import as new deletes the old record first.": "აღდგენა ინარჩუნებს მათ ცვლილებებს; ახლის სახით იმპორტი ჯერ ძველ ჩანაწერს შლის.", + "Restore them": "მათი აღდგენა", + "Retouch": "რეტუში", + "Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them": "Return აჭერს OK-ს დიალოგებში „კოპირებისთვის რედაქტირების პარამეტრების არჩევა“ (⇧⌘C) და „მონიშნული პარამეტრების ჩასმა“ (⇧⌘V), Esc კი აუქმებს მათ", + "Reverse the order": "რიგის შებრუნება", + "Revert": "წინა ვერსიაზე დაბრუნება", + "Review": "განხილვა", + "Review Import": "იმპორტის გადახედვა", + "Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename": "მარჯვენა დაწკაპუნებით ფოტოების ძიებაზე, სლაიდერის აკრეფილ მნიშვნელობაზე, ინფორმაციის პანელის ველებზე, ახალი ალბომის, ალბომის", + "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → ფერადი იარლიყის დაყენება → ფერი ან არცერთი. სტრიქონი", + "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → საქაღალდის", + "Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…": "მარჯვენა დაწკაპუნება საქაღალდეზე → ახალი → ალბომის შექმნა… / სმარტ-ალბომის შექმნა… / სმარტ-ალბომის შექმნა ფილტრიდან… / საქაღალდის შექმნა…", + "Right-click a photo ▸ Edit In ▸ Edit in PhotoCraft (or Photo ▸ Edit In, ⇧⌘E) renders a copy with your edits, stacks it": "მარჯვენა დაწკაპუნება ფოტოზე → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (ან ფოტო → რედაქტირება სხვა პროგრამაში, ⇧⌘E) არენდერებს ასლს თქვენი ცვლილებებით, ათავსებს მას", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "დაწკაპეთ ფოტოზე მარჯვენა ღილაკით → ეტალონურ ფოტოდ დაყენება, შემდეგ ხედი → ეტალონური ხედი (Shift+R)", + "Right-click a preset to delete it": "პრესეტის წასაშლელად დაწკაპეთ მასზე მარჯვენა ღილაკით", + "Right-click ▸ Put New Keywords Inside This Keyword: new keywords, made with + or typed in the box above, go inside": "მარჯვენა დაწკაპუნება → ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა: + ღილაკით ან ზემოთ ველში აკრეფილი ახალი საკვანძო სიტყვები", + "Rights Usage Terms": "უფლებების გამოყენების პირობები", + "Rotate": "შემობრუნება", + "Rotate Crop": "ჩამოჭრის შემობრუნება", + "Rotate Crop Aspect": "ჩამოჭრის თანაფარდობის შემობრუნება", + "Rotate Left": "მარცხნივ შემობრუნება", + "Rotate Right": "მარჯვნივ შემობრუნება", + "Roughness": "უხეშობა", + "Roundness": "სიმრგვალე", + "Rule problems are shown in your language and name the field the way the menu does (\"Title: needs something to look": "წესების პრობლემები ნაჩვენებია თქვენს ენაზე და ველს ისე ასახელებს, როგორც მენიუ („დასახელება: საჭიროა საძიებო", + "Rule values are checked: a rating of 9, a date like 2026-13 or \"banana\", a \"between\" whose dates are the wrong": "წესების მნიშვნელობები მოწმდება: შეფასება 9, თარიღი, როგორიცაა 2026-13 ან „banana“, „შორის“, რომლის თარიღებიც არასწორი", + "Rules": "წესები", + "SAM 3 download stopped: it resumes where it left off next time.": "SAM 3-ის ჩამოტვირთვა შეწყდა: შემდეგ ჯერზე იქიდან გაგრძელდება, სადაც შეჩერდა.", + "SDR": "SDR", + "SDR Brightness": "SDR სიკაშკაშე", + "SDR Clarity": "SDR სიცხადე", + "SDR Contrast": "SDR კონტრასტი", + "SDR Highlights": "SDR ღია ტონები", + "SDR Shadows": "SDR ჩრდილები", + "SDR Whites": "SDR თეთრები", + "Sa": "შაბ", + "Same as raw default": "როგორც RAW-ის ნაგულისხმევ პარამეტრებში", + "Sample Mask Color": "ნიღბის ფერის ნიმუშის აღება", + "Sample a colour on the photo": "ფოტოზე ფერის ნიმუშის აღება", + "Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1": "Samsung SRW ფაილები შეკუმშვის გარეშე ახლა RAW-ებად იხსნება: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1", + "Saturation": "გაჯერება", + "Saturation Range": "გაჯერების დიაპაზონი", + "Saturday": "შაბათი", + "Save": "შენახვა", + "Save As…": "შენახვა როგორც…", + "Save Color Label Set": "ფერადი იარლიყების ნაკრების შენახვა", + "Save Current Filter as Preset…": "მიმდინარე ფილტრის პრესეტად შენახვა…", + "Save Current Keywords as Set…": "მიმდინარე საკვანძო სიტყვების ნაკრებად შენახვა…", + "Save Export Preset": "ექსპორტის პრესეტის შენახვა", + "Save Filter Preset": "ფილტრის პრესეტის შენახვა", + "Save Keyword Set": "საკვანძო სიტყვების ნაკრების შენახვა", + "Save Metadata Preset": "მეტამონაცემების პრესეტის შენახვა", + "Save Metadata Preset…": "მეტამონაცემების პრესეტის შენახვა…", + "Save Metadata to File": "მეტამონაცემების ფაილში შენახვა", + "Save Point Curve Preset": "წერტილოვანი მრუდის პრესეტის შენახვა", + "Save Point Curve…": "წერტილოვანი მრუდის შენახვა…", + "Save as Preset": "პრესეტად შენახვა", + "Save as Set…": "ნაკრებად შენახვა…", + "Save as Smart Album…": "სმარტ-ალბომად შენახვა…", + "Save as a new set": "ახალ ნაკრებად შენახვა", + "Save as set": "ნაკრებად შენახვა", + "Save as…": "შენახვა როგორც…", + "Save preset": "პრესეტის შენახვა", + "Saving Lightroom import index…": "Lightroom-ის იმპორტის ინდექსის შენახვა…", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "სხვა აპლიკაციის მიერ ჩაწერილ XMP თანმხლებ ფაილში მეტამონაცემების შენახვა მას აღარ ანაცვლებს (ხარვეზი #92): LightCraft აერთიანებს თავის", + "Scale": "მასშტაბი", + "Scan Folder for Changes": "საქაღალდის ცვლილებების სკანირება", + "Scan failed": "სკანირება ვერ მოხერხდა", + "Scanning": "სკანირება", + "Scanning folder": "საქაღალდის სკანირება", + "Scanning…": "სკანირება…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "ქულები ფოტოებზე რჩება: გაფილტრეთ ან შექმენით სმარტ-ალბომები ფოკუსისა და მსგავს კადრებს შორის საუკეთესოს მიხედვით.", + "Screen": "ეკრანი", + "Scroll menu down": "მენიუს ქვემოთ გადახვევა", + "Scroll menu up": "მენიუს ზემოთ გადახვევა", + "Search": "ძიება", + "Search Photos": "ფოტოების ძიება", + "Search albums": "ალბომების ძიება", + "Search commands or keys": "ბრძანებების ან კლავიშების ძიება", + "Search…": "ძიება…", + "Seasons": "სეზონები", + "Second Window": "მეორე ფანჯარა", + "Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows'": "სექციები ერთმანეთისგან უკეთ გაირჩევა: მათ შორის თხელი ხაზია, ჩაკეცვის ისარი სტრიქონების", + "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "იხ. [დისტანციური Metal ინფერენსი](ai-masks.md#remote-metal-inference).", + "Select": "მონიშვნა", + "Select All": "ყველაფრის მონიშვნა", + "Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one": "ყველაფრის მონიშვნა და მრავლობითი მონიშვნა ბადესა და კადრების ზოლში ყველა მონიშნულ ფოტოს აჩვენებს და არა მხოლოდ აქტიურს", + "Select Luminance Range": "სიკაშკაშის დიაპაზონის არჩევა", + "Select Mask": "ნიღბის არჩევა", + "Select Photos": "ფოტოების მონიშვნა", + "Select Spot": "ლაქის არჩევა", + "Select a photo to edit": "აირჩიეთ ფოტო რედაქტირებისთვის", + "Select by": "მონიშვნა:", + "Select by Flag, Rating or Label": "მონიშვნა დროშის, შეფასების ან იარლიყის მიხედვით", + "Select photos to give them keywords.": "მონიშნეთ ფოტოები, რომ მათ საკვანძო სიტყვები მიანიჭოთ.", + "Select photos, then choose View → Survey (N)": "მონიშნეთ ფოტოები, შემდეგ აირჩიეთ ხედი → მიმოხილვა (N)", + "Select two photos, then choose View → Compare (Shift+C)": "მონიშნეთ ორი ფოტო, შემდეგ აირჩიეთ ხედი → შედარება (Shift+C)", + "Selected": "მონიშნული", + "Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit": "პარამეტრების ჩასმა). Ctrl+V LightCraft-ს მხოლოდ მაშინ აღწევს, როცა სისტემის ბუფერში ტექსტია; სხვა შემთხვევაში გამოიყენეთ რედაქტირება → რედაქტირების", + "Selecting…": "მონიშვნა…", + "Selenium Tone": "სელენის ტონირება", + "Send Feedback…": "გამოხმაურების გაგზავნა…", + "Separated by commas": "მძიმით გამოყოფილი", + "Sepia Tone": "სეპიის ტონი", + "Sequence number zero-padded to N digits (1–9)": "რიგითი ნომერი, ნულებით შევსებული N ციფრამდე (1–9)", + "Sequence number, counting from the start number": "რიგითი ნომერი, საწყისი რიცხვიდან დათვლით", + "Services": "სერვისები", + "Set": "დაყენება", + "Set Album Cover": "ალბომის ყდის დაყენება", + "Set Before": "„მანამდე“-ს დაყენება", + "Set Color Label": "ფერადი იარლიყის დაყენება", + "Set Crop": "ჩამოჭრის დაყენება", + "Set Develop Values": "დამუშავების მნიშვნელობების დაყენება", + "Set Flag": "დროშის დაყენება", + "Set Folder Color Label": "საქაღალდის ფერადი იარლიყის მინიჭება", + "Set Image Zoom and Pan": "გამოსახულების მასშტაბისა და გადაადგილების დაყენება", + "Set Keyboard Shortcut": "კლავიატურის მალსახმობის მინიჭება", + "Set Mask Adjustments": "ნიღბის კორექციების დაყენება", + "Set Point Curve": "წერტილოვანი მრუდის დაყენება", + "Set Profile": "პროფილის დაყენება", + "Set Rating": "შეფასების დაყენება", + "Set Top of Stack": "დასტის ზედა ფოტოს დაყენება", + "Set as Album Cover": "ალბომის ყდად დაყენება", + "Set as Before": "„მანამდე“-დ დაყენება", + "Set as Reference Photo": "ეტალონურ ფოტოდ დაყენება", + "Set as Target Album": "სამიზნე ალბომად დაყენება", + "Set as Target Album (B adds to it)": "სამიზნე ალბომად დაყენება (B მასში ამატებს)", + "Set as Top of Stack": "დასტის ზედა ფოტოდ დაყენება", + "Set date & time": "თარიღისა და დროის დაყენება", + "Set name": "ნაკრების სახელი", + "Set the mix from the photo's colours": "მიქსის დაყენება ფოტოს ფერების მიხედვით", + "Set up AI Denoise…": "AI ხმაურის მოცილების გამართვა…", + "Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too.": "ფერადი იარლიყის დაყენება ან მოხსნა ქვემოთ მოკლე დადასტურებას აჩვენებს, როგორც ფოტოს შეფასება; შეტყობინებაში იარლიყების მორგებული სახელებიც ჩანს.", + "Settings": "პარამეტრები", + "Settings file problem": "პარამეტრების ფაილის პრობლემა", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "პარამეტრების ფაილები აღარასოდეს ჩამოიყრება შეუმჩნევლად (ხარვეზი #103): დაზიანებული `prefs.json`, `presets.json` ან `view.json` ინახება", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "პარამეტრები, რომლებითაც ახალი RAW ფოტოები იწყება. მათი შეცვლა ბიბლიოთეკაში უკვე არსებულ ფოტოებს არ ეხება.", + "Settings pasted": "პარამეტრები ჩასმულია", + "Settings to include": "ჩასართავი პარამეტრები", + "Settings ▸ Display (issue #787). On P3 screens previews used to look more saturated than the exported files in": "პარამეტრები → ეკრანში (ხარვეზი #787). P3 ეკრანებზე წინასწარი ხედები ადრე უფრო გაჯერებული ჩანდა, ვიდრე ექსპორტირებული ფაილები", + "Settings ▸ Display ▸ Choose Profile… shows photos through your monitor's ICC profile (matrix/TRC or LUT-based), so": "პარამეტრები → ეკრანი → პროფილის არჩევა… ფოტოებს თქვენი მონიტორის ICC პროფილით აჩვენებს (მატრიცა/TRC ან LUT), ასე რომ", + "Settings ▸ General ▸ External Editing: the format (PSD or TIFF), colour space and bit depth for PhotoCraft and for": "პარამეტრები → ზოგადი → გარე რედაქტირება: ფორმატი (PSD ან TIFF), ფერთა სივრცე და ბიტური სიღრმე PhotoCraft-ისა და", + "Settings ▸ Interface ▸ Appearance Mode: Sync with System, Dark Mode or Light Mode, above a light": "პარამეტრები → ინტერფეისი → იერსახის რეჟიმი: სისტემის მიხედვით, ბნელი რეჟიმი ან ღია რეჟიმი, ღია", + "Settings.": "პარამეტრების ჩასმა.", + "Settings…": "პარამეტრები…", + "Shade": "ჩრდილი", + "Shadow": "ჩრდილი", + "Shadows": "ჩრდილები", + "Shadows Hue": "ჩრდილების ფერის ტონი", + "Shadows Luminance": "ჩრდილების სიკაშკაშე", + "Shadows Saturation": "ჩრდილების გაჯერება", + "Shadows Tint": "ჩრდილების ტონირება", + "Shadows split": "ჩრდილების გაყოფა", + "Sharpen": "გამკვეთრება", + "Sharpening": "გამკვეთრება", + "Sharpness": "სიმკვეთრე", + "Shift": "Shift", + "Shift by": "წანაცვლება:", + "Short Edge": "მოკლე გვერდი", + "Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და", + "Show": "ჩვენება", + "Show All": "ყველას ჩვენება", + "Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება", + "Show Clipping": "ჩამოჭრილი ტონების ჩვენება", + "Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება", + "Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება", + "Show Luminance Map": "სიკაშკაშის რუკის ჩვენება", + "Show Mask": "ნიღბის ჩვენება", + "Show Mask Overlay": "ნიღბის ზედდების ჩვენება", + "Show Mask Pins": "ნიღბის მარკერების ჩვენება", + "Show Original": "ორიგინალის ჩვენება", + "Show Person": "ადამიანის ჩვენება", + "Show Photo Counts": "ფოტოების რაოდენობის ჩვენება", + "Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება", + "Show Pins": "მარკერების ჩვენება", + "Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება", + "Show Source": "წყაროს ჩვენება", + "Show Thumbnails": "ესკიზების ჩვენება", + "Show all overlays": "ყველა ზედდების ჩვენება", + "Show commands without a shortcut": "მალსახმობის არმქონე ბრძანებების ჩვენება", + "Show destination gamut warning": "დანიშნულების ფერთა გამიდან გასვლის გაფრთხილების ჩვენება", + "Show display gamut warning": "ეკრანის ფერთა გამიდან გასვლის გაფრთხილების ჩვენება", + "Show file names": "ფაილების სახელების ჩვენება", + "Show in Compare": "შედარებაში ჩვენება", + "Show in Explorer": "Explorer-ში ჩვენება", + "Show in File Manager": "ფაილების მენეჯერში ჩვენება", + "Show in Finder": "Finder-ში ჩვენება", + "Show less": "ნაკლების ჩვენება", + "Show on Map": "რუკაზე ჩვენება", + "Show overlay (O)": "ზედდების ჩვენება (O)", + "Show ratings, flags and edit badges": "შეფასებების, დროშებისა და ცვლილების ნიშნების ჩვენება", + "Show the Navigator while zoomed in": "ნავიგატორის ჩვენება გადიდებისას", + "Show the template tags; click one to insert it": "შაბლონის ტეგების ჩვენება; ჩასასმელად დაწკაპეთ ერთ-ერთზე", + "Show this wheel's effect": "ამ ბორბლის ეფექტის ჩვენება", + "Show/Hide Mask": "ნიღბის ჩვენება/დამალვა", + "Show/hide My Photos panel": "„ჩემი ფოტოების“ პანელის ჩვენება/დამალვა", + "Shutter Speed": "ჩამკეტის სიჩქარე", + "Shutter speed": "ჩამკეტის სიჩქარე", + "Side panels": "გვერდითი პანელები", + "Sidebar Sections": "გვერდითი ზოლის სექციები", + "Sidebar sections": "გვერდითი ზოლის სექციები", + "Sidecar names": "თანმხლები ფაილების სახელები", + "Silver": "ვერცხლისფერი", + "Simulate Paper & Ink": "ქაღალდისა და მელნის იმიტაცია", + "Size": "ზომა", + "Size (% of short edge)": "ზომა (მოკლე გვერდის %)", + "Size limit": "ზომის ზღვარი", + "Size rules measure the photo as shown: Megapixels now counts the cropped photo, like Long Edge, Short Edge and": "ზომის წესები ფოტოს ზომავს ისე, როგორც ჩანს: მეგაპიქსელი ახლა ჩამოჭრილ ფოტოს ითვლის, როგორც გრძელი გვერდი, მოკლე გვერდი და", + "Skip": "გამოტოვება", + "Skipped: files already in the library elsewhere": "გამოტოვებულია: ფაილები, რომლებიც ბიბლიოთეკაში უკვე სხვა ადგილასაა", + "Skipped: files that can't be read": "გამოტოვებულია: ფაილები, რომელთა წაკითხვა შეუძლებელია", + "Sky": "ცა", + "Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it": "ცის, საგნის და ფონის მონიშვნის ნიღბებისთვის ჩვენი ევრისტიკის ნაცვლად, ასე რომ ცა იქ მოინიშნება, სადაც კამერამ იპოვა", + "Slider drags follow the pointer more closely": "სლაიდერის გადათრევა მაჩვენებელს უფრო ზუსტად მისდევს", + "Slider drags on large raws": "სლაიდერის გადათრევა დიდ RAW ფაილებზე", + "Slideshow": "სლაიდშოუ", + "Slideshow paused": "სლაიდშოუ შეჩერებულია", + "Slideshow resumed": "სლაიდშოუ განახლდა", + "Slideshow · Space pauses · Esc ends": "სლაიდშოუ · Space აჩერებს · Esc ასრულებს", + "Slideshow, second window, All Metadata, System Info.": "სლაიდშოუ, მეორე ფანჯარა, ყველა მეტამონაცემი, სისტემის ინფორმაცია.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "უფრო მცირე და სწრაფი კატალოგები: ფოტოები, რომლებიც ლოკალურში მხოლოდ დაათვალიერეთ (არასოდეს დაგიმატებიათ, შეგიფასებიათ ან დაგიმუშავებიათ), დავიწყებას ეძლევა, როცა", + "Smart Album": "სმარტ-ალბომი", + "Smart Album Rule Fields": "სმარტ-ალბომის წესის ველები", + "Smart Preview Status": "ჭკვიანი წინასწარი ხედის სტატუსი", + "Smart Previews Location": "ჭკვიანი წინასწარი ხედების მდებარეობა", + "Smart album rules": "სმარტ-ალბომის წესები", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "სმარტ-ალბომები წესების რედაქტორით: ყველა / რომელიმე / არცერთი დამთხვევა, ჩადგმული ჯგუფები, 26 ველი.", + "Smart preview available": "ჭკვიანი წინასწარი ხედი ხელმისაწვდომია", + "Smart previews": "ჭკვიანი წინასწარი ხედები", + "Smart previews folder changed": "ჭკვიანი წინასწარი ხედების საქაღალდე შეიცვალა", + "Smoothness": "სიგლუვე", + "Soft B&W": "რბილი შავ-თეთრი", + "Soft Pastel": "რბილი პასტელი", + "Soft Proofing": "საცდელი ჩვენება", + "Soft Skin": "რბილი კანი", + "Some rules need fixing (Edit Smart Album…):": "ზოგიერთ წესს გასწორება სჭირდება (სმარტ-ალბომის რედაქტირება…):", + "Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the": "Sony A7R II-ის (და სხვა) RAW-ები, რომელთა კამერის JPEG ობიექტივისთვის კორექტირებულია, აღარ იხსნება ნაცრისფრად და ზედმეტად მუქად (ხარვეზი #232):", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony-ის ARW-ები დაახლოებით 2017 წლამდე (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II და მათი", + "Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too,": "Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 და ILCE-7S კამერების RAW ფაილებიც ახლა თეთრის ბალანსს კელვინებში აჩვენებს,", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4-ის შემცირებული ზომის უდანაკარგო ARW-ები ახლა დისკრეტიზაციაშემცირებულ YCbCr ფილებს წრფივ RGB-ში დეკოდირებს,", + "Sort": "სორტირება", + "Sort Albums A–Z": "ალბომების დალაგება ა–ჰ", + "Sort Albums by Name": "ალბომების დალაგება სახელის მიხედვით", + "Sort Contents A–Z": "შიგთავსის დალაგება ა–ჰ", + "Sort Contents A–Z (or + ▸ Sort Albums A–Z at the top level) goes back to by name. To put a folder after an open": "შიგთავსის სორტირება ა–ჰ (ან ზედა დონეზე + → ალბომების სორტირება ა–ჰ) სახელით დალაგებას აბრუნებს. საქაღალდის ღია", + "Sort by": "სორტირება:", + "Source": "წყარო", + "Source code on GitHub": "საწყისი კოდი GitHub-ზე", + "Speed": "სიჩქარე", + "Spherical": "სფერული", + "Spiral": "სპირალი", + "Split Stack": "დასტის გაყოფა", + "Spring Fresh": "გაზაფხულის სიახლე", + "Square": "კვადრატული", + "Square Grid": "კვადრატული ბადე", + "Square Grid (G toggles)": "კვადრატული ბადე (G გადართავს)", + "Square Grid: show file names and formats": "კვადრატული ბადე: ფაილების სახელებისა და ფორმატების ჩვენება", + "Stack": "დასტა", + "Stack photos taken within this time of each other:": "დასტად დაჯგუფდეს ფოტოები, რომლებიც ერთმანეთისგან ამ დროის ფარგლებში გადაიღეს:", + "Stacks": "დასტები", + "Standard": "სტანდარტული", + "Star rating (0–5)": "ვარსკვლავებით შეფასება (0–5)", + "Start at": "დაწყება:", + "Start number": "საწყისი რიცხვი", + "Starting": "იწყება", + "Starting…": "იწყება…", + "Startup": "გაშვება", + "State / Province": "შტატი / პროვინცია", + "Stop": "შეწყვეტა", + "Stop Building Previews": "წინასწარი ხედების შექმნის შეწყვეტა", + "Stop Using as Target Album": "სამიზნე ალბომად გამოყენების შეწყვეტა", + "Stopped": "შეწყდა", + "Stopped by an error (see the log)": "შეწყდა შეცდომის გამო (იხილეთ ჟურნალი)", + "Stopping…": "ჩერდება…", + "Straighten": "გასწორება", + "Straighten Tool": "გასწორების ხელსაწყო", + "Strong Contrast": "ძლიერი კონტრასტი", + "Style": "სტილი", + "Su": "კვი", + "Subfolder": "ქვესაქაღალდე", + "Subfolder name": "ქვესაქაღალდის სახელი", + "Subject": "საგანი", + "Subtract": "გამოკლება", + "Subtract from this mask": "ამ ნიღბიდან გამოკლება", + "Suggest Names for Faces": "სახეებისთვის სახელების შეთავაზება", + "Suggestions": "შეთავაზებები", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "გვთავაზობს, ვინ არის ფოტოზე, თქვენ მიერ დასახელებული სახეების მიხედვით. ყველაფერი თქვენს კომპიუტერზე რჩება.", + "Summer Haze": "ზაფხულის ბურუსი", + "Sunday": "კვირა", + "Survey": "მიმოხილვა", + "Survey (N)": "მიმოხილვა (N)", + "Swap": "გაცვლა", + "Swap Aspect (X)": "თანაფარდობის გაცვლა (X)", + "Swap Before and After": "„მანამდე“-სა და „შემდეგ“-ის გაცვლა", + "Swap Before and After Settings": "„მანამდე“-სა და „შემდეგ“-ის პარამეტრების გაცვლა", + "Swap Compare Photos": "შესადარებელი ფოტოების გაცვლა", + "Switch light / dark theme": "ღია / ბნელი თემის გადართვა", + "Switch to & Containing to see the keywords with the keywords containing them, or to Will Export to see what": "გადართეთ „და შემცველი“-ზე, რომ საკვანძო სიტყვები მათი შემცველი საკვანძო სიტყვებით ნახოთ, ან „ექსპორტირდება“-ზე, რომ ნახოთ, რას", + "Switching libraries": "ბიბლიოთეკების გადართვა", + "Sync Settings": "პარამეტრების სინქრონიზაცია", + "Sync with System": "სისტემასთან სინქრონიზაცია", + "Synchronize": "სინქრონიზაცია", + "Synchronize Folder": "საქაღალდის სინქრონიზაცია", + "Synchronize Folder…": "საქაღალდის სინქრონიზაცია…", + "Synchronizing folder": "საქაღალდის სინქრონიზაცია", + "Synchronizing…": "სინქრონიზაცია…", + "Synonyms": "სინონიმები", + "Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`, and keywords": "სინონიმების ექსპორტი. ექსპორტი ახლა საკვანძო სიტყვების სახელებს `dc:subject`-ში წერს, მათ გზებს კი `lr:hierarchicalSubject`-ში, ხოლო", + "System": "სისტემა", + "System Info": "სისტემის ინფორმაცია", + "System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK": "სისტემის ინფორმაცია, ყველა მეტამონაცემი და სიახლეები სრულდება ერთი „დახურვის“ ღილაკით, როგორც „პროგრამის შესახებ“, „გაუქმებისა“ და OK-ის ნაცვლად", + "System Info…": "სისტემის ინფორმაცია…", + "System default": "სისტემის ნაგულისხმევი", + "T) now matches.": "T-ის ნაცვლად) ახლა ემთხვევა.", + "TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode": "TIFF-ები), აღარ იხსნება ესკიზის ზომის ჩვეულებრივ გამოსახულებად. ისინი ამოიცნობა როგორც RAW-ები, რომელთა დეკოდირება LightCraft-ს", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "TZ/ZS სერია, Leica D-Lux, V-Lux და C-Lux, ასევე GH6, GH7, G9 II, S5 II და S9 თაობა. ისინი იწყება", + "Table": "ცხრილი", + "Tags": "ტეგები", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "ტეგები: იხილეთ „ტეგები“ შაბლონის გვერდით. ფაილებს სახელი დისკზე ეცვლება (მათ XMP თანმხლებ ფაილებთან ერთად); არსებულ სახელებს ემატება -1, -2…", + "Take a snapshot": "მომენტალური ასლის შექმნა", + "Targeted Adjustment": "მიზნობრივი კორექცია", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "მიზნობრივი კორექცია ჩართულია: გადაათრიეთ ფოტოზე ზემოთ ან ქვემოთ · Esc — შეწყვეტა", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "მიზნობრივი კორექცია: გადაათრიეთ ფოტოზე ზემოთ/ქვემოთ, რომ იქ ფერები შეცვალოთ", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "მიზნობრივი კორექცია: გადაათრიეთ ფოტოზე ზემოთ/ქვემოთ, რომ იქ მრუდი შეცვალოთ", + "Teal & Amber": "ზღვისფერი და ქარვისფერი", + "Teal & Orange": "ზღვისფერი და ნარინჯისფერი", + "Temp": "ტემპ.", + "Temperature": "ტემპერატურა", + "Template": "შაბლონი", + "Temporary session — your library isn't open, and nothing you do here is saved.": "დროებითი სესია — თქვენი ბიბლიოთეკა გახსნილი არ არის და აქ არაფერი ინახება.", + "Test Denoise Model": "ხმაურის მოცილების მოდელის შემოწმება", + "Test Face Model": "სახეების მოდელის შემოწმება", + "Tested and works on this computer": "შემოწმებულია და მუშაობს ამ კომპიუტერზე", + "Text": "ტექსტი", + "Text direction": "ტექსტის მიმართულება", + "Text fields": "ტექსტური ველები", + "Texture": "ტექსტურა", + "Th": "ხუთ", + "That includes the photos in the folders inside it.": "მასში შემავალ საქაღალდეებში არსებული ფოტოების ჩათვლით.", + "That key can't be used as a shortcut": "ეს კლავიში მალსახმობად ვერ გამოიყენება", + "The Export dialog has **Add to Apple Photos**, with an optional album. The files are exported to your folder as": "ექსპორტის დიალოგს აქვს **Apple Photos-ში დამატება**, არასავალდებულო ალბომით. ფაილები ჩვეულებრივად ექსპორტირდება თქვენს საქაღალდეში", + "The Hasselblad Lunar's raws (a Sony NEX-7 inside) now develop from their sensor data with every editing tool,": "Hasselblad Lunar-ის (შიგნით Sony NEX-7) RAW ფაილები ახლა სენსორის მონაცემებიდან მუშავდება ყველა რედაქტირების ხელსაწყოთი,", + "The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks": "ფოტოების იმპორტის მიმოხილვა უფრო დიდი იხსნება და მისი ზომის შეცვლა შეიძლება; ფოტოების ბადე მას ავსებს (ხარვეზი #337). Shift-დაწკაპუნება", + "The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no": "საკვანძო სიტყვების პანელი საკვანძო სიტყვების სიით სრულდება, როგორც Lightroom Classic-ში: ყველა საკვანძო სიტყვა ფოტოების რაოდენობით, მათ შორის ისეთებიც,", + "The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the": "პრესეტების პანელი აგებს მხოლოდ ხილულ რიგებს (და მათ ესკიზებს) ყოველ კადრზე ყველა პრესეტის ნაცვლად;", + "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 მოდელი დაინსტალირებულია: „ობიექტის“ და „აღწერის“ ნიღბები მზადაა.", + "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "ტონირების სლაიდერი სწორი მიმართულებით მუშაობს (ხარვეზები #188, #321): მარცხნივ მწვანეს ამატებს, მარჯვნივ მეწამულს, როგორც მისი ზოლი", + "The Windows app no longer quits at launch with \"Parent device is lost\" when another program's `dxcompiler.dll` is": "Windows-ის აპი გაშვებისას აღარ ითიშება შეტყობინებით „Parent device is lost“, როცა სხვა პროგრამის `dxcompiler.dll`", + "The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you": "Windows-ის ინსტალატორი კითხულობს, სად დაყენდეს LightCraft (ნაგულისხმევად Program Files; განახლებები თქვენ მიერ არჩეულ", + "The catalog format is now version 3 (`Album.order`, `Op::SetAlbumOrder`). Once this version has opened a library,": "კატალოგის ფორმატი ახლა მე-3 ვერსიაა (`Album.order`, `Op::SetAlbumOrder`). როგორც კი ეს ვერსია ბიბლიოთეკას გახსნის,", + "The catalog format is now version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Once this version has": "კატალოგის ფორმატი ახლა მე-4 ვერსიაა (`Catalog.folder_records`, `Op::SetFolderRecord`). როგორც კი ეს ვერსია", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "კატალოგს აქვს ფორმატის ვერსია (ხარვეზი #102). ძველი LightCraft-ის ბიბლიოთეკის გახსნა მას განაახლებს; ბიბლიოთეკა", + "The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a": "საკვანძო სიტყვების პანელის თავში ჩიპები ყველა მონიშნული ფოტოს საკვანძო სიტყვებია და არა მხოლოდ აქტიურისა;", + "The colour is described by the PNG `cICP` chunk, not an ICC profile. HDR-aware viewers (current Chromium-based": "ფერი აღწერილია PNG-ის `cICP` ნაწილით და არა ICC პროფილით. HDR-ის მცოდნე მაყურებლები (მაგ., მიმდინარე Chromium-ზე დაფუძნებული", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "მართვის პორტი (`--control`) კავშირს ხურავს, როგორც კი მიიღებს რამეს, რაც JSON მოთხოვნა არ არის", + "The copy is a 16-bit PSD by default, which PhotoCraft saves straight back to with File ▸ Save; when you come back to": "ასლი ნაგულისხმევად 16-ბიტიანი PSD-ია, რომელშიც PhotoCraft პირდაპირ ინახავს ბრძანებით ფაილი → შენახვა; როცა დაბრუნდებით", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "ხმაურის მოცილების მოდელი დაინსტალირებული და გამოყენებაშია: AI ხმაურის მოცილების სიძლიერის სლაიდერი „დეტალში“ ახლა მუშაობს", + "The desktop app keeps a log file: `logs/lightcraft.log` in its settings folder (Linux `~/.config/lightcraft/logs/`),": "სამუშაო მაგიდის აპი ჟურნალის ფაილს ინახავს: `logs/lightcraft.log` თავის პარამეტრების საქაღალდეში (Linux `~/.config/lightcraft/logs/`),", + "The download didn't work:": "ჩამოტვირთვა ვერ მოხერხდა:", + "The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,": "სინათლის, ფერის და დეტალის სექციების სათაურებზე თვალი ახლა მათ კორექციებს მალავს, როგორც უკვე ხდებოდა ეფექტების,", + "The first time, macOS asks whether LightCraft may control Photos.": "პირველად macOS იკითხავს, შეუძლია თუ არა LightCraft-ს Photos-ის მართვა.", + "The first time, macOS asks whether LightCraft may control Photos. Photos may ask about duplicates; files it skips": "პირველად macOS იკითხავს, შეუძლია თუ არა LightCraft-ს Photos-ის მართვა. Photos-მა შეიძლება დუბლიკატებზე იკითხოს; ფაილები, რომლებსაც გამოტოვებს,", + "The graphics card is set up when the next photo is made.": "ვიდეობარათი გაიმართება, როცა შემდეგი ფოტო დამუშავდება.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "იმპორტი კატალოგს კითხულობს მისი შეცვლის გარეშე და მის ფოტოებს, ცვლილებებს, საკვანძო სიტყვებსა და კოლექციებს ახლა გახსნილ ბიბლიოთეკას ამატებს.", + "The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,": "ინტერფეისი ხელმისაწვდომია ესპანურად (ხარვეზი #371), გერმანულად და რუსულად (რედაქტირება → ენა), ინგლისურის,", + "The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share": "შენახული ორიგინალი აღარ აძევებს ქეშიდან ყველაფერ დანარჩენს: 64 MP RAW-ზე ის ქეშის წილს", + "The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste": "საკვანძო სიტყვების ნაკრების ღილაკები და შეთავაზებებიც მონიშნულს მიჰყვება; ასაკრეფ ველებს აქვს ამოჭრა / კოპირება / ჩასმის", + "The library is up to date with this folder.": "ბიბლიოთეკა ამ საქაღალდესთან სინქრონიზებულია.", + "The loupe says \"Rendering…\" at the bottom of the photo when what it shows isn't the finished photo yet: the": "ლუპა ფოტოს ქვედა ნაწილში წერს „რენდერდება…“, როცა ის, რასაც აჩვენებს, ჯერ დასრულებული ფოტო არ არის:", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "მოდელი მხოლოდ ამ კომპიუტერზე ინახება. LightCraft მას არასოდეს ატვირთავს და არ აზიარებს.", + "The number at the end of the original name (IMG_0042 → 0042)": "რიცხვი ორიგინალი სახელის ბოლოს (IMG_0042 → 0042)", + "The open library's name shows under My Photos at the top of the left panel and in the window title.": "გახსნილი ბიბლიოთეკის სახელი ჩანს „ჩემი ფოტოების“ ქვეშ, მარცხენა პანელის ზედა ნაწილში, და ფანჯრის სათაურში.", + "The original extension is always added; {ext} only puts it inside the name as well.": "ორიგინალი გაფართოება ყოველთვის ემატება; {ext} მას დამატებით სახელშიც სვამს.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "ორიგინალი ფაილები უცვლელად, თითოეული XMP თანმხლები ფაილით, რომელიც მის ცვლილებებს ინახავს.", + "The photo has these settings": "ფოტოს ეს პარამეტრები აქვს", + "The processor does the work.": "სამუშაოს პროცესორი ასრულებს.", + "The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An": "წესების რედაქტორი თითოეულ გასასწორებელ წესს უშუალოდ მის ქვეშ აღნიშნავს და OK-ს გამორთულს ტოვებს, სანამ არ გასწორდება.", + "The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then": "წესების რედაქტორის ველების მენიუ დაჯგუფებულია: შეფასება, რჩეულის დროშა, ფერადი იარლიყი და ნებისმიერი საძიებო ტექსტი თავში, შემდეგ", + "The starting look fitted from a raw's camera JPEG now uses its darkest tones too (issue #1307).": "RAW-ის კამერის JPEG-იდან მორგებული საწყისი იერი ახლა მის ყველაზე მუქ ტონებსაც იყენებს (ხარვეზი #1307).", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "ესკიზების ქეში მხოლოდ საკუთარ ფაილებს ითვლის და შლის (ხარვეზი #98): საქაღალდეზე გახსნილი ბიბლიოთეკა, რომელიც", + "The top bar's appearance button cycles Auto, Light, Dark (a monitor, sun or moon), and View ▸": "ზედა ზოლის იერსახის ღილაკი მონაცვლეობს: ავტომატური, ღია, ბნელი (მონიტორი, მზე ან მთვარე), ხედი →", + "Their photos and the keywords below them come together under one keyword.": "მათი ფოტოები და მათ ქვეშ მყოფი საკვანძო სიტყვები ერთი საკვანძო სიტყვის ქვეშ ერთიანდება.", + "Theme": "თემა", + "There is no model file to install": "დასაინსტალირებელი მოდელის ფაილი არ არის", + "They can be restored from Recently Deleted until it is emptied.": "მათი აღდგენა შესაძლებელია „ახლახან წაშლილიდან“, სანამ ის არ დაცარიელდება.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "ისინი „ახლახან წაშლილში“ გადადის და შეიძლება აღდგეს; დისკზე არცერთ ფაილს არ ეხება.", + "Thirds": "მესამედები", + "This Computer": "ეს კომპიუტერი", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "ამ ვერსიას მოდელის ჩამოტვირთვა ჯერ არ შეუძლია. ხელით დასაინსტალირებლად ზემოთ მითითებულ საქაღალდეში მოათავსეთ model.safetensors, vocab.json და merges.txt Meta-ს facebook/sam3-დან: „ობიექტი“ და „აღწერა“ იქ მათი გაჩენისთანავე იმუშავებს.", + "This build cannot run denoise models.": "ამ ვერსიას ხმაურის მოცილების მოდელების გაშვება არ შეუძლია.", + "This build cannot run recognition models: they can be added and chosen, not used.": "ამ ვერსიას ამოცნობის მოდელების გაშვება არ შეუძლია: მათი დამატება და არჩევა შეიძლება, გამოყენება — არა.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "ამ ვერსიას ხმაურის მოცილების მოდელების შესანახი ადგილი არ აქვს, ამიტომ AI ხმაურის მოცილება მიუწვდომელია. (სამაგიდო აპლიკაციას აქვს.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "ამ ვერსიას სახეების მოდელების შესანახი ადგილი არ აქვს, ამიტომ მოდელების სია მიუწვდომელია. (სამაგიდო აპლიკაციას აქვს.)", + "This file cannot be used yet": "ამ ფაილის გამოყენება ჯერ შეუძლებელია", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "ეს საქაღალდე მიუწვდომელია (დისკი გამოერთებულია?). ჭკვიანი წინასწარი ხედები არ იქმნება და არ გამოიყენება, სანამ ის არ დაბრუნდება ან სხვა საქაღალდეს არ აირჩევთ.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "ეს საქაღალდე აქ არ არის: ის გადაიტანეს ან გადაარქვეს, ან მისი დისკი არ არის მიერთებული.", + "This group is empty: add a rule or remove it.": "ეს ჯგუფი ცარიელია: დაამატეთ წესი ან წაშალეთ ის.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "ეს Lightroom-ის კატალოგია და არა LightCraft-ის ბიბლიოთეკა. გსურთ მისი LightCraft-ის ბიბლიოთეკაში იმპორტი?", + "This is the album you're editing": "ეს ის ალბომია, რომელსაც არედაქტირებთ", + "This photo can't be opened": "ამ ფოტოს გახსნა შეუძლებელია", + "Threshold": "ზღვარი", + "Thumbnail cache": "ესკიზების ქეში", + "Thursday": "ხუთშაბათი", + "Tick the box to accept the model's terms first": "ჯერ მონიშნეთ ველი მოდელის პირობების მისაღებად", + "Time zone": "სასაათო სარტყელი", + "Tint": "ტონირება", + "Title": "დასახელება", + "Title metadata": "დასახელების მეტამონაცემები", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "თუ ამ რედაქტორს ასლის ნაცვლად თავად ორიგინალი ფაილი უნდა გადაეცეს (RAW, ასლის გარეშე: მაგალითად, DxO PhotoLab), ფოტოზე მარჯვენა ღილაკით დაწკაპეთ → რედაქტირება სხვა პროგრამაში → ორიგინალის გახსნა სხვა რედაქტორში (RAW, ასლის გარეშე).", + "Toggle Keyword from Set": "ნაკრებიდან საკვანძო სიტყვის გადართვა", + "Toggle Mask": "ნიღბის გადართვა", + "Toggle Section": "სექციის გადართვა", + "Toggle Zoom": "მასშტაბის გადართვა", + "Told when there is no GPU": "შეტყობინება, როცა GPU არ არის", + "Tone Curve": "ტონალური მრუდი", + "Tools": "ხელსაწყოები", + "Top Level": "ზედა დონე", + "Trackpads: pinch to zoom around the pointer and scroll with two fingers to pan the photo; panning keeps the photo": "თაჩპედები: მასშტაბისთვის მაჩვენებლის გარშემო მოჭერით შეკუმშეთ, ფოტოს გადასაადგილებლად კი ორი თითით გადაახვიეთ; გადაადგილებისას ფოტო", + "Transfer": "გადაცემა", + "Treatment": "დამუშავება", + "Triangle": "სამკუთხედი", + "Try Again": "ხელახლა ცდა", + "Try Saving Again": "შენახვის ხელახლა ცდა", + "Try again": "ხელახლა ცდა", + "Try the graphics card again": "ვიდეობარათის ხელახლა ცდა", + "Tu": "სამ", + "Tuesday": "სამშაბათი", + "Tungsten": "ვარვარების ნათურა", + "Turn Off": "გამორთვა", + "Turn off": "გამორთვა", + "Turn on Include subfolders, or pick another folder under Local": "ჩართეთ „ქვესაქაღალდეების ჩათვლით“ ან აირჩიეთ სხვა საქაღალდე ლოკალურში", + "Type": "ტიპი", + "Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)": "აკრიფეთ ზუსტი მნიშვნელობა ნებისმიერ სლაიდერში (ხარვეზი #322): დაწკაპეთ მისი სახელის გვერდით რიცხვზე, აკრიფეთ (`1.5`, `-20`, `5600`)", + "Uncheck All": "ყველას მონიშვნის მოხსნა", + "Uncheck the files of this type": "ამ ტიპის ფაილების მონიშვნის მოხსნა", + "Undo": "დაბრუნება", + "Unedited": "დაუმუშავებელი", + "Unedited raws": "დაუმუშავებელი RAW-ები", + "Unflag": "დროშის მოხსნა", + "Unflagged": "დროშის გარეშე", + "Ungroup Stack": "დასტის დაშლა", + "Unknown": "უცნობი", + "Unknown Date": "უცნობი თარიღი", + "Unknown licence": "უცნობი ლიცენზია", + "Unknown tags stay as typed — check the preview for a {typo}.": "უცნობი ტეგები რჩება ისე, როგორც აკრიფეთ — გადახედვაში შეამოწმეთ {typo}.", + "Unnamed Faces": "უსახელო სახეები", + "Unrated": "შეუფასებელი", + "Unreadable": "წაუკითხავი", + "Update": "განახლება", + "Update Mask Shape": "ნიღბის ფორმის განახლება", + "Update Preset with Current Settings": "პრესეტის განახლება მიმდინარე პარამეტრებით", + "Update Rules from Current Filter": "წესების განახლება მიმდინარე ფილტრიდან", + "Update Version": "ვერსიის განახლება", + "Update Version with Current Settings": "ვერსიის განახლება მიმდინარე პარამეტრებით", + "Update to Current Process": "მიმდინარე პროცესის ვერსიაზე განახლება", + "Update with Current Settings": "განახლება მიმდინარე პარამეტრებით", + "Updated the edits saved in the external editor": "გარე რედაქტორში შენახული ცვლილებები განახლდა", + "Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed": "0.6-ზე ძველი Windows-ის ინსტალერიდან (მაგ., 0.5.0) განახლებისას LightCraft რჩება იმ საქაღალდეში, სადაც", + "Upright": "Upright", + "Urban": "ქალაქური", + "Use": "გამოყენება", + "Use Keyword Set": "საკვანძო სიტყვების ნაკრების გამოყენება", + "Use Library Folder": "ბიბლიოთეკის საქაღალდის გამოყენება", + "Use Screen Profile": "ეკრანის პროფილის გამოყენება", + "Use camera-specific defaults": "კამერისთვის განკუთვნილი ნაგულისხმევი პარამეტრების გამოყენება", + "Use sRGB": "sRGB-ის გამოყენება", + "Use the GPU for rendering": "რენდერირებისთვის GPU-ის გამოყენება", + "User Presets": "მომხმარებლის პრესეტები", + "Username": "მომხმარებლის სახელი", + "Value": "მნიშვნელობა", + "Variance": "დისპერსია", + "Version": "ვერსია", + "Version name": "ვერსიის სახელი", + "Versions": "ვერსიები", + "Versions (Shift+V)": "ვერსიები (Shift+V)", + "Vertical": "ვერტიკალური", + "Vertical inset (% of short edge)": "ვერტიკალური დაშორება (მოკლე გვერდის %)", + "Vertical text": "ვერტიკალური ტექსტი", + "Vibrance": "სიხალასე", + "Video": "ვიდეო", + "Video Duration": "ვიდეოს ხანგრძლივობა", + "Videos": "ვიდეოები", + "View": "ხედი", + "Viewing and sharing": "ნახვა და გაზიარება", + "Vignette": "ვინიეტი", + "Vignetting": "ვინიეტირება", + "Vintage": "ვინტაჟი", + "Virtual Copy": "ვირტუალური ასლი", + "Visible": "ხილული", + "Visualize HDR": "HDR-ის ვიზუალიზაცია", + "Visualize Spots": "ლაქების ვიზუალიზაცია", + "Visualize Spots (A)": "ლაქების ვიზუალიზაცია (A)", + "Visualize range": "დიაპაზონის ვიზუალიზაცია", + "Vivid": "ცოცხალი", + "Vivid Pop": "ცოცხალი და მკვეთრი", + "Wait for the import to finish": "დაელოდეთ იმპორტის დასრულებას", + "Waiting to start…": "დაწყების მოლოდინში…", + "Warm Film": "თბილი ფირი", + "Warm Glow": "თბილი ნათება", + "Warm Print": "თბილი ანაბეჭდი", + "Warm Table": "თბილი მაგიდა", + "Watched folder": "თვალთვალის ქვეშ მყოფი საქაღალდე", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "ავტოიმპორტი თვალთვალის ქვეშ მყოფი საქაღალდიდან; DNG-ად გარდაქმნა; დუბლირება; სტანდარტული / 1:1 / ჭკვიანი წინასწარი ხედების შექმნა.", + "Watermark": "ჭვირნიშანი", + "We": "ოთხ", + "Wednesday": "ოთხშაბათი", + "What's New": "რა არის ახალი", + "When GPU rendering is on but no graphics processor can be found, LightCraft now says so once at start-up instead": "როცა GPU რენდერი ჩართულია, მაგრამ გრაფიკული პროცესორი ვერ მოიძებნა, LightCraft ახლა ამას გაშვებისას ერთხელ გეუბნებათ, ნაცვლად იმისა,", + "When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):": "როცა საქაღალდეში რამდენიმე ტიპის ფაილია, ფოტოების იმპორტის მიმოხილვას თითო ტიპზე გადამრთველი აქვს (`ARW · 120`, `JPG · 120`):", + "When rated or hovered": "შეფასებისას ან კურსორის მიტანისას", + "White Balance": "თეთრის ბალანსი", + "White Balance Selector": "თეთრის ბალანსის ამრჩევი", + "White Balance Selector (W)": "თეთრის ბალანსის ამრჩევი (W)", + "White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the": "თეთრის ბალანსი DNG-ებზე (და ფერის მატრიცის მქონე სხვა RAW-ებზე) ფოტოს ახალი თეთრისთვის ხელახლა ამუშავებს", + "Whites": "თეთრები", + "Width": "სიგანე", + "Width (% of photo)": "სიგანე (ფოტოს %)", + "Width (px)": "სიგანე (პქს)", + "Width × Height": "სიგანე × სიმაღლე", + "Will Export": "ექსპორტირდება", + "Will be replaced": "ჩანაცვლდება", + "Will be restored": "აღდგება", + "Window": "ფანჯარა", + "Winter Blue": "ზამთრის ლურჯი", + "With **HDR output** on, a photo edited in HDR can now be exported as a **16-bit PNG** with the PQ curve in Rec. 2020,": "**HDR გამოტანის** ჩართვისას HDR-ში დამუშავებული ფოტოს ექსპორტი ახლა შესაძლებელია **16-ბიტიან PNG-ად** PQ მრუდით Rec. 2020-ში,", + "With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later,": "GPU-ით სლაიდერის გადათრევის წინასწარი ხედი იმავე კადრში იხატება, რომელშიც სლაიდერი მოძრაობს, და არა ერთი კადრით გვიან,", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "შენახვის გარეშე გასვლა (ხარვეზი #100). ის აღარ იწყებს ჩუმად სადემონსტრაციო სესიას, რომელიც ჩამოყრილ ბიბლიოთეკას ჰგავდა და", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "მუშაობს სტანდარტული (Bayer) სენსორის მქონე კამერების RAW ფაილებზე. სხვა ფაილებს ხმაურის ჩვეულებრივი შემცირება რჩება.", + "XMP Sidecar Preferences": "XMP თანმხლები ფაილების პარამეტრები", + "XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel": "XMP პრესეტები წითელ / მწვანე / ლურჯ მრუდებს მხოლოდ მაშინ იყენებს, როცა მათ მთავარი მრუდიც და სამივე არხის", + "XMP sidecars": "XMP თანმხლები ფაილები", + "Year": "წელი", + "Yellow": "ყვითელი", + "Yellow Hue": "ყვითლის ფერის ტონი", + "Yellow Luminance": "ყვითლის სიკაშკაშე", + "Yellow Saturation": "ყვითლის გაჯერება", + "Yellow label": "ყვითელი იარლიყი", + "Yes": "დიახ", + "Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `\"false\"` now": "დიახ/არა ველები (აქვს ცვლილებები, ჩამოჭრილი, აქვს GPS…) აჩვენებს დიახ / არა-ს, ნაცვლად true / false-ისა. `\"false\"`-ად გაგზავნილი წესი ახლა", + "You can close this: the download continues, and resumes if interrupted.": "შეგიძლიათ დახუროთ: ჩამოტვირთვა გაგრძელდება და შეწყვეტის შემთხვევაში განახლდება.", + "Your Name": "თქვენი სახელი", + "Your library couldn't be opened": "თქვენი ბიბლიოთეკის გახსნა ვერ მოხერხდა", + "Zoom": "მასშტაბი", + "Zoom 100%": "მასშტაბი 100%", + "Zoom In": "გადიდება", + "Zoom Out": "დაპატარავება", + "Zoom to Fit": "ეკრანში ჩატევა", + "Zoom to page level": "გვერდის დონემდე მასშტაბირება", + "Zoomed 64 MP raws stay decoded": "გადიდებული 64 MP RAW ფაილები დეკოდირებული რჩება", + "Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy": "გადიდება, როცა „მანამდე/შემდეგ“ გვერდიგვერდაა, ორიგინალს ერთხელ დეკოდირებს: „მანამდე“ მხარე ადრე საკუთარ ასლს დეკოდირებდა", + "[camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).": "[კამერის წინასწარი ხედის ფერი](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).", + "`*.jpg` case-sensitively, so `.JPG` and `.NEF` files were hidden — issues #342 and #705).": "`*.jpg`-ს რეგისტრის გათვალისწინებით ადარებდა, ამიტომ `.JPG` და `.NEF` ფაილები დამალული იყო — ხარვეზები #342 და #705).", + "`--import <folder>` takes HEIC": "`--import <folder>` HEIC-ს იღებს", + "`--memory` sessions keep their promise to save nothing (issues #164, #169): UI changes made in one no longer": "`--memory` სესიები პირობას ასრულებს, არაფერი შეინახონ (ხარვეზები #164, #169): მათში შეტანილი UI ცვლილებები აღარ", + "`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The": "`.lrcat-data` საქაღალდეებიდან ერთ-ერთის, ან `.lrcat`-ის) მის შიგნით აღარ ქმნის ცარიელ LightCraft-ის ბიბლიოთეკას (ხარვეზი #891).", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) მათ XMP თანმხლებ ფაილებთან ერთად; თითოეული ორიგინალი ბარათს მხოლოდ მაშინ ტოვებს, როცა მისი", + "`Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.": "`Shift+6–9` იარლიყს ანიჭებს და გადადის შემდეგზე. `Shift+P` ფოტოების ბადესა და კვადრატულ ბადეში რჩეულად მონიშნავს და გადადის შემდეგზე; სხვა ხედებში პრესეტებს ხსნის. ავტომატური გადასვლის ჩართვისას Shift მაინც მხოლოდ ერთხელ გადადის.", + "`export.photosImports`. See [Add to Apple Photos](apple-photos.md).": "`export.photosImports`. იხ. [Apple Photos-ში დამატება](apple-photos.md).", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; რეგისტრის მიმართ არამგრძნობიარე ტომებზე რეგისტრის ცვლილება მაინც სრულდება.", + "`lightcraft-cli --library` given a library's own file (`catalog.snap`) opens its folder instead of failing with": "`lightcraft-cli --library`, თუ მას ბიბლიოთეკის საკუთარ ფაილს (`catalog.snap`) მისცემთ, მის საქაღალდეს ხსნის, ნაცვლად შეცდომისა", + "`lightcraft-cli run --import <folder>` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW": "`lightcraft-cli run --import <folder>` (და MCP ხელსაწყოები, რომლებიც საქაღალდეებს იღებს) ახლა HEIC, HEIF და Nikon NRW", + "`lightcraft-cli` logs warnings on stderr too (issue #168); `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level.": "`lightcraft-cli` გაფრთხილებებს stderr-ზეც წერს (ხარვეზი #168); `LIGHTCRAFT_LOG` ან `RUST_LOG` სხვა დონეს ირჩევს.", + "`publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`).": "`publicDomain` (წესები და აგენტები კვლავ იდენტიფიკატორებს იყენებს; `album.ruleFields` ამატებს `choiceLabels`-ს).", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "სავსე დისკი) შექმნილად აღარ ითვლება და ჭკვიანი წინასწარი ხედების შექმნა მას ჩაანაცვლებს.", + "a line, a tab per level, synonyms in braces, keywords left out of export in brackets). Capture One, darktable, Adobe": "თითო სტრიქონზე, თითო ტაბულაცია დონეზე, სინონიმები ფიგურულ ფრჩხილებში, ექსპორტიდან გამორიცხული საკვანძო სიტყვები კვადრატულ ფრჩხილებში). Capture One, darktable, Adobe", + "a literal %": "სიტყვასიტყვითი %", + "a removed photo": "ამოშლილი ფოტო", + "adjust or switch them off, and DNG export keeps them. Lateral chromatic aberration data is not applied: our": "მათ არეგულირებს ან გამორთავს, DNG ექსპორტი კი ინარჩუნებს. ლატერალური ქრომატული აბერაციის მონაცემები არ გამოიყენება: ჩვენი", + "after a rename, an `.XMP` sidecar. Adding photos or a folder, Browse Folder (Local), drag-and-drop, the CLI and the": "გადარქმევის შემდეგ, `.XMP` თანმხლები ფაილი. ფოტოების ან საქაღალდის დამატება, საქაღალდის დათვალიერება (ლოკალური), გადათრევა, CLI და", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "შემდეგ (ხარვეზი #101): ნაწილობრივი ჩანაწერი იკვეთება LightCraft-ის ხელახალ ცდამდე, ასე რომ შემდეგი გაშვება ხელახლა ასრულებს ყოველ", + "again.": "ხელახლა დეკოდირდეს.", + "again. Change the period (or turn it off) in Settings → Performance.": "ხელახლა. პერიოდის შეცვლა (ან გამორთვა) შეგიძლიათ აქ: პარამეტრები → წარმადობა.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "ბიბლიოთეკის ორიგინალებთან შედარება აღარ ათვალიერებს მთელ ბიბლიოთეკას, როცა ამ მისამართზე არაფერია.", + "album that is gone, or an empty group is reported with its rule (\"rule 2.1: no rating 0–5 is 9\")": "გამქრალი ალბომი ან ცარიელი ჯგუფი მოხსენებულია თავისი წესით („წესი 2.1: შეფასება 0–5 არ არის 9“)", + "all": "ყველა", + "alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every": "მარტო ავსებდა, ამიტომ ფოტოს წინასწარი ხედისა და ესკიზის წყაროები, ისევე როგორც მეზობლებისა, RAW-იდან ხელახლა დეკოდირდებოდა", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "უკვე აქვს სხვა სურათების `thumbs/` საქაღალდე, ქეშის შემცირებისას ან გასუფთავებისას მათ აღარ კარგავს.", + "and": "და", + "and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.": "და CR3, ბრტყლად და გაუფერულებულად გახსნის ნაცვლად (ხარვეზი #310). უკვე იმპორტირებული ფოტოები მას ხელახალი რენდერისას იღებს.", + "and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their": "და WB2000. შეკუმშულები (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) კვლავ თავისი", + "and a dark theme card, each previewing its theme (dark: Charcoal, the look so far, or Midnight;": "და ბნელი თემის ბარათების თავზე, თითოეული თავის თემას აჩვენებს (ბნელი: ნახშირი — აქამდელი იერი, ან შუაღამე;", + "and a section with nothing in it says so instead of disappearing.": "ცარიელი სექცია კი ამას ამბობს და არ ქრება.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "და გამოყენებამდე ამოწმებს ფაილს. ნიღბები ინარჩუნებს მონიშნულს, ამიტომ მოდელის გარეშეც რენდერდება და ექსპორტირდება.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "და ყველა იმპორტირებულ ფოტოს ერთ zip-ად, ხოლო ფაილი → ბიბლიოთეკის აღდგენა სარეზერვო ასლიდან… მას აბრუნებს (მიმდინარე ბიბლიოთეკა", + "and exports don't change. Agents use `app.displayProfile`. See [display profiles](display-profiles.md).": "და ექსპორტი არ იცვლება. აგენტები იყენებენ `app.displayProfile`-ს. იხ. [ეკრანის პროფილები](display-profiles.md).", + "and its Cancel / OK buttons were off screen, so it couldn't be confirmed. Now its options scroll and the title and": "და მისი ღილაკები „გაუქმება“ / OK ეკრანს გარეთ იყო, ამიტომ დადასტურება შეუძლებელი იყო. ახლა პარამეტრები გადაიხვევა, სათაური და", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "და ფანჯარას არასოდეს ყინავს. ის არასავალდებულოა და LightCraft-ის ნაწილი არ არის (Meta-ს SAM ლიცენზია, დაახლოებით 3.4 GB):", + "and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere.": "და ჩაშენებული დეფორმაციის / ვინიეტირების კორექციების მქონე სხვა ფაილები), რაც პრესეტის სხვაგან ქცევას ემთხვევა.", + "and press Return; Esc keeps the old value.": "და დააჭირეთ Return-ს; Esc ძველ მნიშვნელობას ტოვებს.", + "and the fit to the photo's own JPEG still come first, so photos that had a fitted look keep it. See": "და ფოტოს საკუთარ JPEG-ზე მორგება კვლავ უპირატესია, ასე რომ მორგებული იერის მქონე ფოტოები მას ინარჩუნებს. იხ.", + "any": "ნებისმიერი", + "app, and with LightCraft fullscreen it was hidden altogether, so Import Photos… looked like a hang until the": "აპის გვერდით აყენებდნენ, სრულეკრანიან LightCraft-თან კი ის საერთოდ დამალული იყო, ასე რომ ფოტოების იმპორტი… გაჭედვას ჰგავდა, სანამ", + "are empty": "ცარიელია", + "are unchanged; photos imported before this change get it when imported again.": "უცვლელია; ამ ცვლილებამდე იმპორტირებული ფოტოები მას ხელახალი იმპორტისას იღებს.", + "aren't empty": "არ არის ცარიელი", + "as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long": "როგორც 1/250), ფაილის გაფართოება, ვიდეოს ხანგრძლივობა, ასლის სახელი, დასტაში, ალტერნატიული ტექსტი, ქალაქი, შტატი / პროვინცია, ქვეყანა, გრძელი", + "as Exposure would; the edges between bright and dark regions stay as sharp as before. This is rendering process 2:": "ისე, როგორც ექსპოზიცია; ნათელ და მუქ არეებს შორის კიდეები ისევე მკვეთრი რჩება. ეს რენდერის მე-2 პროცესია:", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "როგორც `<name>.corrupt-<time>` და გეცნობებათ; ფაილი, რომლის წაკითხვაც ვერ ხერხდება (მაგ., სხვა პროგრამის მიერ დაბლოკილი), ხელუხლებელი რჩება", + "as a raw and shows its embedded preview (preview only, with the reason), as the compressed MOS files do.": "როგორც RAW და აჩვენებს ჩაშენებულ წინასწარ ხედს (მხოლოდ წინასწარი ხედი, მიზეზით), როგორც შეკუმშული MOS ფაილები.", + "as it changes, without polling.": "მისი ცვლილებისას, გამოკითხვის გარეშე.", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "ამას არ მოითხოვთ. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (ან wgpu-ის `WGPU_BACKEND`, რომელსაც GPU რენდერირება", + "before, and becomes the Kelvin value with the same look the next time it is edited. A photo imported earlier shows": "ადრე, და შემდეგი რედაქტირებისას იმავე იერის მქონე კელვინის მნიშვნელობად იქცევა. ადრე იმპორტირებული ფოტო აჩვენებს", + "behaviour, `fresh` imports a clean copy and deletes the old record).": "ქცევას, `fresh` იმპორტს უკეთებს სუფთა ასლს და ძველ ჩანაწერს შლის).", + "belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.": "სხვა ბრძანებას ეკუთვნოდა, ახალზე გადადის. მენიუები ახალ კლავიშებს აჩვენებს; აგენტები იყენებენ `app.setShortcut`-ს.", + "beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling)": "„შემდეგის“ ასლის გვერდით, ხოლო ფანჯარა, რომელიც ხელახლა მოითხოვებოდა, სანამ პირველი ჯერ კიდევ დეკოდირდებოდა (გადაადგილება, განლაგების დასტაბილურება),", + "beside them is; they used to stay at regular weight.": "ლათინური ტექსტი; ადრე ისინი ჩვეულებრივი სისქით რჩებოდა.", + "blue": "ლურჯი", + "browsers, for example) show it in HDR; **viewers that don't read `cICP` show the PQ values as if they were sRGB**,": "ბრაუზერები) მას HDR-ში აჩვენებს; **მაყურებლები, რომლებიც `cICP`-ს არ კითხულობს, PQ მნიშვნელობებს ისე აჩვენებს, თითქოს sRGB იყოს**,", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "მათგან ნებისმიერის პაკეტები — მთელი საქაღალდეები ერთბაშად, პაკეტების მიხედვით დაჯგუფებული. პრესეტებში არსებული ნიღბებიც გადმოდის.", + "burst by file name, so the first file is the cover.": "სერიას ფაილის სახელით ალაგებს, ასე რომ პირველი ფაილი ყდაა.", + "buttons stay visible. Long lists in dialogs (What's New, All Metadata, Keyboard Shortcuts) get shorter to fit.": "ღილაკები კი ხილული რჩება. დიალოგებში გრძელი სიები (რა არის ახალი, ყველა მეტამონაცემი, კლავიატურის მალსახმობები) მოკლდება, რომ დაეტიოს.", + "by double-clicking it, delete one with − after a confirmation. Right-click for more: create inside, Put New Keywords": "ორმაგი დაწკაპუნებით, წაშლა −-ით დადასტურების შემდეგ. მარჯვენა დაწკაპუნებით მეტი: შიგნით შექმნა, ახალი საკვანძო სიტყვების", + "cached picture": "ქეშირებული სურათი", + "cached pictures": "ქეშირებული სურათი", + "camera JPEG can't be fitted no longer open with the neutral fallback: they start from colour matrices fitted to the": "კამერის JPEG-ზე მორგება შეუძლებელია, აღარ იხსნება ნეიტრალური სათადარიგო იერით: ისინი იწყება ფერის მატრიცებით, მორგებული", + "camera JPEG. Photos already imported pick the change up on Reload.": "კამერის JPEG-იდან იხსნება. უკვე იმპორტირებული ფოტოები ცვლილებას ხელახალი ჩატვირთვისას იღებს.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "კამერაში, აღარ ჩაიწვება შედეგში. „მხოლოდ წინასწარი ხედის“ სახით უკვე იმპორტირებული ფოტოები ხელახლა ჩატვირთვისას გადაერთვება. (ფაილები, რომელთა", + "camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame": "კამერის ჩაშენებული JPEG, სანამ RAW დეკოდირდება, წინა რენდერი, სანამ რედაქტირება რენდერდება, ან გადიდებული კადრი,", + "camera's measured spectral sensitivities, from the Academy Software Foundation's rawtoaces-data. A camera profile": "კამერის გაზომილ სპექტრულ მგრძნობიარობაზე, Academy Software Foundation-ის rawtoaces-data-დან. კამერის პროფილი", + "camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey": "კამერის საკუთარი ფერის მატრიცებით, როგორც Lightroom, გადაღებისას რენდერის ფერების წანაცვლების ნაცვლად: არჩეული", + "camera's own zoom to fill the frame) and vignetting, as with Panasonic and Sony files. The Optics profile controls": "კამერის საკუთარი მასშტაბირებით კადრის შესავსებად) და ვინიეტირება, როგორც Panasonic-ისა და Sony-ის ფაილებში. ოპტიკის პროფილის მართვის ელემენტები", + "can be attached to a report without hunting for the settings folder (issue #260).": "ანგარიშს შეიძლება დაერთოს პარამეტრების საქაღალდის ძებნის გარეშე (ხარვეზი #260).", + "can't be picked, such as one that would make an album include itself, are greyed with the reason. Esc with a dropdown or calendar open in a dialog": "არჩევა შეუძლებელია, მაგ., ის, რომელიც ალბომს საკუთარი თავის ჩართვას აიძულებდა, ნაცრისფრადაა მიზეზით. Esc დიალოგში გახსნილი ჩამოსაშლელი სიით ან კალენდრით", + "can't be tested that way": "ამ გზით შემოწმება შეუძლებელია", + "can't match any rating from 0 to 5": "ვერ დაემთხვევა ვერცერთ შეფასებას 0-დან 5-მდე", + "can't test a folder; choose an album in it": "საქაღალდეს ვერ ამოწმებს; აირჩიეთ მასში არსებული ალბომი", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "ცვლილებას. კატალოგები, რომლებიც უკვე შეიცავს ასეთ ფრაგმენტს, სრულად იტვირთება. გასვლა, როცა კატალოგის ჟურნალის ჩაწერა შეუძლებელია,", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "ცვლილებების შენახვა ვერ მოხერხდა, კიდევ ერთხელ ცდის, შემდეგ კი გეკითხებათ: ხელახლა შენახვის ცდა, მაინც გასვლა ან გაუქმება.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "არხიდან ან MCP-დან, გაერთიანების წინასწარი ხედის მისამართი ან `lightcraft-cli render IMG.jpg -o IMG.jpg` უარყოფილია მკაფიო", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "მოწმდება ბარათიდან წაკითხული შიგთავსის მიხედვით. ასლი, რომელიც ვერ შეიქმნა ან განსხვავდება, იშლება და აღირიცხება როგორც წარუმატებელი", + "choice next to that (Restore them / Import as new / Leave them) now starts on Restore. Files dropped on the window": "მის გვერდით არჩევანი (მათი აღდგენა / იმპორტი ახლის სახით / მათი დატოვება) ახლა აღდგენით იწყება. ფანჯარაზე ჩაგდებული ფაილები", + "choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently": "აირჩიეთ, რა უნდა მოხდეს: ახალი ფოტოების იმპორტი და გადატანილების ხელახლა დაკავშირება (ნაგულისხმევად), დაკარგულების გადატანა ახლახან", + "choosing a profile file.": "პროფილის ფაილის არჩევის შემდეგ.", + "chose) and ends on a page saying it was installed, with a \"Launch LightCraft\" box. It used to finish without a word,": "საქაღალდეს ინარჩუნებს) და სრულდება გვერდით, სადაც წერია, რომ დაყენდა, „Launch LightCraft“ ველით. ადრე ის უსიტყვოდ სრულდებოდა,", + "closes just that.": "მხოლოდ მას ხურავს.", + "codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP.": "codecs-ის `heif` ფუნქციის მიღმა — 8- და 10-ბიტიანი, ალფა, ბადის ფილები, კონტეინერის შემობრუნება/არეკვლა/ჩამოჭრა, ICC, EXIF და XMP.", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "ფერითა და ტონით, რომელიც კამერის საკუთარ JPEG-ს ერგება, როგორც Nikon-ისა და Sony-ის RAW ფაილებში, თანაფარდობით, რომელიც დაყენებულია", + "colours are right on wide-gamut and calibrated displays instead of oversaturated. The Detail view renders straight": "ფართო დიაპაზონის და კალიბრებულ ეკრანებზე ფერები სწორია და არა ზედმეტად გაჯერებული. დეტალური ხედი პირდაპირ ეკრანის", + "comma, separated": "მძიმით, გამოყოფილი", + "commercial use allowed": "კომერციული გამოყენება დაშვებულია", + "commercial use subject to GPL conditions": "კომერციული გამოყენება GPL-ის პირობებით", + "compiler built into Windows (FXC). `WGPU_DX12_COMPILER=dxc` uses a `dxcompiler.dll` instead.": "Windows-ში ჩაშენებული კომპილატორით (FXC). `WGPU_DX12_COMPILER=dxc` ამის ნაცვლად `dxcompiler.dll`-ს იყენებს.", + "contains": "შეიცავს", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "ასლი შემოწმებულია და ბიბლიოთეკაშია. დუბლიკატები და ფაილები, რომლებიც ვერ დამუშავდა, თავის ადგილზე რჩება.", + "copyrighted": "დაცულია საავტორო უფლებით", + "curves, as Lightroom does: a preset with just one channel curve (or no master curve) leaves the channels alone.": "მრუდიც აქვს, როგორც Lightroom-ში: პრესეტი მხოლოდ ერთი არხის მრუდით (ან მთავარი მრუდის გარეშე) არხებს არ ეხება.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "მონაცემები ჯერ არ იშიფრება, ამიტომ ისინი მხოლოდ წინასწარ ხედად რჩება. ადრე იმპორტირებული CR3-ებისთვის ფოტო → დისკიდან ხელახლა ჩატვირთვა (ახლა", + "day 01–31": "დღე 01–31", + "days": "დღე", + "default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail": "ქეშის ნაგულისხმევ წილს (768 MB), ამიტომ შენახვისთანავე იშლებოდა. დეტალურ ხედში ნაჩვენები ფოტოს ორიგინალი", + "deletes a keyword from the whole library.": "შლის საკვანძო სიტყვას მთელი ბიბლიოთეკიდან.", + "demosaic already lines the colour planes up. Details and measurements: `docs/fuji-lens-corrections.md`.": "დემოზაიკა ფერთა სიბრტყეებს ისედაც ასწორებს. დეტალები და გაზომვები: `docs/fuji-lens-corrections.md`.", + "denoiseModel.accept": "denoiseModel.accept", + "depending on the graphics card. On a Windows laptop with an AMD Radeon 780M, a full-size export of a 24 MP raw": "გრაფიკული ბარათის მიხედვით. Windows-ის ლეპტოპზე AMD Radeon 780M-ით 24 MP RAW-ის სრული ზომის ექსპორტი", + "dialog was found and closed.": "დიალოგს არ იპოვიდნენ და დახურავდნენ.", + "differ by about 7–10 % on the ILCE-7M3; which one is off is still open.": "ILCE-7M3-ზე დაახლოებით 7–10 %-ით განსხვავდება; რომელია მცდარი, ჯერ ღია საკითხია.", + "directives. See README → Quick start → Logs.": "დირექტივებს. იხ. README → Quick start → Logs.", + "discarding smart previews": "ჭკვიანი წინასწარი ხედების მოცილება", + "does: As Shot reads the file's white (an ILCE-7M3 sample: 4268 K / +7), the Temp slider runs 2000–50000 K and the": "„როგორც გადაღებულია“ კითხულობს ფაილის თეთრს (ILCE-7M3-ის ნიმუში: 4268 K / +7), ტემპერატურის სლაიდერი 2000–50000 K-ია, და", + "doesn't contain": "არ შეიცავს", + "dragging a handle into the image edge stops there instead of pushing the crop out of shape.": "სახელურის გამოსახულების კიდემდე გადათრევა იქვე ჩერდება, ჩამოჭრის ფორმის დარღვევის ნაცვლად.", + "e.g. sky · the red car · car, road": "მაგ., ცა · წითელი მანქანა · მანქანა, გზა", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "კიდეზე, იჭრება, ხოლო „12-ბიტიანი შეუკუმშავი“ ფაილები აღარ იკვეთება. RX100 სერია რენდერდება გაცილებით ახლოს", + "edit at 1:1, every zoom out and every step to the next photo.": "1:1-ზე ყოველ რედაქტირებაზე, ყოველ დაპატარავებასა და შემდეგ ფოტოზე ყოველ გადასვლაზე.", + "edited earlier may shift by a few pixels.": "ჩამოჭრა და ნიღბები შეიძლება რამდენიმე პიქსელით გადაინაცვლოს.", + "ellipses and radial components in compound masks. Edge and rotation handles keep their existing": "ელიფსები და რთული ნიღბების რადიალური კომპონენტები. კიდისა და შემობრუნების სახელურები თავის არსებულ", + "embedded JPEG.": "ჩაშენებული JPEG.", + "ends with": "მთავრდება", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "ყველა ფორმატში, რომელსაც კამერები წერს. ადრე კორპუსების უმეტესობა მხოლოდ წინასწარი ხედის სახით იხსნებოდა: GH1–GH5, G, GX, GF, GM, FZ, LX და", + "every selected photo; a chip's menu acts on the selection (add to all, remove, show photos, edit) and no longer": "ყველა მონიშნული ფოტოდან; ჩიპის მენიუ მონიშნულზე მოქმედებს (ყველასთვის დამატება, მოშორება, ფოტოების ჩვენება, რედაქტირება) და აღარ", + "existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported": "LightCraft-ის არსებული ცვლილებები ნაგულისხმევად შენარჩუნდება. წყაროს პარამეტრები/ისტორია არქივდება, მხარდაუჭერელი", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "ექსპორტი. კატალოგი, XMP თანმხლები ფაილები, პარამეტრები, DNG-ები, გაერთიანებები, ჭკვიანი წინასწარი ხედები და იმპორტის მიერ შექმნილი ასლები კვლავ", + "exported files will carry for the selection.": "შეიცავს ექსპორტირებული ფაილები მონიშნულისთვის.", + "exported or converted to DNG. It is not applied by default: Lightroom Classic renders ProRAW without it. To see a": "ექსპორტირდება ან DNG-ად გარდაიქმნება. ნაგულისხმევად არ გამოიყენება: Lightroom Classic ProRAW-ს მის გარეშე რენდერავს.", + "false": "მცდარი", + "favourites across restarts (issue #328).": "გადატვირთვის შემდეგაც რჩეულებად რჩება (ხარვეზი #328).", + "fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.": "ველები მოხსენებაში ჩანს, ხოლო Lightroom-ის ორიგინალი მონაცემთა ბაზა მხოლოდ წაკითხვადი რჩება. რენდერი მიახლოებითია.", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "ველებს და დანარჩენს ინარჩუნებს — მაგ., იმ აპლიკაციის დამუშავების პარამეტრებსა და რედაქტირების ისტორიას — ბაიტ-ბაიტ. თანმხლები", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "ფაილიდან (Sony ამ კორპუსებზე მათ მხოლოდ მწარმოებლის შიფრირებულ შენიშვნებში ინახავს), მარჯვენა კიდეზე შევსების რამდენიმე სვეტი", + "file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then": "ფაილი გაქრა, საქაღალდის შიგნით გადარქმეული ან გადატანილი ფაილების და სხვა აპების მიერ შეცვლილი XMP თანმხლები ფაილების საპოვნელად; შემდეგ", + "file name, then id, instead of import order. Recently Added keeps a batch in file name order, and Auto-Stack orders a": "ფაილის სახელით, შემდეგ id-ით, იმპორტის რიგის ნაცვლად. „ბოლოს დამატებული“ პარტიას ფაილის სახელის რიგით ინახავს, ხოლო ავტომატური დასტებად დაჯგუფება", + "files also Adobe RGB and ProPhoto), metadata as for JPEG. With HDR output, photos edited in HDR are written as": "ფაილებისთვის ასევე Adobe RGB და ProPhoto), მეტამონაცემები — როგორც JPEG-ისთვის. HDR გამოტანისას HDR-ში დამუშავებული ფოტოები იწერება როგორც", + "files, the same files as `library.import` (issue #1080). They used to be skipped without a word.": "ფაილებსაც იღებს, იგივე ფაილებს, რასაც `library.import` (ხარვეზი #1080). ადრე ისინი უსიტყვოდ გამოიტოვებოდა.", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "AI ნიღბის პირველად გამოყენებისას LightCraft ჩამოტვირთვამდე გეკითხებათ, აჩვენებს პროგრესს, შეუძლია გაუქმება და განახლება,", + "folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,": "საქაღალდეების სკანირება, ექსპორტი (და საკონტაქტო ფურცლის PDF), საქაღალდის სინქრონიზაცია, წინასწარი / ჭკვიანი წინასწარი ხედების შექმნა, Lightroom-ის კატალოგის იმპორტი, ფოტოების გაერთიანება, მოდელების ჩამოტვირთვა, სახეების სკანირება,", + "folder, fold that folder first (the bottom of an open folder means \"inside\"). Agents use `album.reorder`": "საქაღალდის შემდეგ მოსათავსებლად ჯერ ის საქაღალდე ჩაკეცეთ (ღია საქაღალდის ქვედა ნაწილი „შიგნით“ ნიშნავს). აგენტები იყენებენ `album.reorder`-ს", + "follow.": "მოგვიანებით დაემატება.", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "სესიის განმავლობაში, ნაგულისხმევით გადაწერის ნაცვლად. აპლიკაციის პარამეტრები (`ui.json`, რომელიც იმახსოვრებს თქვენს", + "for\").": "რამე“).", + "formats. On an E-1 photo the starting render moved from ΔE00 11.3 to 8.3 against Lightroom's. Photos already": "ფორმატებისთვის. E-1-ის ფოტოზე საწყისი რენდერი Lightroom-ის მიმართ ΔE00 11.3-დან 8.3-მდე გაუმჯობესდა. უკვე", + "from the grid onto a keyword get it.": "საკვანძო სიტყვაზე გადათრეულ ფოტოებს ის მიენიჭება.", + "full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids": "სრული დიაპაზონის/მატრიცის სიგნალიზაცია გათვალისწინებულია (iPhone-ის ფოტოები სრული დიაპაზონისაა), ქრომა libheif-ის მსგავსად ზრდის გარჩევადობას, ბადეები", + "functions, and one Undo restores the entire drag.": "ფუნქციებს ინარჩუნებს, და ერთი დაბრუნება მთელ გადათრევას აუქმებს.", + "gets -1, -2… instead of being replaced.": "იღებს -1, -2… ჩანაცვლების ნაცვლად.", + "green": "მწვანე", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "მომწვანო ნეიტრალური რენდერის ნაცვლად (ხარვეზი #150); თეთრის ბალანსი გადაღებისდროინდელ იერთან მიმართებით რეგულირდება. 12-ბიტიანი NEF-ები", + "handle, and a program being started by another thread at that moment held a copy of the handle until it was fully": "დესკრიპტორთან ერთად ქრებოდა, და სხვა ნაკადის მიერ იმ მომენტში გაშვებული პროგრამა დესკრიპტორის ასლს ინახავდა, სანამ ბოლომდე", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "აქვს გახსნილი — ამ კომპიუტერზე ან სხვაზე, რომელიც საქაღალდეს იზიარებს — გეუბნებათ, ვისთანაა („უკვე გახსნილია LightCraft-ში (პროცესი", + "has the first date after the second": "პირველი თარიღი მეორეზე გვიანია", + "high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the": "მაღალი გარჩევადობის კამერებზე, როგორიცაა Sony A7R VI (64 MP). მათი დეკოდირებული ორიგინალი (~800 MB) აღემატება", + "highlight recovery missed them and, darkened, they took a colour cast instead of going neutral grey.": "ღია ტონების აღდგენა მათ ვერ ხედავდა და, დამუქებისას, ნეიტრალური ნაცრისფერის ნაცვლად ფერად ელფერს იღებდა.", + "hold (such as a name in brackets), which it leaves out so the file always reads back.": "დაიტევს (მაგ., სახელი ფრჩხილებში) და რომელსაც გამოტოვებს, რათა ფაილი ყოველთვის უკან წაიკითხებოდეს.", + "hour 00–23": "საათი 00–23", + "hours": "საათი", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "როგორ აღადგინოთ ან სამუდამოდ წაშალოთ და ხელახლა მოახდინოთ იმპორტი. (ფოტოს თავიდან დასაწყებად — რედაქტირების ჩამოყრა,", + "iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select": "iPhone ProRAW და სხვა DNG-ები, რომლებსაც საკუთარი სეგმენტაციის მატები აქვს (DNG სემანტიკური ნიღბები), მათ იყენებს", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ადრე იგნორირებული) ირჩევს გრაფიკულ ბექენდს; `off` რენდერს CPU-ზე ასრულებს. GPU ახლა ირთვება მას შემდეგ, რაც ფანჯარა", + "image": "გამოსახულება", + "immutable, and HOSTING.md describes the actual build.": "უცვლელად, ხოლო HOSTING.md რეალურ აწყობას აღწერს.", + "import only the raws and leave the JPEGs beside them (issue #344).": "შემოიტანეთ მხოლოდ RAW-ები და JPEG-ები მათ გვერდით დატოვეთ (ხარვეზი #344).", + "import review checks such files, marks them \"Will be restored\" and counts them as \"in Recently Deleted\"; the": "იმპორტის მიმოხილვა ასეთ ფაილებს ამოწმებს, აღნიშნავს „აღდგება“ და ითვლის როგორც „ახლახან წაშლილში“;", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "იმპორტი — ასე რომ „იმპორტი დასრულდა“ ნიშნავს, რომ ასლები ვარგისია ბარათის დაფორმატებამდე — და დაკავებული სახელი", + "imported as \"preview only\" switch over on Reload.": "„მხოლოდ წინასწარი ხედის“ სახით იმპორტირებული, ხელახლა ჩატვირთვისას გადაერთვება.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "ადრე იმპორტირებული ფოტოები გადაერთვება ბრძანებით ფოტო → დისკიდან ხელახლა ჩატვირთვა.", + "imported from files (or read from sidecars) keep the hierarchy `lr:hierarchicalSubject` gives them.": "ფაილებიდან იმპორტირებული (ან თანმხლები ფაილებიდან წაკითხული) საკვანძო სიტყვები ინარჩუნებს `lr:hierarchicalSubject`-ის იერარქიას.", + "imported pick it up when re-rendered.": "იმპორტირებული ფოტოები მას ხელახალი რენდერისას იღებს.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "მცირე ნაწილებად, რათა ნელი ინტეგრირებული GPU-ები მათმა watchdog-მა არ გადატვირთოს. `ui.inspect` → `perf`", + "in use": "გამოიყენება", + "in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade": "დაყენდა, და აღარ ბრუნდება Program Files-ში (ხარვეზი #399). თუ ის საქაღალდე მას შემდეგ წაიშალა, განახლება", + "includes the folders inside it": "მოიცავს შიდა საქაღალდეებს", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "არასრული, ხელახლა სრულდება CPU-ზე — ფაილი ორივე შემთხვევაში ერთი და იგივე გამოსახულებაა. სამუშაო GPU-ს ეგზავნება", + "inference on a Mac through SSH. Saved masks still render and export offline.": "ინფერენსი კი SSH-ით Mac-ზე გაუშვით. შენახული ნიღბები ოფლაინაც რენდერდება და ექსპორტირდება.", + "inside it; undo and redo carry them back and forth.": "იარლიყებთან ერთად; დაბრუნება და ხელახლა შესრულება მათ წინ და უკან გადაატარებს.", + "inside the view. A plain mouse wheel over a zoomed photo pans it too.": "ხედის შიგნით რჩება. ჩვეულებრივი მაუსის ბორბალი გადიდებულ ფოტოზე მასაც გადააადგილებს.", + "installed": "დაინსტალირებულია", + "installs to Program Files.": "Program Files-ში დაყენდება.", + "instead of opening as their embedded preview. Photos imported earlier switch over with Photo ▸ Reload from Disk.": "ჩაშენებული წინასწარი ხედის სახით გახსნის ნაცვლად. ადრე იმპორტირებული ფოტოები გადაირთვება: ფოტო → დისკიდან ხელახლა ჩატვირთვა.", + "instead of quietly matching nothing or everything. New date rules start at the current year.": "და აღარ ემთხვევა ჩუმად არაფერს ან ყველაფერს. ახალი თარიღის წესები მიმდინარე წლით იწყება.", + "into the display's own gamut; thumbnails and other previews are converted to it. Histograms, the preview caches": "საკუთარ ფერთა დიაპაზონში რენდერდება; ესკიზები და სხვა წინასწარი ხედები მასში გარდაიქმნება. ჰისტოგრამები, წინასწარი ხედების ქეში", + "is": "არის", + "is <": "<", + "is >": ">", + "is after": "გვიანაა, ვიდრე", + "is before": "ადრეა, ვიდრე", + "is between": "შუალედშია", + "is empty": "ცარიელია", + "is in the last": "ბოლო", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "შენარჩუნებულია). წარუმატებელი შენახვა (საცავი სავსეა) ახლა აჩვენებს შეუნახავობის გაფრთხილებას და მეორდება, ფოტო, რომლის შენახვაც ვერ ხერხდება,", + "is refused and nothing is overwritten.": "უარყოფილია და არაფერი გადაიწერება.", + "is touched. Agents use `folder.scanChanges` and `folder.synchronize`.": "არ ეხება. აგენტები იყენებენ `folder.scanChanges`-სა და `folder.synchronize`-ს.", + "is unknown": "უცნობია", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "ჩაირთვება და მხოლოდ მაშინ, როცა პარამეტრები → წარმადობა → რენდერირებისთვის GPU-ის გამოყენება ჩართულია; თუ LightCraft ოდესმე ავარიულად დაიხურება", + "is yes or no": "უნდა იყოს დიახ ან არა", + "is ≤": "≤", + "is ≥": "≥", + "isn't": "არ არის", + "isn't a field rules know": "წესებისთვის ცნობილი ველი არ არის", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "არ ემატება, მეორე ჩანართი პირველის გადაწერის ნაცვლად შეტყობინებას აჩვენებს, `?reset` ჯერ გეკითხებათ, ხოლო გვერდი გეუბნებათ,", + "isn't empty": "არ არის ცარიელი", + "isn't in the last": "არ არის ბოლო", + "isn't one of the choices": "არჩევანთაგანი არ არის", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "მას მეორისგან (ხარვეზი #92). დაკარგული ფოტოების ძებნა გადარქმეულ ფაილებსაც პოულობს შიგთავსით, უპირატესობას ანიჭებს შიგთავსის დამთხვევას", + "it. The list is there with no photo selected too, to set up keywords first.": "მის შიგნით მოთავსდება. სია ფოტოს მონიშვნის გარეშეც ჩანს, რომ საკვანძო სიტყვები წინასწარ მოამზადოთ.", + "its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo (\"Original offline · no": "საჩვენებლად მისი ჩაშენებული JPEG ან ესკიზი, ლუპა ახლა ამას ფოტოს ქვედა ნაწილში წერს („ორიგინალი მიუწვდომელია · ჭკვიანი", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "თავის ფოტოებსა და XMP თანმხლებ ფაილებს, და ფოტოები ისევ მასზე მიუთითებს. თუ ძველ ადგილს ახლა რამე იკავებს, დაბრუნება", + "its real As Shot temperature once the file is opened (or Reload reads it).": "ნამდვილ „როგორც გადაღებულია“ ტემპერატურას, როგორც კი ფაილი გაიხსნება (ან ხელახალი ჩატვირთვა წაიკითხავს).", + "keep names — or e.g. {date}_{seq:3}": "სახელების შენარჩუნება — ან მაგ., {date}_{seq:3}", + "keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from": "საკვანძო სიტყვა, რომელიც მხოლოდ ზოგიერთს აქვს, ვარსკვლავითაა აღნიშნული (მიტანისას: რამდენს). მხოლოდ × შლის საკვანძო სიტყვას", + "labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys": "იარლიყები 6–9, P / U და დანარჩენი არაფერს აკეთებდა, რადგან macOS მენიუს ზოლს მხოლოდ ⌘ / ⌃ კომბინაციებს და ფუნქციურ კლავიშებს", + "land in `ui.json` (where they replaced the saved settings), and the GPU crash sentinel no longer creates the": "ხვდება `ui.json`-ში (სადაც შენახულ პარამეტრებს ცვლიდა), ხოლო GPU-ს ავარიის მცველი იქ აღარ ქმნის", + "latitude, longitude": "განედი, გრძედი", + "launched, so reopening the library straight away could be refused with \"This library is already open in another": "არ გაეშვებოდა, ამიტომ ბიბლიოთეკის მაშინვე ხელახლა გახსნაზე შეიძლება უარი ეთქვა შეტყობინებით „ეს ბიბლიოთეკა უკვე გახსნილია სხვა", + "layout is read from each file instead of assumed.": "განლაგება თითოეული ფაილიდან იკითხება და არა ივარაუდება.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "DNG-ს არ ტოვებს და RAW-ს ინარჩუნებს. ჭკვიანი წინასწარი ხედებიც ასევე იწერება; დაზიანებული (ავარიით ან", + "leaves out whatever Excluded Photos matches, as its rules change. The album picker shows your albums as in the sidebar": "გამოტოვებს ყველაფერს, რასაც Excluded Photos ემთხვევა, მისი წესების ცვლილებასთან ერთად. ალბომის ამრჩევი აჩვენებს თქვენს ალბომებს, როგორც გვერდით ზოლში", + "left out, so these photos come out a little smaller, closer to the camera's own JPEG. Crops and masks on photos": "გამოტოვებულია, ამიტომ ეს ფოტოები ოდნავ პატარა გამოდის, კამერის საკუთარ JPEG-თან უფრო ახლოს. ადრე რედაქტირებულ ფოტოებზე", + "let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's": "სლაიდერს გაუშვებთ. 64 MP RAW-ზე ამ რენდერებიდან პირველი მთელ ფაილს ხელახლა დეკოდირებდა, რაც ლუპის", + "library locked: the lock is the operating system's and goes away with the program.": "ბიბლიოთეკას დაბლოკილს: ბლოკი ოპერაციულ სისტემას ეკუთვნის და პროგრამასთან ერთად ქრება.", + "library open now stays, and LightCraft says \"This is a Lightroom catalog, not a LightCraft library\" and offers": "გახსნილი ბიბლიოთეკა რჩება, LightCraft კი ამბობს: „ეს Lightroom-ის კატალოგია და არა LightCraft-ის ბიბლიოთეკა“ და გთავაზობთ", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "ბიბლიოთეკა) ატომურად იწერება და ინახება, როგორც კი სხვა ბიბლიოთეკას გახსნით, და არა მხოლოდ გასვლისას. გასვლა, როცა", + "light: Silver or Paper). New and existing installs stay dark; following the system is opt-in.": "ღია: ვერცხლისფერი ან ქაღალდი). ახალი და არსებული ინსტალაციები ბნელი რჩება; სისტემის მიყოლა არჩევითია.", + "like a photo that can't be opened, with the reason, instead of rendering forever; its thumbnail stops waiting too.": "როგორც ფოტო, რომლის გახსნაც შეუძლებელია, მიზეზით, უსასრულო რენდერის ნაცვლად; მისი ესკიზიც წყვეტს ლოდინს.", + "like the HDR AVIF: the same brightness, with SDR white at 203 cd/m², and content light levels recorded.": "როგორც HDR AVIF: იგივე სიკაშკაშე, SDR თეთრით 203 cd/m²-ზე, და ჩაწერილი შიგთავსის სინათლის დონეებით.", + "lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and": "თეთრით განათებული ნაცრისფერი ნაცრისფერი გამოდის, გაჯერებული ფერები კი ისე იცვლება, როგორც კამერა ჩაწერს. პიპეტი და", + "longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed": "მას აღარ ათავისუფლებს, რაც შემდეგ გადაადგილებაზე დაახლოებით წამიან ხელახლა ატვირთვას იწვევდა (800 MB 64 MP RAW-ისთვის). ის თავისუფლდება,", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "აღარ ამბობს მხოლოდ „დუბლიკატი გამოტოვებულია“: ის გვერდით პანელს ხსნის ახლახან წაშლილზე, ფოტო მონიშნულია, და გეუბნებათ,", + "looks right everywhere, export a JPEG with HDR output on (an SDR image plus a gain map).": "ყველგან სწორად ჩანს, ექსპორტი გააკეთეთ JPEG-ად ჩართული HDR გამოტანით (SDR გამოსახულება პლუს gain map).", + "lossy tiles decode too, keeping the raw values above 1.0 instead of clipping them (checked on one real file). A tile that cannot be decoded makes the file open from its embedded": "დანაკარგიანი ფილებიც დეკოდირდება, RAW მნიშვნელობებს 1.0-ზე ზემოთ ინარჩუნებს მოკვეთის ნაცვლად (შემოწმებულია ერთ რეალურ ფაილზე). ფილა, რომლის დეკოდირება შეუძლებელია, ფაილს მისი ჩაშენებული", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "გასვლისას ყველაფერს კარგავდა; დროებითი სესია მთელი დროის განმავლობაში აჩვენებს ბანერს და თქვენს ბიბლიოთეკაში არასოდეს წერს.", + "macOS, Linux packages, AppImage, Flatpak), and says so if it isn't installed, before making a copy.": "macOS, Linux-ის პაკეტები, AppImage, Flatpak), და თუ დაყენებული არ არის, ასლის შექმნამდე გეტყვით.", + "made a lock test flaky, and the fix closes the window for good.": "ეს ბლოკის ტესტს არასტაბილურს ხდიდა, გასწორება კი ამ ფანჯარას სამუდამოდ ხურავს.", + "magnified preview isn't taken for the photo when checking focus at 1:1.": "გადიდებული წინასწარი ხედი ფოტოდ არ მიიჩნევა, როცა ფოკუსს 1:1-ზე ამოწმებთ.", + "makes AI Denoise available": "AI ხმაურის მოცილებას ხელმისაწვდომს ხდის", + "makes it inside that folder (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` take `parent`).": "მას ამ საქაღალდის შიგნით ქმნის (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` იღებს `parent`-ს).", + "many Photos added, or why it added none. Export with Previous and export presets repeat it.": "დაამატა Photos-მა, ან რატომ ვერ დაამატა ვერცერთი. ექსპორტი წინა პარამეტრებით და ექსპორტის პრესეტები ამას იმეორებს.", + "map); the option is per photo, so presets and Copy Settings carry it.": "რუკა აქვს); პარამეტრი ფოტოს დონისაა, ასე რომ პრესეტები და პარამეტრების კოპირება მას გადაიტანს.", + "means no (it used to count as yes), and a value that is neither yes nor no is refused.": "ნიშნავს „არას“ (ადრე „დიახად“ ითვლებოდა), ხოლო მნიშვნელობა, რომელიც არც დიახია და არც არა, უარყოფილია.", + "memory only": "მხოლოდ მეხსიერებაში", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "მეხსიერება, აჭარბებს GPU-ის ბუფერის ლიმიტებს, დრაივერის შეცდომას ან გადატვირთვას აწყდება, ან აბრუნებს", + "menu, and Return in the painter's box starts painting.": "მენიუ, ხოლო Return ფუნჯის ველში მინიჭებას იწყებს.", + "message box that names the log file, instead of quitting without a trace (issue #260).": "შეტყობინების ფანჯარაში ამბობს, რომელიც ჟურნალის ფაილს ასახელებს, უკვალოდ გასვლის ნაცვლად (ხარვეზი #260).", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "შეტყობინებით, ხოლო ორიგინალი ბაიტ-ბაიტ ხელუხლებელი რჩება. ჩვეულებრივი წინა ექსპორტები მოთხოვნისას კვლავ გადაიწერება.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "სერიის შუაში: შეცდომა მათ ჩამოთვლის (ძველი → ახალი), ბიბლიოთეკა კი მათ ახალ სახელებზე მიუთითებს (ნაწილობრივი გადარქმევა, რომლის დაბრუნებაც შეიძლება),", + "minute": "წუთი", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "მოდელებიდან, რომელთა სენსორი მწვანე-ლურჯი რიგით იწყება) აღარ გამოდის მეწამული (ხარვეზი #85): ფერის ფილტრის", + "month 01–12": "თვე 01–12", + "months": "თვე", + "moved from ΔE00 7.6 to 5.2 against Lightroom's.": "Lightroom-ის მიმართ ΔE00 7.6-დან 5.2-მდე გაუმჯობესდა.", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "სახელით (ფაილის ჩანაცვლების გარეშე), და მხოლოდ ამის შემდეგ ხდება ფოტოს ხელახლა მიბმა ან RAW ასლის მოცილება — წარუმატებელი ჩაწერა", + "names an album that no longer exists": "მიუთითებს ალბომზე, რომელიც აღარ არსებობს", + "needs a date like 2026, 2026-08 or 2026-08-14": "საჭიროა თარიღი, მაგ., 2026, 2026-08 ან 2026-08-14", + "needs a number": "საჭიროა რიცხვი", + "needs a number more than 0": "საჭიროა 0-ზე მეტი რიცხვი", + "needs a time like 1/250 or 2": "საჭიროა დრო, მაგ., 1/250 ან 2", + "needs an album": "საჭიროა ალბომი", + "needs hours, days, weeks, months or years": "საჭიროა საათები, დღეები, კვირები, თვეები ან წლები", + "needs something to look for (or use “are empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „ცარიელია“)", + "needs something to look for (or use “aren't empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „არ არის ცარიელი“)", + "needs something to look for (or use “is empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „ცარიელია“)", + "needs something to look for (or use “isn't empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „არ არის ცარიელი“)", + "needs text": "საჭიროა ტექსტი", + "needs two values": "საჭიროა ორი მნიშვნელობა", + "neutral default.": "ნეიტრალური ნაგულისხმევით იწყებოდა.", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "არასოდეს ტოვებს შეკვეცილ ფაილს; „ორიგინალის“ ექსპორტის XMP თანმხლები ფაილიც „თუ ფაილი არსებობს“ არჩევანს მიჰყვება.", + "never restore anything. Agents: `library.import` → `onDeleted` defaults to `restore` (`skip` keeps the old": "არაფერს აღადგენს. აგენტები: `library.import` → `onDeleted` ნაგულისხმევად `restore`-ია (`skip` ინარჩუნებს ძველ", + "new photos (and Reset) get it, photos you already edited keep their look until you choose Photo ▸ Update to Current": "ახალი ფოტოები (და ჩამოყრა) მას იღებს, უკვე დამუშავებული ფოტოები კი თავის იერს ინარჩუნებს, სანამ არ აირჩევთ ფოტო → მიმდინარე", + "next to it while you rotate (issue #534). For an exact angle, type it in the new Angle field under Straighten and press Return": "შემობრუნებისას მის გვერდით ჩანს (ხარვეზი #534). ზუსტი კუთხისთვის აკრიფეთ ის გასწორების ქვეშ ახალ კუთხის ველში და დააჭირეთ Return-ს", + "no longer saves it). Return in those dialogs now confirms them, and their name opens selected. More fields will": "აღარ ინახავს მას). Return ამ დიალოგებში ახლა ადასტურებს მათ, ხოლო სახელი მონიშნული იხსნება. სხვა ველებიც", + "non-commercial use only": "მხოლოდ არაკომერციული გამოყენება", + "none": "არცერთი", + "none (CPU rendering)": "არცერთი (CPU რენდერირება)", + "none of": "არცერთი", + "not added to Apple Photos (cancelled)": "Apple Photos-ში არ დაემატა (გაუქმდა)", + "not available here": "აქ ხელმისაწვდომი არ არის", + "noticeably duller.": "შესამჩნევად მკრთალი ჩანდა.", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ახლა მუშავდება RAW მონაცემებიდან კამერის ჩაშენებული JPEG-ის ნაცვლად, ასე რომ შავ-თეთრი ან სხვა გამოსახულების სტილი, რომელიც დაყენებულია", + "now import as \"preview only\" with their embedded JPEG instead of failing. A raw whose data is damaged but whose": "ახლა „მხოლოდ წინასწარი ხედის“ სახით იმპორტირდება ჩაშენებული JPEG-ით, შეცდომის ნაცვლად. RAW, რომლის მონაცემები დაზიანებულია, მაგრამ", + "now measured on the masked columns alone (8 corpus bodies, EOS 40D to 5DS R). On a 6D photo the starting render": "ახლა მხოლოდ დაფარულ სვეტებზე იზომება (კორპუსის 8 კამერა, EOS 40D-დან 5DS R-მდე). 6D-ის ფოტოზე საწყისი რენდერი", + "now stays, like its preview, until another photo or view is shown.": "ახლა რჩება, მისი წინასწარი ხედის მსგავსად, სანამ სხვა ფოტო ან ხედი არ გამოჩნდება.", + "of quietly rendering everything on the CPU, much more slowly. Help ▸ System Info says why. On NixOS this happens": "რომ ყველაფერი ჩუმად, გაცილებით ნელა, CPU-ზე დაარენდეროს. დახმარება → სისტემის ინფორმაცია მიზეზს გეტყვით. NixOS-ზე ეს ხდება,", + "of the following rules:": "შემდეგი წესიდან:", + "older versions refuse it, as with every format change.": "ძველი ვერსიები მას აღარ გახსნიან, როგორც ფორმატის ყოველი ცვლილებისას.", + "on the CPU, are not waited for).": "CPU-ზე, არ ელოდება).", + "on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the": "DLL-ების საძიებო გზაზეა მისი `dxil.dll`-ის გარეშე (ხარვეზი #471): DirectX 12 შეიდერები ახლა ყოველთვის კომპილირდება", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "ბარათზე იმპორტის გადახედვის შემდეგ, აღირიცხება იმის ნაცვლად, რომ მოძველებული მონაცემებით იმპორტირდეს. ექსპორტის მისამართის შემოწმება", + "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; ✕ აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის", + "once you zoom out or move on.": "როცა დააპატარავებთ ან სხვაზე გადახვალთ.", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "სათითაოდ — მათი ხელახლა ექსპორტი ყოველთვის შეიძლება, ხოლო USB დისკზე ან NAS-ზე თითოეული ფაილის სინქრონიზაცია დიდ", + "only convert between the camera's multipliers and Kelvin; the colour look is what it was.": "მხოლოდ კამერის მამრავლებსა და კელვინებს შორის გარდაქმნის; ფერის იერი ისეთივეა, როგორიც იყო.", + "opened a library, older versions refuse it, as with every format change.": "ბიბლიოთეკას გახსნის, ძველი ვერსიები მას აღარ გახსნიან, როგორც ფორმატის ყოველი ცვლილებისას.", + "or": "ან", + "or the edge of another folder, to place it before or after it: that folder is then ordered by hand. Right-click ▸": "ან სხვა საქაღალდის კიდეზე, რომ მის წინ ან შემდეგ მოთავსდეს: იმ საქაღალდის რიგი მაშინ ხელითაა დადგენილი. მარჯვენა დაწკაპუნება →", + "or unchecks a range of photos (issue #338).": "ფოტოების დიაპაზონს მონიშნავს ან მონიშვნას მოხსნის (ხარვეზი #338).", + "out green and dark.": "მწვანე და მუქი გამოდიოდა.", + "out. Clear Recent Libraries empties the list.": "ნაცრისფრადაა. ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება სიას ცარიელს ხდის.", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "იმავე სახელისა და ზომის მსგავს ფაილთან შედარებით, და გამოტოვებს (და აღრიცხავს) ფოტოებს, რომელთა გარჩევაც ვერ ხერხდება, გამოცნობის ნაცვლად.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "ნაწილს გამორიცხავს), ან აკრიფეთ, რა მოინიშნოს („ცა“, „წითელი მანქანა“, „მანქანა, გზა“); ორივე სხვა ნიღბებთან ერთიანდება, აქვს", + "photo has its denoised picture.": "ფოტოს აქვს ხმაურმოცილებული სურათი.", + "photo has yet too. Filter it, tick a keyword to give it to the selected photos (a dash: only some have it), or click": "რომლებიც ჯერ არცერთ ფოტოს არ აქვს. გაფილტრეთ, მონიშნეთ საკვანძო სიტყვა, რომ მონიშნულ ფოტოებს მიენიჭოს (ტირე: მხოლოდ ზოგიერთს აქვს), ან დაწკაპეთ", + "photo waiting": "ფოტო რიგში", + "photos have their denoised picture.": "ფოტოს აქვს ხმაურმოცილებული სურათი.", + "photos waiting": "ფოტო რიგში", + "pick": "რჩეული", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "RAW რედაქტირებისა და სრული გარჩევადობის ექსპორტის შენარჩუნებით, ჩაშენებული JPEG წინასწარი ხედების ნაცვლად.", + "preset pickers in Import and Settings ▸ Import scroll the same way.": "პრესეტების ამრჩევები იმპორტსა და პარამეტრები → იმპორტში ასევე გადაიხვევა.", + "presets.json) stays responsive. Every command used to re-serialize the whole list to see whether it had changed": "presets.json) სწრაფად რეაგირებს. ადრე ყოველი ბრძანება მთელ სიას ხელახლა სერიალიზებდა, რომ გაეგო, შეიცვალა თუ არა", + "press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that": "დააჭირეთ ახალ კლავიშებს (Esc აუქმებს), × შლის მას, ↺ თავდაპირველს აღადგენს, ყველაფრის ჩამოყრა ყველა ცვლილებას აუქმებს. კლავიში, რომელიც", + "preview is intact does the same.": "წინასწარი ხედი მთელია, იგივენაირად იქცევა.", + "preview, with the reason, instead of showing a black tile. A JPEG XL preview stored in the DNG is used like an": "წინასწარი ხედიდან ხსნის, მიზეზის მითითებით, შავი ფილის ჩვენების ნაცვლად. DNG-ში შენახული JPEG XL წინასწარი ხედი ისე გამოიყენება, როგორც", + "previews": "წინასწარი ხედი", + "previews for up to a second.": "წინასწარ ხედებს წამამდე აყოვნებდა.", + "promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341).": "გვპირდება, ღია ალბომის ან საქაღალდის გაფილტვრის ნაცვლად, რაც ხშირად არაფერს აჩვენებდა (ხარვეზი #341).", + "publicDomain": "საზოგადოებრივი საკუთრება", + "purple": "იისფერი", + "raw": "RAW", + "raws of other cameras keep the as-shot shift of issue #510. The two Kelvin scales come from different matrices and": "სხვა კამერების RAW-ები ინარჩუნებს ხარვეზი #510-ის „როგორც გადაღებულია“ წანაცვლებას. ორი კელვინის შკალა სხვადასხვა მატრიციდან მოდის და", + "read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads": "წაკითხულს თეთრის ბალანსის პრესეტებიდან, რომლებსაც კამერა ყველა ARW-ში ინახავს: კამერაზე 3400 K-ზე გადაღებული ფოტო აჩვენებს", + "read in the wrong units.": "არასწორ ერთეულებში იკითხებოდა.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "ბარათის მესამედ წაკითხვის ნაცვლად (გადატანა წყაროს წაშლამდე კვლავ ბაიტ-ბაიტ ადარებს); ფაილი, რომელიც შეიცვალა", + "red": "წითელი", + "reject": "უარყოფილი", + "relinked to the file it is imported from. Browsing a folder in Local, Auto Import and a Lightroom catalog import": "ხელახლა უკავშირდება იმ ფაილს, საიდანაც იმპორტირდება. საქაღალდის დათვალიერება ლოკალურში, ავტოიმპორტი და Lightroom-ის კატალოგის იმპორტი", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "რენდერები და ეკრანის ანაბეჭდები კვლავ დროებით ფაილში იწერება და ადგილზე გადაირქმევა, მაგრამ დისკზე იძულებით აღარ ჩაიწერება", + "reports each row's `label`.": "თითოეული სტრიქონის `label`-ს აბრუნებს.", + "restore the photo too and say \"Restored 1 photo from Recently Deleted\". A deleted photo whose file was moved is": "ფოტოს ასევე აღადგენს და ამბობს „ახლახან წაშლილიდან აღდგა 1 ფოტო“. წაშლილი ფოტო, რომლის ფაილიც გადაიტანეს,", + "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (✕ აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს", + "save goes through.": "შენახვა შესრულდება.", + "saved in": "შენახულია:", + "saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "შენახული მორგებული ტონირება ახლა საპირისპიროდ იხრება; კვლავ დააყენეთ (ან ხელახლა გაუშვით ავტომატური / თეთრის ბალანსის პიპეტი).", + "says what to fix, and `albums.list` reports `problems`.": "ამბობს, რა უნდა გასწორდეს, `albums.list` კი აბრუნებს `problems`-ს.", + "scale keep their look, and As Shot shows the real temperature once the file is opened.": "სკალაზე შესრულებული რედაქტირება იერს ინარჩუნებს, ხოლო „როგორც გადაღებულია“ ფაილის გახსნისთანავე რეალურ ტემპერატურას აჩვენებს.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "სცენის) ახლა სენსორის მონაცემებიდან მუშავდება ყველა რედაქტირების ხელსაწყოთი, კამერის სხვა RAW ფაილების მსგავსად. ფოტოები", + "second": "წამი", + "set). An empty field leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming": "ნაკრებად ინახავს). ცარიელი ველი თავის კლავიშს ცარიელს ტოვებს და მომდევნო საკვანძო სიტყვებს არ ანაცვლებს; ბოლოდროინდელი საკვანძო სიტყვების რედაქტირება და მათთვის სახელის", + "settings folder there. The same goes for the temporary session offered when the library can't be opened.": "პარამეტრების საქაღალდეს. იგივე ეხება დროებით სესიას, რომელიც ბიბლიოთეკის გახსნის შეუძლებლობისას გთავაზობთ.", + "show a black (or, on the 645Z, white) band along one or more edges (issue #1271). The sensor's masked border is": "აჩვენებს შავ (ან, 645Z-ზე, თეთრ) ზოლს ერთ ან რამდენიმე კიდეზე (ხარვეზი #1271). სენსორის დაფარული კიდე", + "show as a partial import.": "ნაწილობრივ იმპორტად ჩანს.", + "show no dialogs and take `INSTALLFOLDER=...`.": "დიალოგებს არ აჩვენებს და იღებს `INSTALLFOLDER=...`-ს.", + "shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "აჩვენებს და როგორც Lightroom-ში, ხოლო Lightroom-ის XMP თანმხლებ ფაილებში ტონირების მნიშვნელობები ახლა ისე რენდერდება, როგორც იქ. ადრინდელ ვერსიაში", + "shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`": "ფოტოების რაოდენობის წინ იარლიყის წერტილს აჩვენებს. აგენტები იყენებენ `folder.label`-ს (`path`, `label`); `library.folders`", + "shows where it will go), or right-click a header for Move Up / Move Down.": "აჩვენებს, სად მოხვდება), ან სათაურზე მარჯვენა დაწკაპუნებით აირჩიეთ აწევა / ჩამოწევა.", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "მონათესავე მოდელები) აღარ იხსნება კაშკაშა მწვანედ (ხარვეზი #148): მათი გადაღებისდროინდელი თეთრის ბალანსი და შავის დონე იკითხება", + "sidecar lookup always did; the Add Photos dialog on Linux did not until 0.5.0 (GTK and the desktop portal match": "თანმხლები ფაილის ძებნა ყოველთვის ასე იქცეოდა; Linux-ზე ფოტოების დამატების დიალოგი — 0.5.0-მდე არა (GTK და დესკტოპის პორტალი", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "თანმხლები ფაილი, რომელიც ვალიდური XMP არ არის, ჯერ კოპირდება `<name>.xmp.bak-<time>`-ში. ნაგულისხმევი ფუძით დასახელებისას RAW და", + "sky no longer flattens the twigs, lines and texture in it, and lifting Shadows keeps the texture of dark areas, much": "შემცირება აღარ აბრტყელებს მასში ტოტებს, ხაზებსა და ტექსტურას, ჩრდილების აწევა კი მუქი უბნების ტექსტურას ინარჩუნებს, დაახლოებით", + "smaller file; Progressive files show a coarse version first. 8- or 16-bit, sRGB, Display P3 or Rec. 2020 (lossless": "უფრო მცირე ფაილზე; პროგრესული ფაილები ჯერ უხეშ ვერსიას აჩვენებს. 8 ან 16 ბიტი, sRGB, Display P3 ან Rec. 2020 (უდანაკარგო", + "smart album value|No": "არა", + "smart album value|Yes": "დიახ", + "smart preview\" or \"This photo can't be opened\", the reason on hover) instead of showing that preview as if it were": "წინასწარი ხედი არ არის“ ან „ამ ფოტოს გახსნა შეუძლებელია“, მიზეზი — მიტანისას), ნაცვლად იმისა, რომ ეს წინასწარი ხედი ისე აჩვენოს, თითქოს", + "smart previews": "ჭკვიანი წინასწარი ხედი", + "so a successful install looked like nothing had happened (issues #18, #399). Silent installs (`/qn`, `/passive`)": "ასე რომ წარმატებული დაყენება ისე გამოიყურებოდა, თითქოს არაფერი მომხდარა (ხარვეზები #18, #399). ჩუმი დაყენებები (`/qn`, `/passive`)", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "ასე რომ არცერთი არ ჩანს დაკარგულად. RAW + JPEG წყვილიდან ერთის გადარქმევა კოპირებს მათ საერთო `IMG_0001.xmp`-ს, ნაცვლად იმისა, რომ", + "so the picture looks flat and washed out there. That's how PQ PNG works, not a broken file: for a picture that": "ამიტომ სურათი იქ ბრტყელი და გაცრეცილი ჩანს. ასე მუშაობს PQ PNG და ფაილი დაზიანებული არ არის: სურათისთვის, რომელიც", + "started yet another decode.": "კიდევ ერთ დეკოდირებას იწყებდა.", + "starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.": "საწყისი იერი კამერის JPEG-ს კიდეებისგან მოშორებით ერგება, როცა არათანხვედრი კიდეები ყველა პიქსელზე მორგებას აფუჭებს.", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "GPU-ის გაშვებისას, შემდეგი გაშვება GPU რენდერირების გამორთვით იწყება და გეუბნებათ, როგორ ჩართოთ ის ხელახლა.", + "starts using it for AI Denoise": "იწყებს მის გამოყენებას AI ხმაურის მოცილებისთვის", + "starts with": "იწყება", + "still saves your queued changes in the closing snapshot.": "რიგში მყოფ ცვლილებებს მაინც ინახავს დახურვისას შექმნილ მომენტალურ ასლში.", + "submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted": "ქვემენიუები: წყარო, ფაილი, თარიღი, საკვანძო სიტყვები და ადამიანები, აღწერა, კამერის ინფორმაცია, მდებარეობა, ზომა, დამუშავება და დამხმარე", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "სინქრონიზდება. იმპორტი → კოპირება ახლა ყოველ ასლს ამოწმებს ბარათის სკანირებისას აღებული შიგთავსის ჰეშით, ნაცვლად", + "take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from": "თავისი ფილების ICC პროფილს იღებს, ხოლო `imir` ისე არეკლავს, როგორც libheif წერს. HEIC-ის იმპორტი ზომას კონტეინერიდან კითხულობს", + "terms unclear": "პირობები გაურკვეველია", + "that light already reaches (left of the image area Canon records), 4 % of the range too high on the EOS 6D. It is": "რომლებსაც სინათლე უკვე აღწევს (Canon-ის ჩაწერილი გამოსახულების არის მარცხნივ), EOS 6D-ზე დიაპაზონის 4 %-ით მეტი. ის", + "the JPEG uses `IMG_0001.JPG.xmp`.": "JPEG იყენებს `IMG_0001.JPG.xmp`-ს.", + "the LightCraft window. The xdg-desktop-portal picker used to open as a separate top-level window on Wayland": "LightCraft-ის ფანჯრის შვილობილია. xdg-desktop-portal-ის ამრჩევი ადრე Wayland-ზე ცალკე ზედა დონის ფანჯრად იხსნებოდა", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "ლოკალური საქაღალდეების ხე და ავტოიმპორტიც დისკს ფონურ ნაკადებში კითხულობს. იმპორტის პროგრესის ფანჯარას აქვს გაუქმება.", + "the My Photos title to bring one back, or choose Reset Sidebar Sections. The arrangement is remembered.": "„ჩემი ფოტოების“ სათაურზე მას დააბრუნებთ, ან აირჩიეთ გვერდითი ზოლის სექციების ჩამოყრა. განლაგება დაიმახსოვრება.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "„ფოტოს“ მენიუსა და ფოტოს კონტექსტურ მენიუში) იღებს სრული ზომის წინასწარ ხედს და ავსებს კამერის მეტამონაცემებს, რომლებიც", + "the arrow to see its photos.": "ისარზე მისი ფოტოების სანახავად.", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "კამერაში; სენსორის ცნობილი დეფექტები ივსება. `.rwl` და `.raw` ფაილებიც იმპორტირდება. ფოტოები, რომლებიც უკვე", + "the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages": "დეკოდირების ნაცვლად; პატარა წინასწარი ხედები ფაილის ჩაშენებულ ესკიზს იყენებს. გამოშვების პაკეტები", + "the graphics card": "ვიდეობარათი", + "the graphics card is not used": "ვიდეობარათი არ გამოიყენება", + "the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy.": "სხვა რედაქტორისთვის (რედაქტირება სხვა რედაქტორში, ახლა ⌥⌘E), და PhotoCraft-ის მდებარეობა, თუ მის პორტატულ ასლს იყენებთ.", + "the photo.": "ის თავად ფოტო იყოს.", + "the photos in view": "ხილული ფოტოები", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "იმავე სახელით და „გადაწერა“ (ან წინა პარამეტრებით ექსპორტი, რომელიც მას იმეორებს), ზუსტი გამოტანის მისამართი მართვის", + "the selected photos": "მონიშნული ფოტოები", + "the shadows: the black level recorded in the file is now subtracted.": "ნაცრისფერი ბურუსის გარეშე იხსნება: ფაილში ჩაწერილი შავის დონე ახლა აკლდება.", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "მათი საქაღალდე 30 დღეა არ დაგითვალიერებიათ — თქვენი ფაილები და თანმხლები ფაილები რჩება, საქაღალდის დათვალიერება კი მათ ისევ აჩვენებს", + "them saves a new set.": "დარქმევა ახალ ნაკრებს ინახავს.", + "this photo": "ეს ფოტო", + "this screen's profile": "ამ ეკრანის პროფილი", + "time is typed, and picking a day replaces one). Typing still works, and \"2026-10-01 10:00\" (a space for the": "დრო იკრიფება, დღის არჩევა კი მას ჩაანაცვლებს). აკრეფა კვლავ მუშაობს, და „2026-10-01 10:00“ (ჰარი", + "to save it as UTF-8. Exporting warns about keywords Capture One won't import, and names any keyword the format can't": "სთხოვს მის UTF-8-ად შენახვას. ექსპორტი აფრთხილებს საკვანძო სიტყვებზე, რომლებსაც Capture One ვერ შემოიტანს, და ასახელებს ყველა საკვანძო სიტყვას, რომელსაც ფორმატი ვერ", + "to the camera's own JPEG.": "კამერის საკუთარ JPEG-თან.", + "to the menu bar and the app ignored those keys, assuming the menu bar would handle them. Keys outside the menus": "გადასცემს, აპი კი ამ კლავიშებს უგულებელყოფდა იმ ვარაუდით, რომ მენიუს ზოლი დაამუშავებდა. მენიუების გარეთ არსებულ კლავიშებზე", + "triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter,": "სამკუთხედებთან ერთ სვეტშია, მაჩვენებლის ქვეშ — მონიშვნის ფონი. ჩაკეცილი სექცია აჩვენებს სტრიქონების რაოდენობას, 0 უფრო მკრთალია,", + "true": "ჭეშმარიტი", + "two dates of a \"between\" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a": "„შორის“-ის ორი თარიღის არეულად არჩევა შეუძლებელია; Esc მხოლოდ კალენდარს ხურავს. ის თარიღებს ირჩევს და არა დროს (", + "unknown": "უცნობი", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "ხელუხლებლად — ძველი ვერსიები აღარ კითხულობს მის ნაწილს დაზიანებულ ჟურნალად. მას შემდეგ, რაც ეს ვერსია ბიბლიოთეკას გახსნის,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "გამოიყენება (მაგ., გამოტოვებული პროგრამული ადაპტერი, როგორიცაა llvmpipe) და რატომ გადაერთო ბოლო რენდერი CPU-ზე.", + "usual and then added to Photos, into a top-level album of that name (made when it's missing). The toast says how": "და შემდეგ ემატება Photos-ს, ამ სახელის მქონე ზედა დონის ალბომში (იქმნება, თუ არ არსებობს). შეტყობინება ამბობს, რამდენი", + "video": "ვიდეო", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "რეგისტრის მიმართ მგრძნობიარე ტომებზე (Linux, APFS) `img_1.JPG` `IMG_1.JPG`-ის გვერდით სხვა ფოტოა და გადარქმეული იღებს", + "way round, \"in the last 0 days\", a colour label that doesn't exist, \"contains\" with nothing to look for, an": "მიმდევრობითაა, „ბოლო 0 დღე“, არარსებული ფერადი იარლიყი, „შეიცავს“ საძიებოს გარეშე,", + "weeks": "კვირა", + "were missing, without touching anything already set.": "აკლდა, უკვე დაყენებულის შეუცვლელად.", + "when a `cargo` build runs outside `nix develop` (no Vulkan loader); start it from there.": "როცა `cargo`-ს ანაწყობი `nix develop`-ის გარეთ ეშვება (Vulkan-ის ჩამტვირთავის გარეშე); გაუშვით იქიდან.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "როცა ბრაუზერმა შეიძლება ბიბლიოთეკა წაშალოს. ჰოსტინგი: ქეშის სანიმუშო სათაურები აღარ აღნიშნავს (ჰეშის გარეშე) ფაილებს", + "when the preview renders in a few milliseconds (it waits at most 40 % of a frame for it; slower renders, such as": "როცა წინასწარი ხედი რამდენიმე მილიწამში რენდერდება (მას კადრის მაქსიმუმ 40 %-ს ელოდება; უფრო ნელ რენდერებს, მაგალითად", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "არის თუ არა ორიგინალები ადგილზე, ფონურ ნაკადში მოწმდება (ბადის ესკიზები, ინფორმაციის პანელი, ფოტოს მენიუ, დაკარგული", + "which section you are in and can fold it from there.": "რომელ სექციაში ხართ და იქიდანვე შეგიძლიათ მისი ჩაკეცვა.", + "while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it.": "სანამ 1:1 დეტალი მზადდება. ის ჩნდება, როცა ეს ნახევარ წამს გაგრძელდება, ამიტომ სწრაფი რენდერები მას არასოდეს აჩვენებს.", + "white, empty Point Color slots…) as settings that couldn't be carried over.": "თეთრი, წერტილის ფერის ცარიელი სლოტები…) როგორც პარამეტრებს, რომელთა გადატანა ვერ მოხერხდა.", + "windows and toasts are gone.": "ფანჯრები და შეტყობინებები აღარ არის.", + "with a counterpart here come along, the rest is listed.": "რომლებსაც აქ შესატყვისი აქვს, გადმოდის, დანარჩენი ჩამოითვლება.", + "with the logs of the two previous runs beside it, so warnings and crashes of a run started from a desktop menu or the": "წინა ორი გაშვების ჟურნალებით მის გვერდით, ასე რომ სამუშაო მაგიდის მენიუდან ან Dock-იდან გაშვებული სესიის გაფრთხილებები და ავარიები", + "with the original and opens it in PhotoCraft. LightCraft finds PhotoCraft where its installer put it (Windows,": "ორიგინალთან ერთ დასტაში და ხსნის PhotoCraft-ში. LightCraft პოულობს PhotoCraft-ს იქ, სადაც მისმა ინსტალერმა დადო (Windows,", + "with those edits went from about 3.7 s to 1.9 s. Photos look the same as before.": "ამ რედაქტირებით დაახლოებით 3.7 წმ-დან 1.9 წმ-მდე შემცირდა. ფოტოები ისევე გამოიყურება, როგორც ადრე.", + "would make this album include itself through other smart albums": "ამ ალბომს სხვა სმარტ-ალბომების გავლით საკუთარი თავის ჩართვას აიძულებდა", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "რომელიც LightCraft-ის უფრო ახალმა ვერსიამ ჩაწერა, უარყოფილია შეტყობინებით „ეს ბიბლიოთეკა LightCraft-ის უფრო ახალმა ვერსიამ ჩაწერა“ და რჩება", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "რომელიც LightCraft-ის უფრო ახალმა ვერსიამ ჩაწერა), LightCraft ამას და მიზეზს გეუბნებათ და გთავაზობთ: ხელახლა ცდა, სხვა ბიბლიოთეკის არჩევა…, გაგრძელება", + "year, 2 digits": "წელი, 2 ციფრი", + "year, 4 digits": "წელი, 4 ციფრი", + "years": "წელი", + "yellow": "ყვითელი", + "yet and import as \"preview only\", with the reason.": "ჯერ არ შეუძლია, და „მხოლოდ წინასწარი ხედის“ სახით იმპორტირდება, მიზეზის მითითებით.", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} გადაღების დროა; ფოტო, რომელსაც ის არ აქვს, იმპორტის დროს იყენებს.", + "© 2026 Your Name": "© 2026 თქვენი სახელი", + "© Your Name": "© თქვენი სახელი", + "− harder border · + softer border": "− უფრო მკვეთრი საზღვარი · + უფრო რბილი საზღვარი", + "≤ at most": "≤ არაუმეტეს", + "≥ at least": "≥ არანაკლებ", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)" +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..87683dce4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -126,6 +126,7 @@ language_table! { Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -212,6 +213,22 @@ pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, S (tr("LightCraft could not start").to_string(), text) } +/// Whether sentence case may upper-case `c`. Georgian has no case: upper-casing Mkhedruli would +/// turn it into Mtavruli, a different (all-caps) script, so Georgian letters stay as written. +pub fn capitalizes(c: char) -> bool { + !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') +} + +/// `text` with its first letter upper-cased ("its input" → "Its input"), unless [`capitalizes`] +/// says that letter has no capital to take. +pub fn upper_first(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) if capitalizes(first) => first.to_uppercase().chain(chars).collect(), + _ => text.to_string(), + } +} + pub fn builtin_label(source: &str, builtin: bool) -> &str { if builtin { tr(source) } else { source } } @@ -432,6 +449,15 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat mod tests { use super::*; + #[test] + fn sentence_case_keeps_georgian_lowercase() { + assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); + assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება"); + assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ"); + assert_eq!(upper_first(""), ""); + assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); + } + #[test] fn every_language_has_a_catalog_covering_the_source() { assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); @@ -667,6 +693,7 @@ mod tests { ("app.language.german", Locale::De), ("app.language.russian", Locale::Ru), ("app.language.ukrainian", Locale::Uk), + ("app.language.georgian", Locale::Ka), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -893,6 +920,31 @@ mod tests { set_language(Locale::En); } + #[test] + fn georgian_switches_formats_counts_and_dates() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.georgian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Ka); + assert_eq!(crate::menubar::checked(&app, "app.language.georgian"), Some(true)); + assert_eq!(tr("Light"), "სინათლე"); + assert_eq!(date_group_label("2026-09-20", false), "კვირა, 20.09.2026"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + for n in [0, 1, 2, 5, 21] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("იმპორტირებულია ფოტო: {n}")); + } + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Trip", n = 3), "„Trip“-ს დაემატა ფოტო: 3"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"ka""#)); + for code in ["ka", "ka-GE", "ka_GE.UTF-8"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::Ka)); + } + // no Mtavruli: Georgian UI text is written in lowercase Mkhedruli + for text in Locale::Ka.catalog().values() { + assert!(!text.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "{text}"); + } + set_language(Locale::En); + } + #[test] fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { let ctx = fonts_ctx(&[]); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..87fb41a1c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -46,6 +47,7 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { "app.language.german" => Some(crate::i18n::Locale::De), "app.language.russian" => Some(crate::i18n::Locale::Ru), "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.georgian" => Some(crate::i18n::Locale::Ka), _ => None, } } diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 75488f76b..db961c3e0 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -606,9 +606,7 @@ pub(super) fn mb(bytes: Option<u64>) -> String { pub(super) fn sentence(s: &str) -> String { let s = s.trim(); - let mut c = s.chars(); - let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default(); - out.push_str(c.as_str()); + let mut out = crate::i18n::upper_first(s); if !out.is_empty() && !out.ends_with(['.', '!', '?']) { out.push('.'); } diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..5ef3d3d47 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -170,9 +170,7 @@ fn mb(bytes: Option<u64>) -> String { /// "its input is not an image" → "Its input is not an image." fn sentence(s: &str) -> String { let s = s.trim(); - let mut c = s.chars(); - let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default(); - out.push_str(c.as_str()); + let mut out = crate::i18n::upper_first(s); if !out.is_empty() && !out.ends_with(['.', '!', '?']) { out.push('.'); } @@ -340,7 +338,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if installed { - let r = ui.button("Remove"); + let r = ui.button(crate::i18n::tr("Remove")); register(ui.ctx(), format!("faces:remove:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.remove", json!({"id": id})); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..65bbcc151 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -617,13 +617,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 10, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 11, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b95b55b19..c67472317 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -103,7 +103,11 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + // eleven endonyms no longer fit one line of a 1000-pt window: they wrap + ui.horizontal_wrapped(|ui| { + ui.set_max_width(640.0); + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/docs/localization-ka.md b/docs/localization-ka.md new file mode 100644 index 000000000..234ab5ee9 --- /dev/null +++ b/docs/localization-ka.md @@ -0,0 +1,77 @@ +# LightCraft in Georgian + +Choose **Edit → Language → ქართული** or **Settings → General → Language**. The choice applies at once +and is saved for the next launch (`language: "ka"` in `ui.json`); the control command is +`app.language.georgian`. `LIGHTCRAFT_LANGUAGE` selects it for headless runs and accepts `ka`, `ka-GE` +and `ka_GE.UTF-8`; the saved value is always `ka`. LightCraft does not follow the system language, +for Georgian as for every other language. + +The Georgian catalogs cover every key of the other catalogs plus all command and slider labels: +menus, shortcuts, library, editing, crop, masks, people, import/export, settings, built-in presets and +profiles, history, versions, date headings, status messages, the smart-album rule editor and the +unsaved-changes dialog. The embedded release notes are translated too. Technical errors from lower +layers stay in English, as in the other languages. They were written from the meaning of the English +labels; no proprietary translation resources were consulted. + +File names, command ids, template placeholders and albums, presets or metadata you named yourself are +not translated. Descriptions for SAM 3 go to the model unchanged. + +## Georgian script + +Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the +interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts +are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with +`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. + +Inter has no Georgian letters and craft-fonts has no Georgian face yet, so Georgian text shows boxes +until a Georgian face is available to the UI. + +## Terminology + +| Source | Georgian | +|---|---| +| Library / Develop | ბიბლიოთეკა / დამუშავება | +| File / Edit / View | ფაილი / რედაქტირება / ხედი | +| Settings / General / Language | პარამეტრები / ზოგადი / ენა | +| Import / Export | იმპორტი / ექსპორტი | +| Photos / Albums / Folders | ფოტოები / ალბომები / საქაღალდეები | +| Smart Album | სმარტ-ალბომი | +| Crop | ჩამოჭრა | +| Masks / Masking | ნიღბები / ნიღბვა | +| Heal / Remove | აღდგენა / მოშორება | +| Presets / Profile | პრესეტები / პროფილი | +| History / Versions | ისტორია / ვერსიები | +| Light / Color / Effects / Detail | სინათლე / ფერი / ეფექტები / დეტალი | +| Exposure / Contrast | ექსპოზიცია / კონტრასტი | +| Highlights / Shadows | ღია ტონები / ჩრდილები | +| Whites / Blacks | თეთრები / შავები | +| Texture / Clarity / Dehaze | ტექსტურა / სიცხადე / ბურუსის მოცილება | +| Vibrance / Saturation | სიხალასე / გაჯერება | +| Hue / Luminance | ფერის ტონი / სიკაშკაშე | +| Tint | ტონირება | +| Tone Curve / Color Grading | ტონალური მრუდი / ფერის გრადაცია | +| Sharpening / Noise Reduction | გამკვეთრება / ხმაურის შემცირება | +| Lens Corrections / Geometry | ობიექტივის კორექციები / გეომეტრია | +| Vignette / Grain | ვინიეტი / მარცვლოვნება | +| Feather / Threshold | კიდეების შერბილება / ზღვარი | +| Rating / Flag / Keywords | შეფასება / დროშა / საკვანძო სიტყვები | +| Reset / Revert | ჩამოყრა / წინა ვერსიაზე დაბრუნება | +| Undo / Redo | დაბრუნება / გამეორება | + +Please follow this table and the shared ArtCraft Georgian glossary when you add strings. + +## Maintenance and checks + +Fixed texts: `crates/ui-egui/locales/ka.json` (2,719 entries). Messages with values: +`crates/ui-egui/locales/ka-formats.json` (249 entries). The English keys stay unchanged; Rust checks +the placeholders at build time. Strings not yet in the plain catalog show in English. + +```sh +cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture +LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000 +``` + +The tests check catalog coverage, placeholders, switching language by menu and control channel, +saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case +keeps Georgian lowercase. More notes are in +[localization.md](localization.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index c90f3a270..3085de10d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,12 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the interface no longer decides on error text, a prerequisite for translating lower-layer errors) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Georgian added; previously minor: the interface no longer decides on error text, a prerequisite for translating lower-layer errors) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-<code>.md` ([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md), [pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md), -[ru](localization-ru.md)). +[ru](localization-ru.md), [ka](localization-ka.md)). ## The target @@ -58,13 +58,14 @@ that is promising but is not recorded as review, so the column says `no (communi | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | -## Other languages LightCraft ships (3) +## Other languages LightCraft ships (4) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | | Ukrainian | `uk` | 2,287 / 2,304 (99%) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) | Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai @@ -92,4 +93,5 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) | +| 2026-10-12 | minor | Georgian (`ka`) row: every catalog message | | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | diff --git a/docs/localization.md b/docs/localization.md index 715b871af..86b1eaa2d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,10 +2,10 @@ The interface ships in the language it is written in (English) plus every language in the table in `crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional -Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`) and French (`fr`). This file is the reference for **adding or maintaining a language**; the per-language notes +Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`) and Georgian (`ka`). This file is the reference for **adding or maintaining a language**; the per-language notes are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md), [`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md), -[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md). +[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md) and [`localization-ka.md`](localization-ka.md). ## Adding a language @@ -30,6 +30,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); } ``` @@ -43,7 +44,9 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h 3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a language whose script has no face in the build shows boxes (the test says so instead of failing). Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a - missing face is added there, not worked around in this repository. + missing face is added there, not worked around in this repository. Sentence-cased messages go + through `i18n::upper_first`, which leaves Georgian lowercase (upper-casing Mkhedruli would make + Mtavruli). 4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check). ## How a message is found diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..454217021 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -475,8 +475,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | -| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | diff --git a/docs/roadmap.md b/docs/roadmap.md index f50d36886..3a02cd66d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -77,7 +77,7 @@ calibration: M0–M13 took ≈ 25 active hours against an estimate of ≈ 110– | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; contact-sheet PDF 🟡; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian/Georgian localisation 🟡; accessibility and further locales ⬜) | ## Risks that coding hours alone don't retire From 6d43479d38f86e4c89406764fd13cb5cf2e924bd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:52:01 +0000 Subject: [PATCH 194/315] Widen offline reverse-geocoding longitude search at high latitudes Found while testing crates/geocode/src/lib.rs:172-195 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- contributors/people.toml | 4 ++++ crates/geocode/src/lib.rs | 40 ++++++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..1b4225953 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -31,3 +31,7 @@ display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs index 3acb6b5f6..89fe86822 100644 --- a/crates/geocode/src/lib.rs +++ b/crates/geocode/src/lib.rs @@ -173,17 +173,22 @@ impl GeoNames { if !p.is_valid() { return None; } - let (cy, cx) = cell(p); + let (cy, _) = cell(p); let mut best: Option<(f64, &City)> = None; - // ±1° covers 50 km everywhere except very near the poles (where there are no cities) + // ±1° of latitude covers 50 km everywhere. Longitude degrees shrink with latitude: a point + // 50 km away differs by at most asin(sin(50 km / R) / cos(lat)) degrees of longitude + // (every longitude once the circle of latitude is that small). + let reach = (MAX_KM * 1000.0 / EARTH_RADIUS_M).sin(); + let scale = p.lat.to_radians().cos(); + let (x0, x1) = if scale <= reach { + (-180, 179) + } else { + let span = (reach / scale).asin().to_degrees(); + ((p.lon - span).floor() as i32, (p.lon + span).floor() as i32) + }; for dy in -1..=1 { - for dx in -1..=1 { - let mut x = cx + dx; - if x < -180 { - x += 360; - } else if x >= 180 { - x -= 360; - } + for dx in x0..=x1 { + let x = (dx + 180).rem_euclid(360) - 180; for &i in self.cells.get(&(cy + dy, x)).into_iter().flatten() { let Some(c) = self.cities.get(i as usize) else { continue }; let d = distance_m(p, c.at); @@ -248,6 +253,23 @@ mod tests { assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); } + #[test] + fn high_latitude_cities_two_cells_away() { + let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None); + // 70°N: 1° of longitude is ~38 km, so 45.6 km is two cells east + assert_eq!(city(70.0, 0.9).reverse(LatLon::new(70.0, 2.1)).unwrap().city, "X"); + assert_eq!(city(70.0, 2.1).reverse(LatLon::new(70.0, 0.9)).unwrap().city, "X"); + // 64°N: 1° is ~48.7 km, so 1.02° is just inside range yet two cells apart + assert_eq!(city(64.0, 0.99).reverse(LatLon::new(64.0, 2.01)).unwrap().city, "X"); + // across the antimeridian, two cells apart + assert_eq!(city(70.0, 178.9).reverse(LatLon::new(70.0, -179.9)).unwrap().city, "X"); + assert_eq!(city(70.0, -179.9).reverse(LatLon::new(70.0, 178.9)).unwrap().city, "X"); + // still out of range (~80 km) + assert!(city(70.0, 0.0).reverse(LatLon::new(70.0, 2.1)).is_none()); + // very near the pole every longitude is close + assert_eq!(city(89.9, 0.5).reverse(LatLon::new(89.9, 100.0)).unwrap().city, "X"); + } + #[test] fn junk_text_parses_to_nothing() { let g = GeoNames::parse("\t\t\t\t\t\n\u{0}\t\u{feff}\n", Some("\n\t\n"), Some("x")); From 30ec48d9b3e44bb43d8208ce523bf966cd3e2442 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 11:20:53 -0500 Subject: [PATCH 195/315] LR-META-XMP: Retry automatic XMP sidecar writes that failed A sidecar write that failed because the folder was offline or read-only was only logged. Keep those photos waiting, write them again once their files are reachable, and tell the user. --- crates/engine/src/cmd/xmp.rs | 12 +++++ crates/engine/src/lib.rs | 4 ++ crates/engine/src/sidecar.rs | 56 +++++++++++++++++++-- crates/engine/src/tests_xmp.rs | 39 ++++++++++++++ crates/ui-egui/locales/de-formats.json | 1 + crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es-formats.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr-formats.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja-formats.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br-formats.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru-formats.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk-formats.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans-formats.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant-formats.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/lib.rs | 27 ++++++++++ crates/ui-egui/src/state.rs | 7 +++ docs/parity-checklist.md | 3 +- docs/whats-new.md | 6 +++ 26 files changed, 167 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index cd89aa896..84987b15b 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -108,6 +108,18 @@ pub fn specs() -> Vec<CommandSpec> { always, prefs ), + cmd!( + "library.retrySidecars", + "Write Waiting XMP Sidecars", + [], + None, + "{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}", + always, + |s, _| { + let written = s.retry_unsaved_sidecars(); + Ok(json!({"written": written, "waiting": s.unsaved_sidecars()})) + } + ), cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| { let on = !s.xmp.auto_write; prefs(s, &json!({"autoWrite": on})) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..b127a0dd6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,6 +206,9 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by + /// [`Session::retry_unsaved_sidecars`] once their file is reachable. + pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>, /// AI denoise: the model in use, the photos that have their picture and the work in progress. pub(crate) denoise: denoise::State, /// Where the host keeps face models (one folder each); `None` where there is no file system (the web). @@ -353,6 +356,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..2f48e7425 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -713,13 +713,61 @@ impl Session { } let owners = StemOwners::among(&self.catalog, &ids); for id in ids { - if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) - && let Err(e) = self.save_sidecar_with(id, &owners) - { - log::warn!("auto-write XMP: {e}"); + if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) { + match self.save_sidecar_with(id, &owners) { + Ok(_) => { + self.unsaved_sidecars.remove(&id); + } + Err(e) => { + // kept for a retry once the file is reachable: a network share that dropped out mustn't leave + // the sidecar silently behind the edit + log::warn!("auto-write XMP: {e}"); + self.unsaved_sidecars.insert(id); + } + } } } } + + /// How many photos have an automatic sidecar write waiting (it failed: the folder offline, read-only or full). + pub fn unsaved_sidecars(&self) -> usize { + self.unsaved_sidecars.len() + } + + /// Write the waiting sidecars whose originals are reachable again (the availability cache answers without + /// touching the disk for the others); photos that are gone, or with auto-write off, are dropped from the wait. + /// Returns how many were written. + pub fn retry_unsaved_sidecars(&mut self) -> usize { + if !self.xmp.auto_write { + self.unsaved_sidecars.clear(); + return 0; + } + let mut ready = Vec::new(); + for id in self.unsaved_sidecars.clone() { + match self.catalog.photo(id).filter(|p| p.copy_of.is_none()).and_then(|p| file_path(p).map(str::to_owned)) { + None => { + self.unsaved_sidecars.remove(&id); + } + Some(path) if self.media.availability.exists(&path) == Some(true) => ready.push(id), + Some(_) => {} + } + } + if ready.is_empty() { + return 0; + } + let owners = StemOwners::among(&self.catalog, &ready); + let mut written = 0; + for id in ready { + match self.save_sidecar_with(id, &owners) { + Ok(_) => { + self.unsaved_sidecars.remove(&id); + written += 1; + } + Err(e) => log::debug!("retry XMP: {e}"), + } + } + written + } } #[cfg(test)] diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..3649b0b43 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -1209,3 +1209,42 @@ fn files_sharing_a_stem_get_their_own_sidecars() { let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } + +/// An automatic sidecar write that fails (here the folder is read-only, as a network share that dropped out or went +/// read-only) waits instead of being forgotten, and `library.retrySidecars` writes it once the folder takes writes +/// again. +#[cfg(unix)] +#[test] +fn a_failed_auto_write_waits_and_is_written_on_retry() { + use std::os::unix::fs::PermissionsExt; + let src = temp_dir("auto-retry"); + write_png(&src.join("a.png"), 4); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o555)).unwrap(); + // (root writes anyway: nothing to test there) + if std::fs::write(src.join(".probe"), b"x").is_ok() { + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let sidecar = src.join("a.xmp"); + assert!(!sidecar.exists()); + assert_eq!(s.unsaved_sidecars(), 1, "the failed write waits"); + let r = s.execute("library.retrySidecars", &json!({})).unwrap(); + assert_eq!(r, json!({"written": 0, "waiting": 1}), "still read-only"); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + let r = s.execute("library.retrySidecars", &json!({})).unwrap(); + assert_eq!(r, json!({"written": 1, "waiting": 0})); + assert!(std::fs::read_to_string(&sidecar).unwrap().contains("<xmp:Rating>4</xmp:Rating>")); + // a later edit that writes fine leaves nothing waiting; turning auto-write off drops what still waits + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o555)).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + assert_eq!(s.unsaved_sidecars(), 1); + s.execute("library.xmpPreferences", &json!({"autoWrite": false})).unwrap(); + s.execute("library.retrySidecars", &json!({})).unwrap(); + assert_eq!(s.unsaved_sidecars(), 0); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..dd7de11c8 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -61,6 +61,7 @@ "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", "Import failed: {e}": "Import fehlgeschlagen: {e}", "Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..b287fdf51 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1617,6 +1617,7 @@ "Window": "Fenster", "Winter Blue": "Winterblau", "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Ohne Speichern fortfahren und Beenden (Issue #100). Sie startet keine unbemerkte Demo-Sitzung mehr, die wie eine zurückgesetzte Bibliothek wirkte und", + "Wrote the XMP sidecars that were waiting": "Ausstehende XMP-Begleitdateien geschrieben", "XMP Sidecar Preferences": "Einstellungen für XMP-Begleitdateien", "XMP sidecars": "XMP-Begleitdateien", "Year": "Jahr", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..df9cdee3a 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -61,6 +61,7 @@ "Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}", "Import failed: {e}": "Error de importación: {e}", "Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "No se pudieron escribir los archivos XMP de {n} fotos (¿su carpeta está desconectada?). LightCraft lo sigue intentando.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..d9efd748b 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1581,6 +1581,7 @@ "Will be restored": "Se restaurará", "Window": "Ventana", "Winter Blue": "Azul invernal", + "Wrote the XMP sidecars that were waiting": "Se escribieron los archivos XMP pendientes", "XMP Sidecar Preferences": "Preferencias de archivos XMP complementarios", "XMP sidecars": "Archivos XMP complementarios", "Year": "Año", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..9e0fceba9 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -65,6 +65,7 @@ "Imported {n} preset{}": "{n} préréglage{} importé(s)", "Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Les fichiers annexes XMP de {n} photos n'ont pas pu être écrits (leur dossier est-il hors ligne ?). LightCraft continue d'essayer.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Le profil XMP « {profile} » n’a pas été appliqué à {path} ; les couleurs peuvent différer de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La géométrie Lightroom de {path} n’a pas été conservée ; {mode} sera recalculé.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée est ignorée, car Upright est désactivé.", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..fc2a1a1e9 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1543,6 +1543,7 @@ "Will be restored": "Sera restaurée", "Window": "Fenêtre", "Winter Blue": "Bleu hivernal", + "Wrote the XMP sidecars that were waiting": "Fichiers annexes XMP en attente écrits", "XMP Sidecar Preferences": "Préférences des fichiers annexes XMP", "XMP sidecars": "Fichiers annexes XMP", "Year": "Année", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..dbac557d4 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -66,6 +66,7 @@ "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", "Showing {n} of {}": "{}枚中{n}枚を表示", "Smart album: {}": "スマートアルバム:{}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "{n} 枚の写真の XMP サイドカーを書き込めませんでした(フォルダーがオフラインではありませんか?)。LightCraft は再試行を続けます。", "Undo {}": "取り消す:{}", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ", "Version: {}": "バージョン:{}", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..efa5752c8 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1471,6 +1471,7 @@ "Will be restored": "復元されます", "Window": "ウィンドウ", "Winter Blue": "ウィンターブルー", + "Wrote the XMP sidecars that were waiting": "保留中の XMP サイドカーを書き込みました", "XMP Sidecar Preferences": "XMPサイドカーファイルの環境設定", "XMP sidecars": "XMPサイドカーファイル", "Year": "年", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..1417d88cb 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -87,6 +87,7 @@ "Show {hidden} hidden location{}": "Mostrar {hidden} local(is) oculto(s){:.0}", "Showing {n} of {}": "Mostrando {n} de {}", "Smart album: {}": "Álbum inteligente: {}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Não foi possível gravar os arquivos XMP de {n} fotos (a pasta está offline?). O LightCraft continua tentando.", "Undo {}": "Desfazer {}", "Updated “{name}”": "“{name}” atualizado", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versão {} — uma biblioteca de fotos e revelador RAW em Rust puro, desenvolvida de forma independente.", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..cee25024e 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1566,6 +1566,7 @@ "Will be restored": "Será restaurada", "Window": "Janela", "Winter Blue": "Azul invernal", + "Wrote the XMP sidecars that were waiting": "Os arquivos XMP pendentes foram gravados", "XMP Sidecar Preferences": "Preferências de sidecar XMP", "XMP sidecars": "Sidecars XMP", "Year": "Ano", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..a3f3bb08f 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -64,6 +64,7 @@ "Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}", "Import failed: {e}": "Ошибка импорта: {e}", "Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не удалось записать XMP-файлы {n} фото (папка недоступна?). LightCraft продолжает попытки.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрия Lightroom для {path} не сохранена; режим {mode} будет пересчитан.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование игнорируется, поскольку Upright выключен.", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..aaa571798 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1309,6 +1309,7 @@ "Width × Height": "Ширина × высота", "Window": "Окно", "Winter Blue": "Зимняя синева", + "Wrote the XMP sidecars that were waiting": "Ожидавшие XMP-файлы записаны", "XMP Sidecar Preferences": "Настройки XMP-файлов", "XMP sidecars": "XMP-файлы", "Year": "Год", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..0f9f360a5 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -65,6 +65,7 @@ "Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}", "Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не вдалося записати XMP-файли {n} фото (папка недоступна?). LightCraft продовжує спроби.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профіль XMP «{profile}» не застосовано до {path}; кольори можуть відрізнятися від Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрію Lightroom для {path} не збережено; режим {mode} буде перераховано.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрію Lightroom для {path} не збережено; збережене перетворення ігнорується, оскільки Upright вимкнено.", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..62204d635 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -305,6 +305,7 @@ "Whites": "Білі тони", "Width": "Ширина", "Window": "Вікно", + "Wrote the XMP sidecars that were waiting": "XMP-файли, що очікували, записано", "Yellow": "Жовтий", "Yes": "Так", "Zoom": "Масштаб", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..7ea9340e5 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -66,6 +66,7 @@ "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", "Showing {n} of {}": "显示 {0} 张中的 {n} 张", "Smart album: {}": "智能相册:{0}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "无法写入 {n} 张照片的 XMP 附属文件(其文件夹是否离线?)。LightCraft 会继续重试。", "Undo {}": "撤销 {0}", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", "Version: {}": "版本:{0}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..407be8654 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1547,6 +1547,7 @@ "Will be restored": "将恢复", "Window": "窗口", "Winter Blue": "冬日蓝调", + "Wrote the XMP sidecars that were waiting": "已写入等待中的 XMP 附属文件", "XMP Sidecar Preferences": "XMP 附属文件偏好设置", "XMP sidecars": "XMP 附属文件", "Year": "年", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..945585b6a 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -83,6 +83,7 @@ "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", "Showing {n} of {}": "顯示 {n}/{} 張", "Smart album: {}": "智慧型相簿:{}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "無法寫入 {n} 張照片的 XMP 附屬檔案(其資料夾是否離線?)。LightCraft 會持續重試。", "Undo {}": "復原 {}", "Updated “{name}”": "已更新「{name}」", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..215f3c29c 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1547,6 +1547,7 @@ "Will be restored": "將還原", "Window": "視窗", "Winter Blue": "冬日藍調", + "Wrote the XMP sidecars that were waiting": "已寫入等待中的 XMP 附屬檔案", "XMP Sidecar Preferences": "XMP 附屬檔案偏好設定", "XMP sidecars": "XMP 附屬檔案", "Year": "年", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..d54f50e9c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1092,6 +1092,33 @@ impl LightcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(3)); } + // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files + // are reachable, and said once when they start waiting and once when they're written + #[cfg(not(target_arch = "wasm32"))] + { + if self.session.unsaved_sidecars() > 0 { + let now = ctx.input(|i| i.time); + if now - self.ui.sidecar_retry_at >= 10.0 { + self.ui.sidecar_retry_at = now; + self.session.retry_unsaved_sidecars(); + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } + let waiting = self.session.unsaved_sidecars(); + if waiting > 0 && self.ui.sidecars_waiting == 0 { + self.toast_for( + ctx, + crate::i18n::tr_format!( + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + n = waiting + ), + 8.0, + ); + } else if waiting == 0 && self.ui.sidecars_waiting > 0 { + self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); + } + self.ui.sidecars_waiting = waiting; + } self.session.persist_if_dirty(); panels::denoise::pump(self, ctx); model_setup::pump(self, ctx); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..30e7f6357 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -613,6 +613,11 @@ pub struct UiState { /// When the watched folder was last scanned (egui time). #[serde(skip)] pub auto_import_at: f64, + /// When the automatic XMP writes that failed were last tried again (egui time), and how many were waiting then. + #[serde(skip)] + pub sidecar_retry_at: f64, + #[serde(skip)] + pub sidecars_waiting: usize, /// The develop control whose slider is being dragged (geometry sliders show a grid). #[serde(skip)] pub dragging_control: Option<String>, @@ -1101,6 +1106,8 @@ impl Default for UiState { external_edits: Vec::new(), was_focused: true, auto_import_at: 0.0, + sidecar_retry_at: 0.0, + sidecars_waiting: 0, search: String::new(), focus_search: false, renaming_mask: None, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..574ec0587 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-META-XMP: failed automatic sidecar writes are retried; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | LR-META-XMP: automatic sidecar writes that fail (folder offline or read-only) wait and are written again once the files are reachable, with a notice (`cmd:library.retrySidecars`); status unchanged | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..6e5268cbe 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### XMP sidecars catch up after a network share comes back +- With automatic XMP writing on, an edit to a photo whose folder can't be written (a network share that dropped + out, a folder that went read-only) used to be written only to the library, with a line in the log, so the sidecar + stayed behind the edit. LightCraft now says once that the sidecars are waiting, tries again every few seconds once + the files are reachable, and says when they're written. `library.retrySidecars` does it on request. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 9ef40b98866ca7270fa4621c83028357c7ed79b8 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 11:28:23 -0500 Subject: [PATCH 196/315] Commands: Refuse malformed photo ids instead of using the selection (#1506) A wrongly typed id or ids fell back to the selection, or kept only the valid part of the list. Session::targets now returns an error for those, and callers pass it on. --- crates/engine/src/cmd/browse.rs | 4 +-- crates/engine/src/cmd/convert.rs | 8 +++--- crates/engine/src/cmd/cull.rs | 2 +- crates/engine/src/cmd/develop.rs | 15 ++++++----- crates/engine/src/cmd/face_detect.rs | 2 +- crates/engine/src/cmd/face_recognize.rs | 2 +- crates/engine/src/cmd/geocode.rs | 2 +- crates/engine/src/cmd/keywords.rs | 6 ++--- crates/engine/src/cmd/library.rs | 21 +++++++-------- crates/engine/src/cmd/manage.rs | 6 ++--- crates/engine/src/cmd/merge.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/organize.rs | 14 +++++----- crates/engine/src/cmd/previews.rs | 6 ++--- crates/engine/src/cmd/xmp.rs | 4 +-- crates/engine/src/contact_sheet.rs | 2 +- crates/engine/src/lib.rs | 28 +++++++++++++++----- crates/engine/src/tests.rs | 32 +++++++++++++++++++++++ crates/ui-egui/src/menubar.rs | 2 +- crates/ui-egui/src/menus.rs | 6 ++--- crates/ui-egui/src/merge.rs | 4 +-- crates/ui-egui/src/panels/dialogs.rs | 14 +++++----- crates/ui-egui/src/panels/grid.rs | 4 +-- crates/ui-egui/src/panels/keyword_list.rs | 2 +- crates/ui-egui/src/panels/keywording.rs | 4 +-- crates/ui-egui/src/panels/left.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +-- crates/ui-egui/src/shortcuts.rs | 2 +- crates/ui-egui/src/tests_keywording.rs | 2 +- docs/whats-new.md | 5 ++++ 30 files changed, 130 insertions(+), 79 deletions(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14b..0b4049a06 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -151,7 +151,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> { /// The browsed photos among `ids` (default: the selection) become library photos. fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> { - let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); + let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); if ids.is_empty() { return Ok(json!({"added": 0})); } @@ -337,7 +337,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> { if !dir.is_dir() { return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); } - let ids = s.targets(p); + let ids = s.targets(p)?; if ids.is_empty() { return Err(bad(C, "no photos to move: select some or give ids")); } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..ef01bf47f 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -58,7 +58,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path fn convert(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "photo.convertToDng"; - let ids = s.targets(p); + let ids = s.targets(p)?; let mut ops = Vec::new(); let mut converted = Vec::new(); let mut skipped = Vec::new(); @@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> { /// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "photo.editExternal"; - let mut ids = s.targets(p); + let mut ids = s.targets(p)?; ids.dedup(); if ids.is_empty() { return Err(bad(C, "no active photo")); @@ -274,7 +274,7 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result<Value> { - let ids = s.targets(p); + let ids = s.targets(p)?; let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids .iter() .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { @@ -320,7 +320,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> { fn duplicate(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "photo.duplicate"; let mut made = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue }; let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) }; let src = Path::new(path); diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 51049797e..676519d29 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -20,7 +20,7 @@ fn secs(iso: &str) -> Option<i64> { } fn analyze(s: &mut Session, p: &Value) -> Result<Value> { - let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); // measure (thumbnail-level sources: fast, and enough for focus at 512 px) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260..f85998138 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> { }) }), cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| { - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -304,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> { let targets = match named_photos(s, p, C)? { Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())), Some(ids) => ids, - None => s.targets(p), + None => s.targets(p)?, }; let ops: Vec<Op> = targets .into_iter() @@ -810,7 +810,7 @@ pub fn specs() -> Vec<CommandSpec> { s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?; let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); - let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect(); + let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect(); let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -837,7 +837,7 @@ pub fn specs() -> Vec<CommandSpec> { let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0); clip = lightcraft_develop::extract_groups(&full, &only); } - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -889,7 +889,7 @@ pub fn specs() -> Vec<CommandSpec> { let delta = f64_req(p, "delta", c)?; let label = format!("Quick Develop: {}", spec.label); let ops: Vec<Op> = s - .targets(p) + .targets(p)? .into_iter() .filter_map(|id| s.develop_of(id).map(|d| (id, d))) .filter_map(|(id, d)| { @@ -942,7 +942,7 @@ pub fn specs() -> Vec<CommandSpec> { }; let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?; let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0); - let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect(); + let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect(); let (mut ops, mut skipped) = (Vec::new(), Vec::new()); for id in ids { let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else { @@ -977,7 +977,8 @@ pub fn specs() -> Vec<CommandSpec> { let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?; let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?; let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0; - let ids = s.targets(p); + // `id` names the preset here: the photos are `ids`, else the selection + let ids = s.targets(&json!({"ids": p.get("ids")}))?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 4676d1c65..2328a3db4 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -132,7 +132,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> { let detector = detector(s).map_err(|e| e.into_error(C))?; let started = Instant::now(); let (mut results, mut ops) = (Vec::new(), Vec::new()); - for id in s.targets(p) { + for id in s.targets(p)? { let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) { Ok(r) => r, Err(e) => { diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..a30776b51 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -751,7 +751,7 @@ mod imp { const C: &str = "faces.suggest"; let embedder = current(s, C)?; collect(s); - let targets = s.targets(p); + let targets = s.targets(p)?; let model_threshold = embedder.manifest().thresholds.match_cosine; let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32); let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD); diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index ddbe6b13a..085183d58 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, fn reverse(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "metadata.reverseGeocode"; let overwrite = bool_or(p, "overwrite", false); - let ids = s.targets(p); + let ids = s.targets(p)?; let todo: Vec<(PhotoId, LatLon)> = ids .iter() .filter_map(|id| { diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index b2c7e90f0..fe09a92f9 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> { let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else { return Ok(json!({"changed": 0})); }; - let ids = s.targets(p); + let ids = s.targets(p)?; let all = !ids.is_empty() && ids.iter().all(|id| { s.catalog @@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> { always, |s, p| { let mut current: Vec<String> = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { if let Some(ph) = s.catalog.photo(id) { current.extend(ph.meta.keywords.iter().cloned()); } @@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> { Some(parent) => format!("{parent}|{name}"), None => name, }); - let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() }; + let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() }; let op = s .catalog .create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..1908002cf 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -169,7 +169,7 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> { - let targets = s.targets(p); + let targets = s.targets(p)?; let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect(); let n = ops.len(); if n > 0 { @@ -181,7 +181,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O /// Adjust each target's own rating; unchanged bounds do not create an undo step. fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> { let mut ops = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; if rating != old { @@ -226,7 +226,7 @@ fn row_step(p: &Value) -> isize { } fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -241,7 +241,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> { } fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -807,7 +807,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let before = s.visible_cloned(); - let t = s.targets(p); + let t = s.targets(p)?; let op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; select_after_deletion(s, &before); @@ -824,7 +824,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { let strs = |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>()); - let targets = s.targets(p); + let targets = s.targets(p)?; // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear let gps: Option<Option<(f64, f64)>> = match p.get("gps") { None => None, @@ -922,8 +922,7 @@ pub fn specs() -> Vec<CommandSpec> { } let folder = bool_or(p, "folder", false); let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let (photos, skipped) = - if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) }; + let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); s.commit( @@ -1163,7 +1162,7 @@ pub fn specs() -> Vec<CommandSpec> { has_library_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; if al.is_smart() || al.folder { return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); @@ -1190,7 +1189,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", has_library_selection, |s, p| { - let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?; + let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?; let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { @@ -1336,7 +1335,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| { let id = album_param(p, "id", "album.removePhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index d06271525..66ddfe9fb 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -10,7 +10,7 @@ use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> { let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string(); let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize; - Ok((s.targets(p), template, start)) + Ok((s.targets(p)?, template, start)) } /// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name). @@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> { let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0); let mut ops = Vec::new(); let mut photos = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id) else { continue }; if ph.meta.gps.is_some() && !replace { has_gps += 1; @@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; let c = "photo.setCaptureTime"; - let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); + let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); if targets.is_empty() { return Err(bad(c, "no photos")); } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..6fcbad27f 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> { fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> { let (kind, finish) = crate::merge::parse(id, p)?; - let ids: Vec<PhotoId> = s.targets(p); + let ids: Vec<PhotoId> = s.targets(p)?; if !bool_or(p, "preview", false) { return s.merge_now(kind, finish, &ids); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..f09ff2d78 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -105,7 +105,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { /// can take (issue #684; Add to My Photos first). pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } + if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } } /// Why an album command refused a Local-only selection. pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)"; diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 494785628..24cc2061c 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> { fn in_stack(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } + if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } } fn has_stacks(s: &Session) -> std::result::Result<(), String> { @@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); let mut made = Vec::new(); for t in targets { @@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let top = p .get("top") .and_then(Value::as_u64) @@ -153,11 +153,11 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { - let ops = s.catalog.ungroup_ops(&s.targets(p)); + let ops = s.catalog.ungroup_ops(&s.targets(p)?); Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) }), cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { - let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?); Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) }), cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { @@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { - let stacks = stacks_of(s, &s.targets(p)); + let stacks = stacks_of(s, &s.targets(p)?); let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); let collapsed = bool_or(p, "collapsed", !first); set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) @@ -212,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { let gap = f64_or(p, "gap", 60.0).max(0.0); let explicit = p.get("ids").is_some(); - let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let groups = s.catalog.auto_stack_groups(&pool, gap); let photos: usize = groups.iter().map(Vec::len).sum(); if bool_or(p, "preview", false) { diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 4fc7ababa..b63ccc2b4 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(C, "a preview build is already running")); } // explicit ids, else the selection, else everything in view - let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); @@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> { if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) { return Err(bad(C, "a preview build is already running")); } - let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let mut jobs: Vec<SmartJob> = Vec::new(); for id in ids { let Some(ph) = s.catalog.photo(id).cloned() else { continue }; @@ -503,7 +503,7 @@ pub fn specs() -> Vec<CommandSpec> { smart_location ), cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| { - let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; + let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; let online = match s.catalog.photo(id).map(|p| p.source.clone()) { Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(), _ => true, diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index cd89aa896..f163e499a 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> { let mut merged = Vec::new(); let mut backups = Vec::new(); let mut failed = Vec::new(); - let ids = s.targets(p); + let ids = s.targets(p)?; let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids); for id in ids { match s.save_sidecar_with(id, &owners) { @@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> { let mut read = Vec::new(); let mut failed = Vec::new(); let mut warnings = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { match s.read_sidecar_op_with_warnings(id) { Ok(Some((op, from, mut photo_warnings))) => { ops.push(op); diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index e83ad8c67..320323fa3 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -82,7 +82,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> { if p.get("id").is_some_and(|v| v.as_u64().is_none()) { return Err("id must be a photo id".into()); } - let ids = session.targets(p); + let ids = session.targets(p).map_err(|e| e.to_string())?; if ids.is_empty() || ids.len() > 1000 { return Err("select between 1 and 1000 photos".into()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..b8f9e7857 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -427,7 +427,11 @@ impl Session { } let empty = Value::Object(Default::default()); let params = if params.is_null() { &empty } else { params }; - self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)) + self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e { + // a shared parameter check ([`Session::targets`]) names the command it ran for + EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg }, + e => e, + }) } /// Run `f` as command `id` with what [`Session::execute`] does around every command: the @@ -963,14 +967,24 @@ impl Session { (self.visible_gen, self.visible.clone()) } - /// Targets of photo commands: explicit `ids`/`id` param, else the selection. - pub fn targets(&self, p: &Value) -> Vec<PhotoId> { - if let Some(a) = p.get("ids").and_then(Value::as_array) { - return a.iter().filter_map(Value::as_u64).map(PhotoId).collect(); + /// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`]. + /// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list + /// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo. + pub fn targets(&self, p: &Value) -> Result<Vec<PhotoId>> { + let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") }; + match p.get("ids") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(), + Some(_) => return Err(invalid("ids")), } - if let Some(id) = p.get("id").and_then(Value::as_u64) { - return vec![PhotoId(id)]; + match p.get("id") { + None | Some(Value::Null) => Ok(self.selected()), + Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")), } + } + + /// The photos commands act on when a call names none: the selection, else the active photo. + pub fn selected(&self) -> Vec<PhotoId> { if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..dc586be0f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1486,3 +1486,35 @@ fn oversized_spots_render() { } } } + +/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes +/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets +/// out still means the selection. +#[test] +fn malformed_photo_targets_are_refused_not_widened() { + use lightcraft_catalog::PhotoId; + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap(); + let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating; + let before: Vec<u8> = ids.iter().map(|i| rating(&s, *i)).collect(); + for bad in [ + json!({"rating": 5, "id": "5"}), + json!({"rating": 5, "ids": [ids[2], "x"]}), + json!({"rating": 5, "ids": ids[2].to_string()}), + json!({"rating": 5, "id": -1}), + ] { + let err = s.execute("photo.rate", &bad).unwrap_err(); + assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}"); + assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::<Vec<_>>(), before, "{bad} changed a rating"); + } + let snapshot = s.catalog.to_snapshot(); + assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err()); + assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted"); + // no targets, or a null one: the selection + s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap(); + assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2])); + // a preset's id is not a photo target + let preset = s.presets.first().unwrap().id.clone(); + s.execute("preset.apply", &json!({"id": preset})).unwrap(); +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..ae0b429ff 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -644,7 +644,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { v } "Add to Album" => { - let targets = app.session.targets(&Value::Null); + let targets = app.session.selected(); let sel = !targets.is_empty(); // albums hold library photos (issue #684): a Local-only selection is told what to do first let local_only = crate::panels::grid::local_only(app, &targets); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..ba7607e05 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1932,7 +1932,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(), "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { - app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() + app.session.selected().len() >= 2 && app.merge.final_task.is_none() } _ => true, } @@ -1979,7 +1979,7 @@ pub fn confirm_delete(app: &mut LightcraftApp) -> bool { if !app.ui.settings.confirm_delete { return false; } - let count = app.session.targets(&json!({})).len(); + let count = app.session.selected().len(); if count == 0 { return false; } @@ -2097,7 +2097,7 @@ fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> } fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { - let mut ids = app.session.targets(p); + let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; ids.dedup(); if ids.is_empty() { return Err("no photo selected".into()); diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..05e7baa8e 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -148,7 +148,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b /// Open the merge dialog for the selection. pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } @@ -185,7 +185,7 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result<Value, /// Merge the selection without the dialog, with the options last used for `command` (the /// defaults the first time). pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..08b05e4bf 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,7 +22,7 @@ pub(crate) fn rename_preview( ) -> (Option<Vec<lightcraft_engine::rename::RenamePlan>>, usize) { use std::hash::{Hash, Hasher}; type Rows = std::sync::Arc<std::sync::Mutex<Option<Vec<lightcraft_engine::rename::RenamePlan>>>>; - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); let total = photos.len(); let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -71,7 +71,7 @@ fn watermark_preview( type Base = std::sync::Arc<std::sync::Mutex<(u8, Option<Result<lightcraft_raster::Rgba8, String>>)>>; type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); let t = Tokens::get(ui.ctx()); - let Some(&id) = app.session.targets(&json!({})).first() else { return }; + let Some(&id) = app.session.selected().first() else { return }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); let export_short = out_w.min(out_h); @@ -439,7 +439,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); field(ui, "Change", |ui| { ui.spacing_mut().item_spacing.x = 4.0; for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { @@ -523,7 +523,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::Rename { template, start } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); let template_id = egui::Id::new("rename-template"); let tags_open = field(ui, "Template", |ui| { @@ -614,7 +614,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.targets(&json!({})).is_empty() { + if !*folder && !app.session.selected().is_empty() { ui.add_space(6.0); let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); @@ -633,7 +633,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::PasteSettings { groups } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label( egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), @@ -643,7 +643,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ContactSheet { options } => { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..76952b5ce 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1040,7 +1040,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); - if local_only(app, &app.session.targets(&serde_json::Value::Null)) { + if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); register(ui.ctx(), "button:contextAddToAlbum", add.rect); @@ -1146,7 +1146,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.reset", json!({})); } ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(id, json!({})); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..57ffb3549 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -72,7 +72,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim)); }); } - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); for r in &rows { row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 857498599..6a0f8b493 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -30,7 +30,7 @@ pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// exported files will carry. One only some of the photos have is marked with an asterisk. pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let export = app.ui.keywording_view == crate::state::KeywordingView::WillExport; let names = app.caches.keyword_names(&app.session.catalog, &selection, export); if names.is_empty() { @@ -120,7 +120,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { /// The selection's keywords as chips: the name (right-click for its menu), and × to take it off /// every selected photo. One only some of them have is marked with an asterisk. pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); ui.horizontal_wrapped(|ui| { for c in chips.iter() { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..5e19661fc 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1571,7 +1571,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { ui.separator(); } if !a.folder && !a.is_smart() { - let local_only = super::grid::local_only(app, &app.session.targets(&serde_json::Value::Null)); + let local_only = super::grid::local_only(app, &app.session.selected()); let b = ui .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) .on_disabled_hover_text(super::grid::albums_hint()); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..60f1aa3cc 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -876,7 +876,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let typed = ui.data(|d| d.get_temp::<String>(kid).unwrap_or_default()); let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); // the keywords every selected photo has: those only some have are still suggested - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let have: Vec<String> = app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); @@ -904,7 +904,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); let t = Tokens::get(ui.ctx()); let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b10387c76..c88d01683 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -491,7 +491,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // B: the brush while editing; in the grids, add to the target album (Quick Collection) if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); app.toast( diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..acf2c1d54 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -191,7 +191,7 @@ fn the_active_photo_alone_is_the_selection() { assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(ids[0])); assert!(h.app.session.selection.ids.is_empty()); assert!(has(&h, "keywordChip:Weddings") && !has(&h, "keywordChipPartial:Weddings"), "the active photo's keywords"); - let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.targets(&json!({}))); + let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.selected()); assert_eq!(ticks.tick("Weddings"), crate::panels::keyword_list::Tick::All); assert!(has(&h, "keywordCheck:beach")); } diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..0ba1a14cb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Reliability +- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a + script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, + or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 6d080de4470221e2289203c86d51023a9d0c7790 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 12:47:21 -0500 Subject: [PATCH 197/315] Never crash: Check raw data size before allocating in PEF, TIFF tiles and ARW6 (#1512) A compressed PEF too short for its size, an uncompressed tile claiming far more samples than it holds, and an ARW6 with a failed tile now get refused before the big buffer is allocated. --- crates/raw/src/tiffraw.rs | 35 ++++++++++++++++++++++++++++++++++- crates/raw/src/vendor/arw6.rs | 4 ++-- crates/raw/src/vendor/pef.rs | 20 +++++++++++++++++++- docs/whats-new.md | 6 ++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 293fafb61..8093cb9d3 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -156,7 +156,15 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack return Err(RawError::Limit("chunk too large")); } match info.compression { - comp::NONE => unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1), + comp::NONE => { + // uncompressed: missing rows are filled with zeros, but a chunk holding a small fraction of what its + // samples need is no picture: refused before the nominal chunk is allocated (issue #1512) + let need = (n as u64).saturating_mul(u64::from(bits)) / 8; + if (src.len() as u64).saturating_mul(64) < need { + return Err(RawError::Corrupt(format!("an uncompressed chunk of {n} samples has only {} bytes", src.len()))); + } + unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1) + } comp::JPEG => { let f = ljpeg::decode(src, n.saturating_mul(2).max(1 << 16))?; if f.data.len() < n { @@ -405,6 +413,31 @@ mod lossy_tests { assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err()); } + /// A small image whose uncompressed tile claims to be enormous (16384 x 16384 nominal, a few bytes of data) is + /// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes. + #[test] + fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() { + let info = |tile: u32, bytes: u64| ImageInfo { + width: 8, + height: 8, + bits_per_sample: vec![16], + samples_per_pixel: 1, + compression: comp::NONE, + photometric: 32803, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: tile, tile_height: tile }, + offsets: vec![0], + byte_counts: vec![bytes], + }; + let small = vec![1u8; 2 * 16 * 16]; + assert!(read_image(&small, &info(16, small.len() as u64), ByteOrder::Little, Packing::Msb).is_ok()); + let data = vec![1u8; 4096]; + assert!(matches!(read_image(&data, &info(16384, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } + /// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter. #[test] fn short_lossless_jpeg_tiles_are_errors() { diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index f6f535bd6..46af909dd 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -524,10 +524,10 @@ pub(crate) fn decode(strip: &[u8], width: usize, height: usize, mode: crate::Mod } return Ok(Vec::new()); } - let decoded: Vec<Result<Vec<u16>>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect(); + // a tile that fails is reported before the mosaic is allocated (issue #1512) + let decoded: Vec<Vec<u16>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect::<Result<_>>()?; let mut out = vec![0u16; width.checked_mul(height).ok_or_else(|| corrupt("image size"))?]; for (t, d) in tiles.iter().zip(decoded) { - let d = d?; for (r, row) in d.chunks_exact(t.width.max(1)).enumerate() { let start = (t.y + r).checked_mul(width).and_then(|a| a.checked_add(t.x)).ok_or_else(|| corrupt("tile placement"))?; let dst = start.checked_add(row.len()).and_then(|end| out.get_mut(start..end)).ok_or_else(|| corrupt("tile outside image"))?; diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 86db33540..49486be74 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -143,7 +143,13 @@ pub(crate) fn table(b: &[u8], order: ByteOrder) -> Result<Huffman> { /// Decode a `w × h` Huffman-coded image. pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits_per: u32) -> Result<Vec<u16>> { - let mut out = vec![0u16; w * hgt]; + // every sample takes at least one bit: a stream too short for the declared size is refused before its picture + // is allocated, so a few bytes can't ask for gigabytes (issue #1512) + let samples = w.checked_mul(hgt).ok_or(RawError::Limit("image too large"))?; + if (src.len() as u64).saturating_mul(8) < samples as u64 { + return Err(RawError::Corrupt(format!("PEF: {w} x {hgt} samples need more than the {} bytes of data there", src.len()))); + } + let mut out = vec![0u16; samples]; let mut bits = Bits::new(src); let mut vpred = [[0i32; 2]; 2]; let max = (1i32 << bits_per.min(16)) - 1; @@ -358,6 +364,18 @@ mod tests { (b, codes) } + /// A compressed PEF declaring far more samples than its data could hold (one bit each at least) is refused + /// before its picture is allocated (issue #1512). + #[test] + fn a_stream_too_short_for_its_size_is_refused_before_allocating() { + let (tb, _) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + let t = std::time::Instant::now(); + let r = decode_huffman(&[0x55; 4096], &h, 30_000, 30_000, 12); + assert!(matches!(r, Err(RawError::Corrupt(ref m)) if m.contains("need more")), "{r:?}"); + assert!(t.elapsed() < std::time::Duration::from_secs(1)); + } + fn encode(px: &[u16], w: usize, codes: &[(u16, u8)]) -> Vec<u8> { let mut bits: Vec<bool> = Vec::new(); let put = |v: u32, n: u8, bits: &mut Vec<bool>| (0..n).rev().for_each(|i| bits.push(v >> i & 1 == 1)); diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..8cc534688 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Reliability +- A damaged or crafted raw can no longer make LightCraft set aside gigabytes of memory before finding out the file is + bad (issue #1512): a compressed PEF whose data is too short for the size it declares, an uncompressed TIFF-based + raw whose tile claims far more samples than it holds, and an ARW6 with a broken tile are refused first. All 74 + files in the test corpus still decode. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 1f87328c6d083f921969605d5846b412a1b56572 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 13:06:34 -0500 Subject: [PATCH 198/315] Never crash: Write preview cache files from one bounded writer (#1522) Each deferred cache write started its own thread holding its image, with no limit. One writer thread per cache now takes them in turn, and a write is skipped once 128 MB is waiting. --- crates/preview/src/lib.rs | 99 +++++++++++++++++++++++++++++++------ crates/preview/src/tests.rs | 23 +++++++++ docs/whats-new.md | 6 +++ 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index a571ecda0..14bd50a1b 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -18,6 +18,7 @@ pub mod lru; pub mod pool; use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; pub use disk::{DiskCache, decode_jpeg, encode_jpeg}; @@ -26,23 +27,58 @@ use lightcraft_raster::Rgba8; pub use lru::{Lru, next_tick}; pub use pool::JobPool; +/// Images that deferred disk writes ([`PreviewCache::put_deferred`]) may hold waiting for the writer, in bytes. A +/// write that would go past it is skipped (the image stays in memory; a later render writes it), so renders that +/// arrive faster than the disk takes them can't pile up threads and memory (issue #1522). +#[cfg(not(target_arch = "wasm32"))] +const MAX_QUEUED_BYTES: usize = 128 << 20; + +/// A deferred disk write waiting for the cache's writer thread. +#[cfg(not(target_arch = "wasm32"))] +struct Write { + generation: u64, + key: Hash128, + img: Arc<Rgba8>, + cost: usize, +} + /// Rendered-thumbnail cache: memory LRU over an optional disk cache. pub struct PreviewCache { /// Current generation; `None` once [`Self::retire`]d (nothing is read or written any more). generation: RwLock<Option<u64>>, mem: Mutex<Lru<Hash128, Arc<Rgba8>>>, disk: Option<DiskCache>, + /// The writer thread's queue (started with the first deferred write), and the bytes waiting in it. + #[cfg(not(target_arch = "wasm32"))] + writer: Mutex<Option<std::sync::mpsc::Sender<Write>>>, + queued: Arc<AtomicUsize>, } impl PreviewCache { /// Memory-only cache of at most `mem_bytes`. pub fn memory(mem_bytes: usize) -> PreviewCache { - PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: None } + Self::new(mem_bytes, None) + } + + fn new(mem_bytes: usize, disk: Option<DiskCache>) -> PreviewCache { + PreviewCache { + generation: RwLock::new(Some(0)), + mem: Mutex::new(Lru::new(mem_bytes)), + disk, + #[cfg(not(target_arch = "wasm32"))] + writer: Mutex::new(None), + queued: Arc::new(AtomicUsize::new(0)), + } + } + + /// Bytes of images waiting for a deferred disk write. + pub fn queued_bytes(&self) -> usize { + self.queued.load(Ordering::Relaxed) } /// Memory LRU + disk cache in `dir` (at most `disk_bytes`). pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache { - PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) } + Self::new(mem_bytes, Some(DiskCache::new(dir, disk_bytes))) } pub fn disk(&self) -> Option<&DiskCache> { @@ -102,25 +138,56 @@ impl PreviewCache { let c = cost(&img); self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), c); drop(current); - if self.disk.is_none() { - return; + let Some(d) = &self.disk else { return }; + #[cfg(target_arch = "wasm32")] + { + d.put(key, &img); } - let me = self.clone(); - let write = move || { - let current = me.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner); - if *current != Some(generation) { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = d; + if self.queued.load(Ordering::Relaxed).saturating_add(c) > MAX_QUEUED_BYTES { + log::debug!("preview cache: disk writes are behind; this one is skipped (the image stays in memory)"); return; } - if let Some(d) = &me.disk { - d.put(key, &img); + let mut writer = self.writer.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if writer.is_none() { + *writer = self.start_writer(); + } + let Some(tx) = writer.as_ref() else { return }; + self.queued.fetch_add(c, Ordering::Relaxed); + if tx.send(Write { generation, key, img, cost: c }).is_err() { + self.queued.fetch_sub(c, Ordering::Relaxed); + *writer = None; } - }; - #[cfg(not(target_arch = "wasm32"))] - if std::thread::Builder::new().name("lc-preview-write".into()).spawn(write).is_err() { - log::warn!("preview cache: could not start a writer thread"); } - #[cfg(target_arch = "wasm32")] - write(); + } + + /// The thread that makes the deferred disk writes, one after another. It holds the cache weakly, so it ends + /// once the cache is dropped (its queue's sender with it). + #[cfg(not(target_arch = "wasm32"))] + fn start_writer(self: &Arc<Self>) -> Option<std::sync::mpsc::Sender<Write>> { + let (tx, rx) = std::sync::mpsc::channel::<Write>(); + let cache = Arc::downgrade(self); + let queued = self.queued.clone(); + let spawned = std::thread::Builder::new().name("lc-preview-write".into()).spawn(move || { + for job in rx { + if let Some(me) = cache.upgrade() { + let current = me.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner); + if *current == Some(job.generation) + && let Some(d) = &me.disk + { + d.put(job.key, &job.img); + } + } + queued.fetch_sub(job.cost, Ordering::Relaxed); + } + }); + if spawned.is_err() { + log::warn!("preview cache: could not start the writer thread"); + return None; + } + Some(tx) } /// Drop everything (memory and disk). Does nothing once [`Self::retire`]d. diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 6c93e51af..70f400622 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -495,3 +495,26 @@ fn a_panicking_job_frees_its_slot() { })); assert_eq!(got, Some(2)); } + +/// Deferred disk writes go through one writer with a byte budget: a burst of big renders can't queue more than the +/// budget (each used to start a thread of its own, holding its image; issue #1522), and what was queued reaches disk. +#[test] +fn deferred_writes_are_bounded_and_drain_to_disk() { + let dir = temp_dir("deferred-budget"); + let cache = Arc::new(PreviewCache::with_disk(1 << 30, &dir, 1 << 31)); + let big = Arc::new(gradient(4096, 2048, 3)); // 32 MiB each + let keys: Vec<Hash128> = (0..24u32).map(|i| hash_bytes(&i.to_le_bytes())).collect(); + for key in &keys { + cache.put_deferred(*key, big.clone()); + assert!(cache.queued_bytes() <= MAX_QUEUED_BYTES, "{} bytes queued", cache.queued_bytes()); + } + let t = std::time::Instant::now(); + while cache.queued_bytes() > 0 && t.elapsed() < std::time::Duration::from_secs(120) { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(cache.queued_bytes(), 0, "the queue drains"); + assert!(cache.disk().unwrap().get(keys[0]).is_some(), "the first write reached disk"); + // every image is still in memory, written to disk or not + assert!(keys.iter().all(|k| cache.get(*k).is_some())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..21d0e4768 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Reliability +- Thumbnails and previews are written to the cache on disk by one background writer instead of a new thread for + each image (issue #1522). When renders come in faster than the disk takes them, at most 128 MB of images wait; + past that a write is skipped (the image stays in memory and is written by a later render), so a slow disk can't + pile up threads and memory. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From e65b97e23922ff0a9d45d981a1dea3f1b8968740 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 13:08:15 -0500 Subject: [PATCH 199/315] Model downloads: Time out and cancel the name lookup (#1532) The host name lookup blocked with no deadline before the cancel check, and each address then got the full connect timeout. The lookup now runs on a helper thread under the connect deadline, which all addresses share. --- crates/fetch/src/http.rs | 124 ++++++++++++++++++++++++++++++++------- docs/whats-new.md | 5 ++ 2 files changed, 109 insertions(+), 20 deletions(-) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index c2b5bee1e..98eee913b 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -10,7 +10,7 @@ //! the downloading thread. No proxies (see docs/ai-masks.md). use std::io::{ErrorKind, Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -182,27 +182,68 @@ fn tls_config() -> Result<Arc<rustls::ClientConfig>, HttpError> { Ok(Arc::new(config)) } +/// Resolve a host name within `deadline`, checking the cancel flag while it runs. The system resolver blocks with no +/// timeout of its own, so it runs on a helper thread (which ends when the resolver returns); a lookup that takes too +/// long is given up on, and Cancel works during it (issue #1532). +fn lookup( + host: &str, + resolve: impl FnOnce() -> std::io::Result<Vec<SocketAddr>> + Send + 'static, + deadline: Instant, + limits: &Limits, +) -> Result<Vec<SocketAddr>, HttpError> { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("lc-fetch-lookup".into()) + .spawn(move || { + let _ = tx.send(resolve()); + }) + .map_err(|e| HttpError::Connect(format!("{host}: {e}")))?; + loop { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: the name lookup timed out"))); + } + match rx.recv_timeout(left.min(TICK)) { + Ok(r) => return r.map_err(|e| HttpError::Connect(format!("{host}: {e}"))), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Err(HttpError::Connect(format!("{host}: the name lookup failed"))), + } + } +} + +/// Connect to the first of `addrs` that answers, all of them together within `deadline` (each used to get the whole +/// connect timeout of its own, issue #1532). +fn connect_any(host: &str, addrs: &[SocketAddr], deadline: Instant, limits: &Limits) -> Result<TcpStream, HttpError> { + let mut last = format!("{host}: no address"); + for a in addrs { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: connecting timed out"))); + } + match TcpStream::connect_timeout(a, left) { + Ok(s) => return Ok(s), + Err(e) => last = format!("{host}: {e}"), + } + } + Err(HttpError::Connect(last)) +} + impl Stream { fn connect(url: &Url, limits: &Limits) -> Result<Stream, HttpError> { - let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect(); - if addrs.is_empty() { - return Err(HttpError::Connect(format!("{}: no address", url.host))); - } - let mut last = String::new(); - let mut sock = None; - for a in addrs { - if limits.cancel.load(Ordering::Relaxed) { - return Err(HttpError::Cancelled); - } - match TcpStream::connect_timeout(&a, limits.connect) { - Ok(s) => { - sock = Some(s); - break; - } - Err(e) => last = format!("{}: {e}", url.host), - } - } - let sock = sock.ok_or(HttpError::Connect(last))?; + // the lookup and the connection attempts share one connect timeout + let deadline = Instant::now() + limits.connect; + let (host, port) = (url.host.clone(), url.port); + let addrs = lookup(&url.host, move || (host.as_str(), port).to_socket_addrs().map(Iterator::collect), deadline, limits)?; + let sock = connect_any(&url.host, &addrs, deadline, limits)?; sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; let _ = sock.set_nodelay(true); @@ -512,6 +553,49 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res Ok(Response { status, headers, conn, body }) } +#[cfg(test)] +mod tests_connect { + use super::*; + + fn limits(cancel: &AtomicBool) -> Limits<'_> { + Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel } + } + + /// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532). + #[test] + fn a_hung_name_lookup_times_out_and_can_be_cancelled() { + let cancel = AtomicBool::new(false); + let slow = || { + std::thread::sleep(Duration::from_secs(10)); + Ok(Vec::new()) + }; + let t = Instant::now(); + let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel)); + assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}"); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); + cancel.store(true, Ordering::Relaxed); + let t = Instant::now(); + assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled))); + assert!(t.elapsed() < Duration::from_secs(1)); + // a lookup that answers is used + let cancel = AtomicBool::new(false); + let local: SocketAddr = "127.0.0.1:9".parse().unwrap(); + assert_eq!(lookup("local", move || Ok(vec![local]), Instant::now() + Duration::from_secs(5), &limits(&cancel)).unwrap(), [local]); + } + + /// Several addresses that don't answer share one deadline instead of each getting the full timeout. + #[test] + fn addresses_share_one_connect_deadline() { + let cancel = AtomicBool::new(false); + // non-routable addresses: each either hangs until the deadline or fails at once, never longer + let addrs: Vec<SocketAddr> = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect(); + let t = Instant::now(); + let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel)); + assert!(r.is_err()); + assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..3ca956764 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Reliability +- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout, + and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time + limit, and each of a server's addresses then got the whole connect timeout of its own. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 8283ae1687e9c4b2dbf86708ac5d8756d4796772 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Tue, 13 Oct 2026 19:58:05 -0500 Subject: [PATCH 200/315] LR-PREF-GENERAL: Keep the Settings window in place when switching tabs Anchor Settings at its top edge instead of the center and don't let it get narrower, so the tab row stays under the pointer. --- crates/ui-egui/src/headless.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 18 ++++++++++++++++-- docs/whats-new.md | 4 ++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..3af6f50a0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2948,6 +2948,29 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it + /// narrower, though the tabs are of different heights and widths. + #[test] + fn settings_tabs_keep_the_window_in_place() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let window = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r).expect("the dialog"); + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let first = window(&h); + let mut heights = Vec::new(); + for tab in ["display", "import", "performance", "general", "interface", "display"] { + h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + h.settle(SETTLE); + let now = window(&h); + assert!((now.top() - first.top()).abs() < 1.0, "{tab}: top {} → {}", first.top(), now.top()); + assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); + heights.push(now.height()); + } + assert!(heights.iter().any(|h| (h - first.height()).abs() > 20.0), "the tabs differ in height: {heights:?}"); + } + /// The folder scan runs in the background: the request returns at once and the review opens /// when the scan finishes (a folder on a network share must not freeze the window). /// Clicking the folder being read again keeps the running read (and its progress). diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..a76795d58 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -276,7 +276,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // previous frame (a guess on the first one). let chrome_id = window_id.with("chrome"); let chrome = ctx.data(|d| d.get_temp::<f32>(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); - let body_max = (screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome).max(DIALOG_MIN_BODY); + // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab + // row stays where it is (centred like the others, the row jumped with each tab's height) + let settings = matches!(dlg, Dialog::Settings { .. }); + let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); + let body_max = + if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } + .max(DIALOG_MIN_BODY); + let widest_id = window_id.with("widest"); + let widest = if settings { ctx.data(|d| d.get_temp::<f32>(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; + let mut content_w = 0.0f32; let mut body_h = None; let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) @@ -284,7 +293,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .vscroll(import) .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) .frame(frame) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) + .min_width(widest) .default_width(match dlg { Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, @@ -1273,7 +1283,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if import { crate::import::note_dialog_bottom(ui); } + content_w = ui.min_rect().width(); }); + if settings && content_w > widest + 0.5 { + ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); + } if let Some(w) = shown { ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); if let Some(body_h) = body_h { diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..891f72748 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,10 @@ ## 2026-10-14 +### Settings stays put when you change tabs +- The Settings window keeps its top edge and width when you switch tabs, so the tab row no longer jumps around + under the pointer. It used to be centred, so every tab of a different height moved it. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 9b9a96a1b5fe220fc04e4bc14efb49ff9ea540d3 Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Wed, 14 Oct 2026 23:22:36 +0400 Subject: [PATCH 201/315] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/ka.json | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index cfb845f56..3b2456478 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -1828,6 +1828,7 @@ "Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one": "ყველაფრის მონიშვნა და მრავლობითი მონიშვნა ბადესა და კადრების ზოლში ყველა მონიშნულ ფოტოს აჩვენებს და არა მხოლოდ აქტიურს", "Select Luminance Range": "სიკაშკაშის დიაპაზონის არჩევა", "Select Mask": "ნიღბის არჩევა", + "Select Only Active Photo": "მხოლოდ აქტიური ფოტოს მონიშვნა", "Select Photos": "ფოტოების მონიშვნა", "Select Spot": "ლაქის არჩევა", "Select a photo to edit": "აირჩიეთ ფოტო რედაქტირებისთვის", From 69346009d21938c9aa1e04d3cbc547db21275529 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:12:15 -0700 Subject: [PATCH 202/315] Packaging: advertise HEIF images and repair the AppStream screenshot Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- packaging/linux/ai.storyteller.lightcraft.desktop | 2 +- packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packaging/linux/ai.storyteller.lightcraft.desktop b/packaging/linux/ai.storyteller.lightcraft.desktop index f827c5820..044c9ca63 100644 --- a/packaging/linux/ai.storyteller.lightcraft.desktop +++ b/packaging/linux/ai.storyteller.lightcraft.desktop @@ -9,4 +9,4 @@ Terminal=false Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;raw;camera;library;develop; StartupWMClass=ai.storyteller.lightcraft -MimeType=image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef; +MimeType=image/heic;image/heif;image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef; diff --git a/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in index 8964e76e9..119aaae9b 100644 --- a/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in @@ -35,8 +35,8 @@ </keywords> <screenshots> <screenshot type="default"> - <caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption> - <image>https://raw.githubusercontent.com/storytold/lightcraft/main/docs/images/lightcraft-demo.jpg</image> + <caption>Photo editing with The Tetons and the Snake River by Ansel Adams</caption> + <image type="source">https://raw.githubusercontent.com/storytold/lightcraft/3b4279fc2485bb51847c0071a1b3c514aa0f98f4/docs/images/hero-tetons.jpg</image> </screenshot> </screenshots> <branding> From b0241748eebf392817225ba25dac43e7f8008e27 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:47:31 +0300 Subject: [PATCH 203/315] cli: refuse non-object snapshot params instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script line for `ui.screenshot` without a `path` had its generated path assigned with `params["path"] = …`: `serde_json`'s mutable index panics on an array, a string or a number, and the CLI installs no `catch_unwind`, so a script handed to the documented headless workflow aborted the process. Only an object (or null, like an absent `params`) takes the path now; anything else is a script error naming the line, like the existing handling of a malformed line. The regression test runs the real binary on such a script and asserts a non-zero exit, no "panicked" in stderr and the `params` must be a JSON object message (it fails before the fix with `cannot access key "path" in JSON array`). --- apps/lightcraft-cli/src/main.rs | 12 +++++++++++- apps/lightcraft-cli/tests/cli.rs | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..572a4e955 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -832,7 +832,17 @@ fn snapshot(args: &[String]) -> Result<(), String> { match next_path(&mut shots) { Some(p) => { wrote_output = true; - params["path"] = json!(p); + // `params` is untrusted script JSON: `params["path"] = …` panics on + // an array or a string. Only an object (or null, like an absent + // `params`) may take the path. + if params.is_null() { + params = json!({}); + } + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(p)); + } else { + return Err(format!("{script}:{}: ui.screenshot: `params` must be a JSON object", n + 1)); + } } None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)), } diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index bf56b02ab..5948d8b07 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -273,6 +273,24 @@ fn snapshot_script_failure_exits_non_zero() { assert_eq!((d.width, d.height), (320, 200)); } +/// A script line whose `params` is not an object must be an error, never a panic: the +/// `ui.screenshot`-without-a-path branch used to index (`params["path"] = …`) an array and abort +/// the whole script. +#[test] +fn snapshot_script_with_non_object_params_is_an_error() { + let script = tmp("snap-bad-params.jsonl"); + let out = tmp("snap-bad-params.png"); + std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap(); + let o = Command::new(BIN) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "{stderr}"); + assert!(!stderr.contains("panicked"), "a bad `params` must be an error, not a panic: {stderr}"); + assert!(stderr.contains("`params` must be a JSON object"), "{stderr}"); +} + #[test] fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() { let script = tmp("snap-ui-zoom.jsonl"); From 3bb13afdead2ceb4f97850726e1532e1f03353fb Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:50:06 +0300 Subject: [PATCH 204/315] raw: check maker-note crop bounds with checked_add A PEF or ORF maker-note crop with an origin of `u64::MAX` and a width of 1 made `x + width` wrap to 0, so the guard read it as inside the image and `RawImage::check`'s identical comparison also wrapped: `validate()` passed and `normalized()` then indexed past the sample buffer. Debug builds panicked at the guard itself ("attempt to add with overflow"). The active-area check and both vendor crop guards (PEF AOC, ORF ImageProcessing) now compare with `checked_add`, so a wrapping rectangle is a `Corrupt` error at validate time. Exact fits still pass. Regression test `an_active_area_that_wraps_past_the_image_is_corrupt` builds such an image and asserts `validate()` and `normalized()` return `Err` (fails before the fix with the overflow panic above). --- crates/raw/src/lib.rs | 36 +++++++++++++++++++++++++++++++++++- crates/raw/src/vendor/orf.rs | 7 ++++++- crates/raw/src/vendor/pef.rs | 7 ++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0f366648c..399a8346b 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -728,7 +728,10 @@ impl RawImage { return Err(RawError::Unsupported(format!("CFA pattern {}", c.name()))); } let a = self.active_area; - if a.width == 0 || a.height == 0 || a.x + a.width > self.width || a.y + a.height > self.height { + // checked: a crop origin from a maker note can be `u64::MAX`, and `x + width` would wrap + // back inside the image here and again in `normalized`'s index arithmetic. + let fits = |x: usize, len: usize, limit: usize| x.checked_add(len).is_some_and(|end| end <= limit); + if a.width == 0 || a.height == 0 || !fits(a.x, a.width, self.width) || !fits(a.y, a.height, self.height) { return Err(RawError::Corrupt("active area outside image".into())); } Ok(()) @@ -878,6 +881,37 @@ mod tests { assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); } + /// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to + /// pass the bounds check (`x + width` wraps to 0) and then index out of bounds in + /// `normalized`. It must be a `Corrupt` error at validate time instead. + #[test] + fn an_active_area_that_wraps_past_the_image_is_corrupt() { + let (w, h) = (12usize, 10usize); + let mut raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(vec![100; w * h]), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(usize::MAX, 0, 1, 1), + crop: Rect::default(), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + }; + assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); + assert!(raw.normalized().is_err()); + raw.active_area = Rect::new(0, 0, w + 1, h); + assert!(raw.validate().is_err(), "one past the last column is outside too"); + } + #[test] fn black_levels() { let b = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![1.0, 2.0, 3.0, 4.0], delta_h: vec![0.5, 0.0], delta_v: vec![0.0, 10.0] }; diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..31dd2d21b 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -285,7 +285,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { let mn = maker_note(bytes, &tiff); let ip = mn.as_ref().and_then(|m| sub_ifd(bytes, m, IMAGE_PROCESSING)); let active = match ip.as_ref().map(|i| CROP.map(|tag| i.u64(tag).map(|v| v as usize))) { - Some([Some(x), Some(y), Some(cw), Some(ch)]) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= h => Rect::new(x, y, cw, ch), + // checked: the tags are file data; `x + cw` on a hostile value would wrap past the bounds + Some([Some(x), Some(y), Some(cw), Some(ch)]) + if cw > 0 && ch > 0 && x.checked_add(cw).is_some_and(|end| end <= w) && y.checked_add(ch).is_some_and(|end| end <= h) => + { + Rect::new(x, y, cw, ch) + } _ => Rect::new(0, 0, w, h), }; let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active)); diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 86db33540..1a7414151 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -243,7 +243,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); let tagged = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { - (Some((x, y)), Some((cw, ch))) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= hgt => Some(Rect::new(x, y, cw, ch)), + // checked: the tags are file data; `x + cw` on a hostile value would wrap past the bounds + (Some((x, y)), Some((cw, ch))) + if cw > 0 && ch > 0 && x.checked_add(cw).is_some_and(|end| end <= w) && y.checked_add(ch).is_some_and(|end| end <= hgt) => + { + Some(Rect::new(x, y, cw, ch)) + } _ => None, }; // without crop tags the image area is found from the samples (dark borders) From ea826b11cc5bec456c2b71e6bc6ea59a4f91af45 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:53:04 +0300 Subject: [PATCH 205/315] engine: refuse negative indices instead of targeting element 0 `version.*`, `pointColor.delete` and `redeye.catchlight`/`redeye.delete` parsed `index` with `f64_req(..)? as usize`. Rust's float-to-int cast saturates, so `index: -1` became 0 and the bounds checks never saw a negative: a caller sending `index: -1` silently deleted, restored or renamed version 0 (or the first point-colour sample / red-eye correction) and got `ok` back. `history.restore` already guards this and `spot_index` uses `as_u64`. New `index_req` helper in `cmd/mod.rs` (the same finite, non-negative guard) is used by the five sites. Behaviour for valid indices is unchanged. Regression test `negative_indices_are_refused` creates a version, then asserts `version.delete/restore/rename` with `index: -1` error and version 0 is untouched (before the fix the first one deletes it). --- crates/engine/src/cmd/color.rs | 2 +- crates/engine/src/cmd/develop.rs | 8 ++++---- crates/engine/src/cmd/masks.rs | 4 ++-- crates/engine/src/cmd/mod.rs | 9 +++++++++ crates/engine/src/tests_history_cursor.rs | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/color.rs b/crates/engine/src/cmd/color.rs index 55ea8b5ab..111159e7a 100644 --- a/crates/engine/src/cmd/color.rs +++ b/crates/engine/src/cmd/color.rs @@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result<Value> { } fn index(p: &Value, c: &str) -> Result<usize> { - Ok(f64_req(p, "index", c)? as usize) + super::index_req(p, "index", c) } pub fn specs() -> Vec<CommandSpec> { diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260..231316395 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param}; use crate::{Result, Session, media::SourceLevel}; fn active(s: &Session, c: &str) -> Result<PhotoId> { @@ -144,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre /// Change version `p.index` of the active photo (`f` gets it and the current settings). fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> { let id = active(s, c)?; - let i = f64_req(p, "index", c)? as usize; + let i = index_req(p, "index", c)?; let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; let mut versions = ph.versions.clone(); let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?; @@ -1060,14 +1060,14 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| { let id = active(s, "version.restore")?; - let i = f64_req(p, "index", "version.restore")? as usize; + let i = index_req(p, "index", "version.restore")?; let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?; s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?; ok() }), cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| { let id = active(s, "version.delete")?; - let i = f64_req(p, "index", "version.delete")? as usize; + let i = index_req(p, "index", "version.delete")?; let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default(); if i >= versions.len() { return Err(bad("version.delete", "no such version")); diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 25ff3f24d..8d5a6751e 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -921,7 +921,7 @@ pub fn specs() -> Vec<CommandSpec> { has_active, |s, p| { let c = "redeye.catchlight"; - let i = super::f64_req(p, "index", c)? as usize; + let i = super::index_req(p, "index", c)?; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?; @@ -936,7 +936,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| { let c = "redeye.delete"; - let i = super::f64_req(p, "index", c)? as usize; + let i = super::index_req(p, "index", c)?; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); if i >= dd.red_eye.len() { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..6092c5345 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -187,6 +187,15 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError { pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } +/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize` +/// saturates to 0), just as `history.restore` already refuses a negative step index. +pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> { + let i = f64_req(p, key, cmd)?; + if !(i.is_finite() && i >= 0.0) { + return Err(bad(cmd, format!("`{key}` must be a non-negative integer"))); + } + Ok(i as usize) +} pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 { p.get(key).and_then(Value::as_f64).unwrap_or(d) } diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs index 55c265403..ffe300795 100644 --- a/crates/engine/src/tests_history_cursor.rs +++ b/crates/engine/src/tests_history_cursor.rs @@ -98,3 +98,20 @@ fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() { let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap(); assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1))); } + +/// A negative `index` must be an error on every indexed command, not element 0 (`-1.0 as usize` +/// saturates to 0): `history.restore` guards it, `version.*` (and the other `index` commands) +/// must too. A caller sending `index: -1` used to delete or restore version 0 silently. +#[test] +fn negative_indices_are_refused() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let versions = |s: &Session| s.catalog.photo(id).unwrap().versions.len(); + s.execute("version.create", &json!({"name": "A"})).unwrap(); + assert_eq!(versions(&s), 1); + assert!(s.execute("version.delete", &json!({"index": -1})).is_err(), "must not delete version 0"); + assert_eq!(versions(&s), 1, "nothing was deleted"); + assert!(s.execute("version.restore", &json!({"index": -1})).is_err()); + assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err()); + assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed"); +} From 66a202bbcfa1d349518f7c45feaacff0df2a84fa Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:10 +0300 Subject: [PATCH 206/315] codecs: decode lossless (SOF3) JPEGs correctly `header` accepts a lossless frame but the decoder routing only sent CMYK/cropped/12-bit/non-interleaved files to jpeg-decoder: an 8-bit SOF3 file went to zune-jpeg, which refuses lossless, so a listed file could not open at all. The files that did reach jpeg-decoder were wrong too: jpeg-decoder emits its lossless samples native-endian (its `to_ne_bytes`), so `from_be_bytes` swapped every sample (a 12-bit 2048 decoded as 8), the raw `precision`-bit value was never left-justified into the 16-bit range `Buf::U16` feeds onward, and a 3- or 4-component frame over 8 bits still reports `RGB24`/`CMYK32` while holding two bytes per sample, so it was wrapped as 8-bit data over double-length rows. `scale()` (a DCT size, not a lossless resample) also cut a lossless frame to the top-left when a thumbnail box was requested. Every SOF3 file now routes to jpeg-decoder; lossless frames skip `scale` (`finish` scales after); lossless samples are read native-endian and left-justified; high-precision RGB/CMYK become `Buf::U16`. Four hand-built SOF3 regression tests (12-bit gray, 12-bit RGB, 12-bit CMYK, and 8-bit through the public `decode`); all four fail before the fix (byte-swapped samples, U8-wrapped high-precision buffers, and the zune refusal) and pass after. The crate's other tests stay green. --- crates/codecs/src/jpeg.rs | 117 +++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 7 deletions(-) diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index e40cf7e73..90a1b0eed 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -182,9 +182,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: check_size(F, m.width as u64, m.height as u64, opts)?; let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2))); // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, - // 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling). + // 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling) + // and every lossless (SOF3) file, which zune-jpeg refuses outright. + let lossless = m.sof == 0xC3; let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; - let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved { + let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless { decode_jpeg_decoder(bytes, &m, scale_to)? } else { decode_zune(bytes, &m)? @@ -221,7 +223,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) use jpeg_decoder::PixelFormat; let mut d = jpeg_decoder::Decoder::new(bytes); d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?; - if let Some((mw, mh)) = scale_to { + // `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the + // output to the top-left of each plane. Those decode at full size; `finish` scales after. + if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) { // Ask for the smallest DCT scale still covering the target box. let (sw, sh) = scaled_request(m.width, m.height, mw, mh); d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?; @@ -231,16 +235,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) let (w, h) = (info.width as usize, info.height as usize); let (model, buf, depth) = match info.pixel_format { PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8), - PixelFormat::L16 => { - let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (Model::Gray, Buf::U16(v), 16) - } + PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16), + // A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its + // buffer holds two native-endian bytes per sample like L16. + PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8), + PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8), }; Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) } +/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw +/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads, +/// as every 16-bit decoder feeding [`Buf::U16`] does. +fn lossless_samples(px: &[u8], precision: u8) -> Vec<u16> { + let shift = 16u8.saturating_sub(precision); + px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect() +} + /// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this /// is most of [`header`]'s time). fn find_ff(b: &[u8]) -> Option<usize> { @@ -450,4 +463,94 @@ mod tests { let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap(); assert!(m.exif.is_none()); } + + // --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so + // every pixel is a zero difference and decodes to the predictor's initial sample. No such + // encoder exists here; the bytes are the smallest a decoder accepts. + + fn lossless_header(out: &mut Vec<u8>, components: u8, w: u16, precision: u8) { + // DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol. + out.extend_from_slice(&[0xFF, 0xC4]); + out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes()); + out.push(0x00); + let mut counts = [0u8; 16]; + counts[0] = 1; + out.extend_from_slice(&counts); + out.push(0x00); + // SOF3 + out.extend_from_slice(&[0xFF, 0xC3]); + out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes()); + out.push(precision); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&w.to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0x11, 0]); + } + // SOS: predictor Ra, Se = 0, Ah/Al = 0 + out.extend_from_slice(&[0xFF, 0xDA]); + out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0]); + } + out.extend_from_slice(&[1, 0, 0]); + } + + /// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial + /// prediction `1 << (precision - 1)`. + fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec<u8> { + let mut out = vec![0xFF, 0xD8]; + lossless_header(&mut out, components, w, precision); + // entropy: one zero bit per sample (a category-0 code), padded to whole bytes + out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]); + out.extend_from_slice(&[0xFF, 0xD9]); + out + } + + /// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw + /// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the + /// raw value was never left-justified into the 16-bit buffer. + #[test] + fn lossless_grayscale_samples_are_native_endian_and_left_justified() { + let bytes = lossless_jpeg(1, 8, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified"); + assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16)); + } + + /// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per + /// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows. + #[test] + fn lossless_high_precision_rgb_is_16_bit() { + let bytes = lossless_jpeg(3, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 6]); + assert_eq!(raw.model, Model::Rgb); + } + + /// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample. + #[test] + fn lossless_high_precision_cmyk_is_16_bit() { + let bytes = lossless_jpeg(4, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8]); + assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16)); + } + + /// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to + /// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable. + #[test] + fn eight_bit_lossless_jpeg_decodes() { + let bytes = lossless_jpeg(1, 8, 8); + let d = crate::decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (8, 1)); + assert_eq!(d.bit_depth, 8); + } } From 6d493c9c730449d9b048bfa934e9678420387247 Mon Sep 17 00:00:00 2001 From: FredApps <FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:23:01 +0200 Subject: [PATCH 207/315] Open the active grid photo with Enter --- crates/ui-egui/src/shortcuts.rs | 179 ++++++++++++++++++++++++++++++++ docs/library-shortcuts.md | 3 + 2 files changed, 182 insertions(+) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b10387c76..dcc6c9427 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -357,6 +357,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // Esc that just took a text field's focus away (it gives back the field's text) does nothing else let popup_open = popup_open || app.text_focus; ctx.input(|i| { + // Enter opens the active grid photo; widgets, popups and custom bindings keep the key. + if grid + && app.session.active().is_some() + && !popup_open + && !native("Enter") + && !taken.contains(&(Modifiers::NONE, Key::Enter)) + && matches(i, Modifiers::NONE, Key::Enter) + { + fire.push("view.detail".to_string()); + } for b in bindable() { if let Some(sc) = binding(keymap, b.id, b.default) && let Some((m, k)) = parse(sc) @@ -1009,6 +1019,175 @@ mod tests { } } } + mod issue1771 { + use super::*; + use crate::state::{Dialog, ViewMode}; + use lightcraft_catalog::PhotoId; + + fn grid(view: ViewMode) -> crate::headless::Headless { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); + for _ in 0..3 { + h.step(); + } + h + } + + #[test] + fn issue1771_photo_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::PhotoGrid); + } + + #[test] + fn issue1771_square_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::SquareGrid); + } + + fn opens(view: ViewMode) { + let mut h = grid(view); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "Enter from {view:?}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "second Enter remains in Detail"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + } + + #[test] + fn issue1771_no_selection_and_modifier_chords_do_not_open() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + for modifiers in [json!({"shift":true}), json!({"cmd":true}), json!({"alt":true}), json!({"ctrl":true})] { + let mut args = modifiers; + args["key"] = json!("Enter"); + assert_eq!(h.request("ui.key", args, std::time::Duration::from_secs(20))["ok"], true); + assert_eq!(h.app.ui.view, view); + } + h.app.session.selection = Default::default(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert!(h.app.session.selection.ids.is_empty()); + assert_eq!(h.app.session.active(), None); + } + } + + #[test] + fn issue1771_search_dialog_and_shortcut_recording_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"field:search"}), std::time::Duration::from_secs(20))["ok"], true); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "focused search"); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "shortcut dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + h.app.recording_shortcut = Some("view.survey".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "recording owns Enter"); + assert_eq!(super::super::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Enter")); + } + } + + #[test] + fn issue1771_custom_binding_and_native_menu_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + h.app.run("app.setShortcut", json!({"id":"view.survey", "shortcut":"Enter"})).unwrap(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Survey, "custom binding wins"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + let mut h = grid(view); + h.app.native_shortcuts.insert("Enter".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "native menu owns Enter"); + } + } + + #[test] + fn issue1771_popup_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| { + let ctx = ui.ctx(); + egui::Popup::open_id(ctx, egui::Id::new("issue1771-menu")); + assert!(egui::Popup::is_any_open(ctx)); + super::super::handle(&mut h.app, ctx); + }, + ); + output.textures_delta.clear(); + assert_eq!(h.app.ui.view, view); + } + } + + #[test] + fn issue1771_detail_enter_keeps_active_and_multiselection() { + let mut h = grid(ViewMode::Detail); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + } + + #[test] + fn issue1771_open_file_menu_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"menu:File"}), std::time::Duration::from_secs(20))["ok"], true); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx), "File menu open before Enter"); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + } + } + + #[test] + fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { + let mut h = grid(ViewMode::PhotoGrid); + h.events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + h.events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.selection.ids.len(), 3); + } + } } #[cfg(test)] diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index ca3100fc8..e6b9af6ba 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -4,6 +4,7 @@ Select photos in Photo Grid or Square Grid, then use: | Key | Action | |---|---| +| `Enter` | Open the active photo in Detail from Photo Grid or Square Grid | | `Cmd+Shift+D` (macOS) / `Ctrl+Shift+D` (Windows/Linux) | Select only the active photo | | `0` | Clear the star rating | | `1`–`5` | Set the star rating | @@ -14,6 +15,8 @@ Select photos in Photo Grid or Square Grid, then use: | `U` | Clear the flag | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | +Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. + Select Only Active Photo (Edit menu) narrows the selection to the active photo. It does not change the photo, its edits or the last edit Undo step, and it works in any view that has an active photo. It can be reassigned or disabled in Help → Keyboard Shortcuts; text fields and dialogs keep the key. From fb2a46053257844cd6ece0717b94d25d9af3afeb Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze <david.machakhelidze@gmail.com> Date: Thu, 15 Oct 2026 00:38:48 +0400 Subject: [PATCH 208/315] i18n(ka): bring the Georgian catalog up to date Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/ka.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 3b2456478..bafd7827c 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -1692,24 +1692,24 @@ "Remove this group": "ამ ჯგუფის წაშლა", "Remove this rule": "ამ წესის წაშლა", "Removed": "ამოშლილია", - "Rename": "გადარქმევა", - "Rename Album": "ალბომის გადარქმევა", - "Rename Component": "კომპონენტის გადარქმევა", - "Rename Folder": "საქაღალდის გადარქმევა", - "Rename Folder…": "საქაღალდის გადარქმევა…", + "Rename": "სახელის შეცვლა", + "Rename Album": "ალბომის სახელის შეცვლა", + "Rename Component": "კომპონენტის სახელის შეცვლა", + "Rename Folder": "საქაღალდის სახელის შეცვლა", + "Rename Folder…": "საქაღალდის სახელის შეცვლა…", "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "საქაღალდის გადარქმევა… და საქაღალდის გადატანა… (ლოკალური) შეიძლება დაბრუნდეს და ხელახლა შესრულდეს (ხარვეზი #97): საქაღალდე დისკზე ბრუნდება", - "Rename Keyword": "საკვანძო სიტყვის გადარქმევა", - "Rename Keyword…": "საკვანძო სიტყვის გადარქმევა…", - "Rename Mask": "ნიღბის გადარქმევა", - "Rename Photos": "ფოტოების გადარქმევა", + "Rename Keyword": "საკვანძო სიტყვის სახელის შეცვლა", + "Rename Keyword…": "საკვანძო სიტყვის სახელის შეცვლა…", + "Rename Mask": "ნიღბის სახელის შეცვლა", + "Rename Photos": "ფოტოების სახელის შეცვლა", "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "ფოტოების გადარქმევა აღარასოდეს გადააწერს სხვა ფოტოს, როცა მხოლოდ ასოების რეგისტრი იცვლება (ხარვეზი #95): რეგისტრის მიმართ მგრძნობიარე", "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "ფოტოების გადარქმევა აცნობებს ფაილებს, რომელთა უკან დაბრუნებაც შეცდომის შემდეგ ვერ მოხერხდა (ხარვეზი #105), მაგ. როცა ქსელური საქაღალდე ითიშება", - "Rename Photos…": "ფოტოების გადარქმევა…", - "Rename Preset": "პრესეტის გადარქმევა", - "Rename Preview": "გადარქმევის გადახედვა", - "Rename Template Tags": "გადარქმევის შაბლონის ტეგები", - "Rename Version": "ვერსიის გადარქმევა", - "Rename…": "გადარქმევა…", + "Rename Photos…": "ფოტოების სახელის შეცვლა…", + "Rename Preset": "პრესეტის სახელის შეცვლა", + "Rename Preview": "სახელის შეცვლის გადახედვა", + "Rename Template Tags": "სახელის შეცვლის შაბლონის ტეგები", + "Rename Version": "ვერსიის სახელის შეცვლა", + "Rename…": "სახელის შეცვლა…", "Rendering": "რენდერირება", "Rendering…": "რენდერდება…", "Reorder Album": "ალბომის გადალაგება", From 0366116c76cfdfc00710b5a0a3b00398ee0ca172 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:05:03 +0300 Subject: [PATCH 209/315] ui: render the Show All Masks overlay in zoom windows The loupe's window rendering already evaluates every visible mask's alpha for an `Overlay::AllMasks` (its union is composed from the same alphas the selected-mask overlay uses), but `overlay_supports_window` refused it, so at 1:1 and closer "Show All Masks" fell back to a whole-frame render at the zoomed size: slower, and its detail stopped at the frame render's resolution. Allowing it makes the zoomed view a canvas-sized frame plus a native window, as a single mask's overlay already is. Tests: - tests_preview_limit::an_all_masks_overlay_at_one_to_one_is_a_window (fails before: "native 6000: the whole-frame render is 6000") - tests_window::an_all_masks_overlay_window_matches_the_whole_render (a window of the union matches the whole render's pixels, for every view) --- crates/pipeline/src/tests_window.rs | 32 +++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 8 ++++-- crates/ui-egui/src/tests_preview_limit.rs | 34 +++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 1d5b15c65..b0fc3de8f 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -238,6 +238,38 @@ fn a_mask_overlay_window_matches_the_whole_render() { } } +// Given the Show All Masks view (issue #1712), a window of it matches the same pixels of the +// whole render with that overlay: the union is composed from the same mask alphas, so the zoomed +// loupe can show it as a window like a single mask's overlay (issue #547) +#[test] +fn an_all_masks_overlay_window_matches_the_whole_render() { + use crate::{MaskView, Overlay}; + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shapes = [ + (3, MaskShape::Radial { center: Point::new(0.45, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0), + (4, MaskShape::Linear { start: Point::new(0.4, 0.55), end: Point::new(0.6, 0.5) }, 100.0), + ]; + let mut s = DevelopSettings::default(); + for (id, shape, refine) in shapes { + s.masks.push(Mask { + id, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, + refine, + ..Default::default() + }); + } + for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] { + let overlay = Overlay::AllMasks { view, color: [255, 0, 0], opacity: 70 }; + let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image; + let w = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, MID); + // (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`) + assert!(max <= 3 && mean <= 0.25, "{view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)"); + } +} + // ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- /// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..d35147628 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -250,10 +250,12 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { } /// Whether an overlay can be rendered on a region tile without changing what the tile means. -/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic -/// overlays either need whole-frame context or deliberately replace the loupe. +/// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask +/// alphas as the full render; the other diagnostic overlays either need whole-frame context or +/// deliberately replace the loupe. pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { - matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. }) + use lightcraft_pipeline::Overlay; + matches!(overlay, Overlay::None | Overlay::Mask { .. } | Overlay::AllMasks { .. }) } /// What a window render of the loupe needs to know about the view it belongs to. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 4f058f536..76d3ea29c 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -227,6 +227,40 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // Given the Show All Masks view (issue #1712) at 1:1, the zoomed loupe is a canvas-sized frame + // plus a native window carrying the overlay, as a selected mask's is: the union is evaluated + // from the same mask alphas, so it must not fall back to a whole-frame render at the zoomed + // size (slower, and its detail stops at the frame render's resolution) + #[test] + fn an_all_masks_overlay_at_one_to_one_is_a_window() { + let (mut h, native) = detail(); + for (command, params) in [ + ("panel.masking", json!({})), + ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2})), + ("mask.add", json!({"kind": "linear", "start": [0.2, 0.3], "end": [0.8, 0.7]})), + ] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + } + let r = h.request("engine.execute", json!({"command": "view.maskOverlayAll", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.mask_overlay_all, "the all-masks overlay is on"); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.develop_of(id).expect("settings"); + assert!( + matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }), + "the all-masks overlay is shown" + ); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let region = h.app.region_view.expect("a window render with the all-masks overlay"); + assert_eq!(region.full.0.max(region.full.1), native, "cut from the photo's own pixels"); + let (w, hh) = region_tile(&h).expect("its texture"); + assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { From 053b7420fe6348c7199b3036fd228d3c8850a6a9 Mon Sep 17 00:00:00 2001 From: Guillem Hermida <guillemhr2@gmail.com> Date: Wed, 14 Oct 2026 23:09:11 +0200 Subject: [PATCH 210/315] LR-CROP-STRAIGHTEN: turning a crop shrinks it about its centre instead of swapping it for the largest crop (#1794) CropGeometry::with_angle replaced a crop that no longer fit at the new angle with the largest centred crop of its aspect. For a crop dragged in from a corner (which still touches two edges) that happened at the first fraction of a degree, so the crop jumped back to nearly the whole photo. A crop that no longer fits now shrinks about its own centre, keeping its aspect, to the largest size that fits there. The full photo, or a crop an earlier turn shrank to the largest of its aspect, still follows the largest crop and grows back towards 0 degrees (#1388). A crop whose centre is off the turned photo is centred at its own size at most. No crop the user made ever grows. --- crates/engine/src/cmd/develop.rs | 4 +- crates/engine/src/tests_crop_angle.rs | 27 ++++++++ crates/geom/src/crop.rs | 95 ++++++++++++++++++++++++--- docs/parity-checklist.md | 2 +- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260..1f3aa59ba 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -588,14 +588,14 @@ pub fn specs() -> Vec<CommandSpec> { "Straighten", [], None, - "{angle: degrees -45..45} — keeps the largest crop of the current aspect", + "{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)", has_active, |s, p| { let id = active(s, "crop.straighten")?; let (w, h) = crop_dims(s, id); let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0); edit(s, "crop.straighten", "Straighten", |d| { - // keep the current crop if it still fits, else the largest of its aspect that does + // keep the current crop if it still fits, else shrink it about its centre until it does d.crop.geometry = d.crop.geometry.with_angle(angle, w, h); Ok(()) }) diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests_crop_angle.rs index 90a12b2d0..bac941239 100644 --- a/crates/engine/src/tests_crop_angle.rs +++ b/crates/engine/src/tests_crop_angle.rs @@ -107,6 +107,33 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() { assert!(close(geometry(&s), start)); } +#[test] +fn turning_a_crop_dragged_in_from_a_corner_keeps_that_crop() { + // issue #1794: lock the aspect, drag a corner in, then rotate: the crop went back to the photo's + // size, because the dragged crop still touches two edges and stops fitting at once + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + s.execute("crop.aspect", &json!({"aspect": "original"})).unwrap(); + s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [1.0, 1.0], "to": [0.6, 0.6]})).unwrap(); + let made = geometry(&s).rect_px(w, h); + assert!(made.width() < 0.7 * w, "the drag cropped: {made:?}"); + s.execute("develop.beginInteraction", &json!({"label": "Crop"})).unwrap(); + for angle in [0.25, 1.0, 2.5, 5.0, -3.0] { + s.execute("crop.straighten", &json!({"angle": angle})).unwrap(); + let g = geometry(&s); + let r = g.rect_px(w, h); + assert!(g.is_within_image(w, h), "{angle}°: inside the photo"); + assert!(r.width() <= made.width() + 1e-6, "{angle}°: no larger than the crop made, got {r:?} from {made:?}"); + assert!((r.aspect() - made.aspect()).abs() < 1e-6, "{angle}°: the locked aspect holds"); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + // the first small turn only trims it + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("crop.straighten", &json!({"angle": 0.25})).unwrap(); + let r = geometry(&s).rect_px(w, h); + assert!(r.width() > 0.97 * made.width(), "0.25° keeps nearly all of it: {r:?} from {made:?}"); +} + #[test] fn a_crop_shrunk_by_straightening_grows_back_towards_0() { // issue #1388: slide the Straighten slider out and back, in one drag or in several diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index 857ea19b9..df145322a 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; -use crate::{Affine, Point, Rect, Vec2}; +use crate::{Affine, MIN_CROP_FRACTION, Point, Rect, Vec2}; #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct CropGeometry { @@ -81,12 +81,14 @@ impl CropGeometry { } /// This crop turned to `angle_deg` on a `w × h` image: kept as it is when it still fits inside - /// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's - /// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle, - /// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so - /// they all give the same crop (issue #742). A crop that is the largest of its aspect at its - /// own angle (the full photo, or one an earlier turn shrank to fit) stays the largest at the new - /// angle, so it grows back as the angle returns towards 0° (issue #1388). + /// the rotated image, else shrunk about its own centre, keeping its aspect, until it fits + /// (Lightroom's "Constrain to image"). Every way of setting the angle goes through here (the + /// rotate handle, the Straighten slider and Angle field, `crop.straighten`, `develop.set` on + /// `crop.angle`), so they all give the same crop (issue #742). A crop that is the largest of its + /// aspect at its own angle (the full photo, or one an earlier turn shrank to fit) stays the + /// largest at the new angle, so it grows back as the angle returns towards 0° (issue #1388). + /// Any other crop never grows: a crop dragged in from a corner still touches two edges of the + /// photo, and turning it used to swap it for the largest crop of its aspect (issue #1794). pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry { let r = self.rect_px(w, h); // a degenerate rect has no aspect to keep: fall back to the image's own @@ -99,7 +101,45 @@ impl CropGeometry { if !is_largest && cand.is_within_image(w, h) { return cand; } - crop_fit_angle(w, h, angle_deg, aspect) + let fitted = crop_fit_angle(w, h, angle_deg, aspect); + if is_largest || aspect.is_none() { + return fitted; + } + // shrunk in place, as long as that leaves a crop the drag model would allow + let min_side = MIN_CROP_FRACTION * w.min(h); + if let Some(s) = cand.fit_scale_about_centre(w, h).filter(|s| r.width().min(r.height()) * s >= min_side) { + return CropGeometry { rect: Rect::from_center(r.center(), r.width() * s, r.height() * s).scale(1.0 / w, 1.0 / h), angle: angle_deg }; + } + // its centre is off the turned photo (a sliver in a corner): centred, at its own size at most + let f = fitted.rect_px(w, h); + let Some(s) = Some(r.width() / f.width()).filter(|s| s.is_finite() && *s < 1.0) else { + return fitted; + }; + CropGeometry { rect: Rect::from_center(f.center(), f.width() * s, f.height() * s).scale(1.0 / w, 1.0 / h), angle: angle_deg } + } + + /// The largest scale, at most 1, at which this crop shrunk about its own centre lies inside the + /// rotated image; `None` when its centre is outside it. + fn fit_scale_about_centre(&self, w: f64, h: f64) -> Option<f64> { + let r = self.rect_px(w, h); + let back = Affine::rotate_about(-self.angle.to_radians(), Point::new(w / 2.0, h / 2.0)); + let c = back.apply(r.center()); + if !(0.0..=w).contains(&c.x) || !(0.0..=h).contains(&c.y) { + return None; + } + // each corner, in the image frame, is c + s·d: keep both coordinates in 0..w and 0..h + let mut s: f64 = 1.0; + for corner in r.corners() { + let d = back.apply(corner) - c; + for (q, dq, max) in [(c.x, d.x, w), (c.y, d.y, h)] { + if dq > 0.0 { + s = s.min((max - q) / dq); + } else if dq < 0.0 { + s = s.min(q / -dq); + } + } + } + Some(s).filter(|s| s.is_finite() && *s > 0.0) } } @@ -195,6 +235,45 @@ mod tests { } } + #[test] + fn with_angle_shrinks_a_crop_against_the_edge_about_its_own_centre() { + // issue #1794: a crop dragged in from one corner keeps the opposite corner on the photo's, + // so it stops fitting at the first fraction of a degree; it used to become the largest crop + // of its aspect, nearly the whole photo + let (w, h) = (600.0, 400.0); + let c = CropGeometry { rect: Rect::new(0.0, 0.0, 0.6, 0.6), angle: 0.0 }; + let a = c.rect_px(w, h); + for ang in [-10.0, -1.0, 0.5, 3.0, 20.0] { + let t = c.with_angle(ang, w, h); + let b = t.rect_px(w, h); + assert!(t.is_within_image(w, h), "{ang}°"); + assert_eq!(t.angle, ang); + assert!(b.width() <= a.width() + 1e-9, "{ang}°: never larger than the crop made, {} vs {}", b.width(), a.width()); + assert!((a.aspect() - b.aspect()).abs() < 1e-9, "{ang}°: aspect kept"); + assert!(b.center().dist(a.center()) < 1e-9, "{ang}°: shrunk about its own centre"); + // as large as fits there: 1 % bigger about the same centre does not + let big = CropGeometry { rect: Rect::from_center(t.rect.center(), t.rect.width() * 1.01, t.rect.height() * 1.01), angle: ang }; + assert!(!big.is_within_image(w, h), "{ang}°: the largest about its centre"); + } + // a small turn is a small change, not a jump + let t = c.with_angle(0.5, w, h).rect_px(w, h); + assert!(t.width() > 0.97 * a.width(), "0.5° keeps nearly all of the crop: {} of {}", t.width(), a.width()); + } + + #[test] + fn with_angle_never_grows_a_crop_whose_centre_leaves_the_photo() { + // a sliver in the corner: at 45° its centre is outside the turned photo, so it can't shrink + // in place; it moves to the centre at its own size, not at the largest of its aspect + let (w, h) = (600.0, 400.0); + let c = CropGeometry { rect: Rect::new(0.0, 0.0, 0.05, 0.05), angle: 0.0 }; + let a = c.rect_px(w, h); + let t = c.with_angle(45.0, w, h); + let b = t.rect_px(w, h); + assert!(t.is_within_image(w, h)); + assert!(b.width() <= a.width() + 1e-9 && (a.aspect() - b.aspect()).abs() < 1e-9, "{b:?} from {a:?}"); + assert!(b.center().dist(Point::new(w / 2.0, h / 2.0)) < 1e-9, "centred: {b:?}"); + } + #[test] fn with_angle_survives_a_degenerate_crop() { // zero height: no aspect to keep (width / height = ∞) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..1c9475cb4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -247,7 +247,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it to the largest crop of its aspect inside the photo (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | From f50372d80887d1db6aad8c05fdcdc992e35dbb06 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Wed, 14 Oct 2026 16:55:38 -0400 Subject: [PATCH 211/315] Dialogs: the confirm button is on the right, filled when it is the next step A design critique of the first run found the import review's "Import N Photos" a plain button at the bottom left beside Cancel, though the welcome before it had taught that blue means go. - Every dialog's footer is right-aligned: the confirm on the right, Cancel to its left, added in that order so the keyboard goes through them as the eye does. The row spans the width the window gives its content this frame, so it reaches the window's edge without widening it. - The confirm is filled with the accent when it makes or brings in something (`confirm_style`); Delete and Remove, and an informational dialog's Close, stay plain. Which dialogs only show something is one list (`only_shows`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 78 ++++++++++++++++++---- crates/ui-egui/src/tests_dialog_primary.rs | 74 ++++++++++++++++++++ docs/whats-new.md | 5 ++ 4 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/tests_dialog_primary.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..544724671 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,8 @@ mod tests_date_picker; #[cfg(test)] mod tests_dialog_fit; #[cfg(test)] +mod tests_dialog_primary; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] mod tests_error_kinds; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..c91266a26 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1194,15 +1194,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), }); ui.add_space(4.0); - ui.horizontal(|ui| { + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let content_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(content_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model - || matches!( - dlg, - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew - ); + let informational = unusable_model || only_shows(&dlg); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); // no download location in this build: nothing to offer but the manual install @@ -1212,13 +1214,6 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } => "Not Now", _ => "Cancel", }; - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } let add_label; let ok = match &dlg { Dialog::Import { opts } => { @@ -1258,7 +1253,37 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ) // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); + let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let width_of = |label: &str| { + let text = egui::WidgetText::from(crate::i18n::tr(label)) + .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button) + .size() + .x; + (text + 2.0 * ui.spacing().button_padding.x).max(ui.spacing().interact_size.x) + }; + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += width_of(cancel) + gap; + } + if !ok.is_empty() { + buttons_w += width_of(ok); + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational { + let r = ui.button(crate::i18n::tr(cancel)); + crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); + if r.clicked() { + close = true; + } + } + let button = if primary { + egui::Button::new(egui::RichText::new(crate::i18n::tr(ok)).color(egui::Color32::WHITE)).fill(t.accent) + } else { + egui::Button::new(crate::i18n::tr(ok)) + }; + let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, button)); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); } @@ -1975,3 +2000,28 @@ mod watermark_position_tests { } } } + +/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it +/// makes or brings in something, plain when it deletes or removes (never the eye-catching +/// default) or when the dialog only shows something (its Close). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmStyle { + Primary, + Plain, +} + +pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { + match dlg { + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::RemoveFolder { .. } => ConfirmStyle::Plain, + _ if only_shows(dlg) => ConfirmStyle::Plain, + _ => ConfirmStyle::Primary, + } +} + +/// A dialog that only shows something: one Close, nothing to confirm or cancel. +fn only_shows(dlg: &Dialog) -> bool { + matches!( + dlg, + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + ) +} diff --git a/crates/ui-egui/src/tests_dialog_primary.rs b/crates/ui-egui/src/tests_dialog_primary.rs new file mode 100644 index 000000000..29078d009 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_primary.rs @@ -0,0 +1,74 @@ +//! A dialog's confirm button reads as its primary action: on the right, Cancel to its left, and +//! filled with the accent when it makes or brings in something (Import, Merge, Save…). A +//! destructive confirm (Delete, Remove) stays plain: never the eye-catching default. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::dialogs::{ConfirmStyle, confirm_style}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")) +} + +fn png(dir: &std::path::Path, name: &str) -> String { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() +} + +#[test] +fn the_import_review_puts_its_confirm_on_the_right_as_the_primary_action() { + // Given the import review of two photos + let dir = std::env::temp_dir().join(format!("lc-ui-dialog-primary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = [png(&dir, "a.png"), png(&dir, "b.png")]; + let app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("file.addPhotos", json!({"paths": paths})).unwrap(); + h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:dialogOk")); + h.step(); + // Then "Import 2 Photos" is right of Cancel, at the dialog's right edge + let (ok, cancel, window) = (rect(&h, "button:dialogOk"), rect(&h, "button:dialogCancel"), rect(&h, "dialog:window")); + assert!(ok.left() > cancel.right(), "confirm {ok:?} right of cancel {cancel:?}"); + assert!(window.right() - ok.right() < 40.0, "confirm {ok:?} at the right edge of {window:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn constructive_confirms_are_primary_and_destructive_ones_plain() { + use crate::state::Dialog; + assert_eq!(confirm_style(&Dialog::AutoStack { gap: 60.0 }), ConfirmStyle::Primary); + assert_eq!(confirm_style(&Dialog::ConfirmDelete { count: 3 }), ConfirmStyle::Plain); + // a dialog that only shows something has nothing to confirm: its Close is plain + assert_eq!(confirm_style(&Dialog::About), ConfirmStyle::Plain); +} + +#[test] +fn a_dialog_wider_than_its_content_still_ends_its_footer_at_its_edge() { + // Auto-Stack's title makes it wider than its slider and note: the buttons go to the edge, and + // the window doesn't grow for them + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.autoStack", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + let (ok, window) = (rect(&h, "button:dialogOk"), rect(&h, "dialog:window")); + assert!(window.right() - ok.right() < 24.0, "confirm {ok:?} at the right edge of {window:?}"); + let width = window.width(); + for _ in 0..4 { + h.step(); + } + assert_eq!(rect(&h, "dialog:window").width(), width, "the footer doesn't widen the window"); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..d8611d7a6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### A dialog's confirm button says it's the next step +- In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or + brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive + choice is never the eye-catching default. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From d0a9ca09dd0c4d146aae3a12a71c2babd42c5f6f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Wed, 14 Oct 2026 17:37:54 -0400 Subject: [PATCH 212/315] Buttons: one component with kinds, sizes and icons Buttons were egui's plain one, a fill set by hand where one had to stand out (a dialog's confirm, About's Discord link), with no hover or press shade on a filled one and no shared sizes. - `crate::button::Button`: what it is for (`Kind`: Primary filled with the accent, Secondary with a neutral fill and a border, Danger filled red, Ghost with no fill until hovered), how big (`Size`: Small 24, Medium 30, Large 36 points, each with its padding, text and icon size), and what it shows (text, an icon before the text, or an icon alone, which takes a name for screen readers and its tooltip). - Hover and press darken it; disabled (or in a disabled part of the window) fades it and takes no click, its tooltip or name still shown; a focused one shows an accent ring. A filled kind's fill is darkened as little as needed for white to read at 4.5:1 (the light themes' accent and the red would be too pale), in every state. Drawn within its own rectangle. - Named for screen readers, registered for scripts and tests (`.widget(id)`). Its egui id comes from the widget id (salted with `.id_salt`, e.g. the dialog's kind, so the keyboard's place doesn't carry from one dialog to the next) or from its place in the ui, moved on so buttons with the same text side by side stay apart. - Dialogs' footers (Cancel, the confirm; at least 72 points wide) and About's Discord link use it. The Export dialog's expected height in `tests_dialog_fit` grows by the 8 points of its footer's 30-point buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/button.rs | 389 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/dialogs.rs | 52 ++-- crates/ui-egui/src/tests_button.rs | 159 ++++++++++ crates/ui-egui/src/tests_dialog_fit.rs | 5 +- docs/whats-new.md | 6 + 6 files changed, 583 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/button.rs create mode 100644 crates/ui-egui/src/tests_button.rs diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs new file mode 100644 index 000000000..f46156ed9 --- /dev/null +++ b/crates/ui-egui/src/button.rs @@ -0,0 +1,389 @@ +//! One button for the whole interface: what it is for ([`Kind`]: Primary, Secondary, Danger, +//! Ghost), how big ([`Size`]: Small, Medium, Large), and what it shows (text, an icon and text, +//! or an icon alone, which then needs a name for screen readers). It looks the same everywhere, +//! answers hover, press and the keyboard the same way, and its text always reads at WCAG AA on +//! its fill. +//! +//! ```ignore +//! if Button::new("Import Photos…").primary().widget("button:emptyImportPhotos").show(ui).clicked() { … } +//! Button::icon_only(Icon::Trash, "Delete mask").ghost().small().show(ui); +//! Button::new("Remove").icon(Icon::Trash).danger().show(ui); +//! ``` + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; + +/// What a button is for, which decides how loud it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + /// The step to take: filled with the accent. One per place (a dialog's confirm, the welcome's + /// Import Photos…). + Primary, + /// Any other action: a neutral fill with a border. + #[default] + Secondary, + /// A step that removes or erases something, when it is the one being asked for: filled red. + Danger, + /// A quiet action among content (a toolbar, a banner's Hide): no fill until hovered. + Ghost, +} + +/// How big a button is: its height, padding, text and icon size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Size { + /// 24 points: in toolbars, banners and dense rows. + Small, + /// 30 points: dialogs and panels. + #[default] + Medium, + /// 36 points: a page's one main action (the welcome, About's Discord link). + Large, +} + +impl Size { + pub const fn height(self) -> f32 { + match self { + Size::Small => 24.0, + Size::Medium => 30.0, + Size::Large => 36.0, + } + } + fn padding(self) -> f32 { + match self { + Size::Small => 8.0, + Size::Medium => 12.0, + Size::Large => 16.0, + } + } + fn text(self) -> f32 { + match self { + Size::Small => 12.0, + Size::Medium => 13.0, + Size::Large => 14.0, + } + } + fn icon(self) -> f32 { + match self { + Size::Small => 14.0, + Size::Medium => 16.0, + Size::Large => 18.0, + } + } + fn gap(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 6.0, + Size::Large => 8.0, + } + } + fn radius(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 5.0, + Size::Large => 6.0, + } + } +} + +/// Where the pointer and the keyboard are, for the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Idle, + Hovered, + Pressed, + Disabled, +} + +/// The colours a button of `kind` takes in `state`: fill (if any), border (if any), and what is +/// drawn on it (text and icon). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub fill: Option<Color32>, + pub border: Option<Color32>, + pub content: Color32, +} + +/// WCAG 2 contrast of two opaque colours. +pub fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// `c` toward black by `amount` (0 to 1). +fn darker(c: Color32, amount: f32) -> Color32 { + crate::widgets::lerp(c, Color32::BLACK, amount) +} + +/// `fill`, darkened as little as needed for white to read on it at 4.5:1 (a light theme's or a +/// bright red's fill would otherwise be too pale for white text). +fn fill_for_white(fill: Color32) -> Color32 { + let mut f = fill; + for _ in 0..20 { + if contrast(Color32::WHITE, f) >= 4.5 { + break; + } + f = darker(f, 0.06); + } + f +} + +/// The colours of a `kind` button in `state` with the theme `t`. +pub fn look(t: &Tokens, kind: Kind, state: State) -> Look { + let filled = |base: Color32| { + let base = fill_for_white(base); + // hover and press go darker: white on them only reads better + let fill = match state { + State::Idle | State::Disabled => base, + State::Hovered => darker(base, 0.12), + State::Pressed => darker(base, 0.22), + }; + Look { fill: Some(fill), border: None, content: Color32::WHITE } + }; + let look = match kind { + Kind::Primary => filled(t.accent), + Kind::Danger => filled(t.reject), + Kind::Secondary => Look { + fill: Some(match state { + State::Idle | State::Disabled => t.button, + State::Hovered => t.hover, + State::Pressed => t.pressed, + }), + border: Some(t.button_border), + content: t.text, + }, + Kind::Ghost => Look { + fill: match state { + State::Idle | State::Disabled => None, + State::Hovered => Some(t.hover), + State::Pressed => Some(t.pressed), + }, + border: None, + content: t.text, + }, + }; + if state == State::Disabled { + let fade = |c: Color32| c.gamma_multiply(0.45); + Look { fill: look.fill.map(fade), border: look.border.map(fade), content: fade(look.content) } + } else { + look + } +} + +/// A button, built and then shown with [`Button::show`] (in the layout) or [`Button::show_at`] +/// (in a given rectangle). +#[derive(Clone, Debug)] +pub struct Button<'a> { + text: Option<&'a str>, + icon: Option<Icon>, + name: Option<&'a str>, + kind: Kind, + size: Size, + enabled: bool, + widget: Option<String>, + tooltip: Option<&'a str>, + min_width: f32, + id_salt: Option<egui::Id>, +} + +impl<'a> Button<'a> { + /// A button with `text` (an English string, translated when drawn). + pub fn new(text: &'a str) -> Self { + Button { + text: Some(text), + icon: None, + name: None, + kind: Kind::default(), + size: Size::default(), + enabled: true, + widget: None, + tooltip: None, + min_width: 0.0, + id_salt: None, + } + } + + /// A button showing `icon` alone; `name` is what a screen reader says and the tooltip shows. + pub fn icon_only(icon: Icon, name: &'a str) -> Self { + Button { text: None, icon: Some(icon), name: Some(name), ..Button::new("") } + } + + /// An icon before the text. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + + pub fn kind(mut self, kind: Kind) -> Self { + self.kind = kind; + self + } + pub fn primary(self) -> Self { + self.kind(Kind::Primary) + } + pub fn secondary(self) -> Self { + self.kind(Kind::Secondary) + } + pub fn danger(self) -> Self { + self.kind(Kind::Danger) + } + pub fn ghost(self) -> Self { + self.kind(Kind::Ghost) + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } + pub fn small(self) -> Self { + self.size(Size::Small) + } + pub fn large(self) -> Self { + self.size(Size::Large) + } + + /// Off: drawn faded, and a click does nothing. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on + /// screen, as every registered id is. + pub fn widget(mut self, id: impl Into<String>) -> Self { + self.widget = Some(id.into()); + self + } + + /// Shown on hover (an icon-only button shows its name without one). + pub fn tooltip(mut self, text: &'a str) -> Self { + self.tooltip = Some(text); + self + } + + /// Told apart from buttons of the same widget id in other places (each dialog's confirm is + /// `button:dialogOk`: salted with the dialog, the keyboard's place doesn't carry from one + /// dialog to the next). + pub fn id_salt(mut self, salt: impl std::hash::Hash + std::fmt::Debug) -> Self { + self.id_salt = Some(egui::Id::NULL.with(salt)); + self + } + + /// At least this wide (a row of buttons of one width). + pub fn min_width(mut self, width: f32) -> Self { + self.min_width = width; + self + } + + fn font(&self, t: &Tokens) -> egui::FontId { + // the loud kinds speak a little bolder + match self.kind { + Kind::Primary | Kind::Danger => t.semibold(self.size.text()), + Kind::Secondary | Kind::Ghost => t.font(self.size.text()), + } + } + + fn galley(&self, ui: &Ui) -> Option<std::sync::Arc<egui::Galley>> { + let t = Tokens::get(ui.ctx()); + self.text.filter(|s| !s.is_empty()).map(|s| ui.painter().layout_no_wrap(crate::i18n::tr(s).to_string(), self.font(&t), Color32::WHITE)) + } + + /// How big it is drawn. + pub fn desired_size(&self, ui: &Ui) -> Vec2 { + let h = self.size.height(); + let text_w = self.galley(ui).map_or(0.0, |g| g.size().x); + let w = match (self.icon.is_some(), text_w > 0.0) { + // an icon alone: a square + (true, false) => h, + (true, true) => self.size.padding() * 2.0 + self.size.icon() + self.size.gap() + text_w, + (false, _) => self.size.padding() * 2.0 + text_w, + }; + vec2(w.max(self.min_width), h) + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let size = self.desired_size(ui); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + self.show_at(ui, rect) + } + + /// Show it in `rect` (sized by the caller, e.g. a row of equal buttons). + pub fn show_at(self, ui: &mut Ui, rect: Rect) -> Response { + let t = Tokens::get(ui.ctx()); + // stable across frames and moves: its widget id (with its salt) when it has one, else its + // place in the ui (moved on, so the next button in the same ui gets another) + let id = match &self.widget { + Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)), + None => { + let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt)); + ui.skip_ahead_auto_ids(1); + id + } + }; + // a button in a disabled part of the window is disabled too + let enabled = self.enabled && ui.is_enabled(); + let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + let state = if !enabled { + State::Disabled + } else if resp.is_pointer_button_down_on() { + State::Pressed + } else if resp.hovered() { + State::Hovered + } else { + State::Idle + }; + let lk = look(&t, self.kind, state); + // (its own rectangle and its focus ring: a long text never runs over its neighbours) + let p = &ui.painter_at(rect.expand(5.0)); + let radius = self.size.radius(); + if let Some(fill) = lk.fill { + p.rect_filled(rect, radius, fill); + } + if let Some(border) = lk.border { + p.rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside); + } + // where the keyboard is: an accent ring around it + if resp.has_focus() { + p.rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = self.galley(ui); + let icon_s = self.size.icon(); + let content_w = match (&galley, self.icon) { + (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, + (Some(g), None) => g.size().x, + (None, Some(_)) => icon_s, + (None, None) => 0.0, + }; + let mut x = rect.center().x - content_w / 2.0; + if let Some(icon) = self.icon { + paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); + x += icon_s + self.size.gap(); + } + if let Some(g) = galley { + let at = pos2(x, rect.center().y - g.size().y / 2.0); + p.galley_with_override_text_color(at, g, lk.content); + } + // the tooltip, or an icon's name, also while disabled (what the button would do). egui + // calls a response disabled only in a disabled ui: a button turned off on its own still + // answers hover, and takes the ordinary tooltip + match self.tooltip.or(if self.text.is_none() { self.name } else { None }) { + Some(tip) if resp.enabled() => resp.on_hover_text(crate::i18n::tr(tip)), + Some(tip) => resp.on_disabled_hover_text(crate::i18n::tr(tip)), + None => resp, + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 544724671..5fb1f4e71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod button; mod command_error; pub mod control; pub mod credits; @@ -49,6 +50,8 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_button; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index c91266a26..743d6becd 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1169,11 +1169,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::theme::font_credits(app.chinese_font.is_some()) )); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) - .fill(t.accent) - .min_size(egui::vec2(260.0, 34.0)); - let r = ui.add(discord).on_hover_text(crate::links::DISCORD); - crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); if r.clicked() { let _ = crate::links::open(app, crate::links::DISCORD); } @@ -1254,39 +1256,31 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(&dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); // (Cancel first, then the confirm: the keyboard goes through them as the eye does) - let width_of = |label: &str| { - let text = egui::WidgetText::from(crate::i18n::tr(label)) - .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button) - .size() - .x; - (text + 2.0 * ui.spacing().button_padding.x).max(ui.spacing().interact_size.x) - }; let gap = ui.spacing().item_spacing.x; let mut buttons_w = 0.0; if !informational { - buttons_w += width_of(cancel) + gap; + buttons_w += cancel_button.desired_size(ui).x + gap; } if !ok.is_empty() { - buttons_w += width_of(ok); + buttons_w += ok_button.desired_size(ui).x; } ui.add_space((ui.available_width() - buttons_w).max(0.0)); - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } - let button = if primary { - egui::Button::new(egui::RichText::new(crate::i18n::tr(ok)).color(egui::Color32::WHITE)).fill(t.accent) - } else { - egui::Button::new(crate::i18n::tr(ok)) - }; - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, button)); - if let Some(r) = &r { - crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); + if !informational && cancel_button.show(ui).clicked() { + close = true; } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); if r.is_some_and(|r| r.clicked()) { if informational { close = true; diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests_button.rs new file mode 100644 index 000000000..e06948fff --- /dev/null +++ b/crates/ui-egui/src/tests_button.rs @@ -0,0 +1,159 @@ +//! The button component: its sizes, its looks in every theme and state, what screen readers hear, +//! and that a disabled one can't be clicked. + +use egui::{Color32, Pos2, Rect, pos2, vec2}; + +use crate::button::{Button, Kind, Size, State, contrast, look}; +use crate::icons::Icon; +use crate::theme::{ThemeKind, Tokens}; + +/// Run frames of `draw` (with pointer `events` in the second), returning the AccessKit nodes' +/// (role, name) and what `draw` reported. +fn frames<T: Default>(events: Vec<egui::Event>, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<(String, String)>, T) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut found = Vec::new(); + let mut last = T::default(); + for frame in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: if frame == 1 { events.clone() } else { Vec::new() }, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| last = draw(ui)); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (found, last) +} + +#[test] +fn the_three_sizes_and_an_icon_alone() { + let (_, sizes) = frames(Vec::new(), |ui| { + [Size::Small, Size::Medium, Size::Large] + .map(|s| (Button::new("Import Photos…").size(s).desired_size(ui), Button::icon_only(Icon::Trash, "Delete").size(s).desired_size(ui))) + .to_vec() + }); + let heights: Vec<f32> = sizes.iter().map(|(text, _)| text.y).collect(); + assert_eq!(heights, vec![24.0, 30.0, 36.0]); + for (text, icon) in &sizes { + assert_eq!(icon.x, icon.y, "an icon alone is square"); + assert!(text.x > icon.x, "text makes it wider"); + } + // an icon beside the text makes it wider still, and a minimum width holds + let (_, (plain, with_icon, wide)) = frames(Vec::new(), |ui| { + ( + Button::new("Remove").desired_size(ui).x, + Button::new("Remove").icon(Icon::Trash).desired_size(ui).x, + Button::new("OK").min_width(120.0).desired_size(ui).x, + ) + }); + assert!(with_icon > plain); + assert_eq!(wide, 120.0); +} + +#[test] +fn a_screen_reader_hears_every_button_by_its_name() { + let (found, _) = frames(Vec::new(), |ui| { + Button::new("Import Photos…").primary().show(ui); + Button::icon_only(Icon::Trash, "Delete mask").ghost().show(ui); + Button::new("Remove").icon(Icon::Trash).danger().enabled(false).show(ui); + }); + let has = |label: &str| found.iter().any(|(r, l)| r == "Button" && l == label); + assert!(has("Import Photos…"), "{found:?}"); + assert!(has("Delete mask"), "an icon alone is named: {found:?}"); + assert!(has("Remove"), "a disabled one is still announced: {found:?}"); +} + +#[test] +fn text_on_a_fill_reads_at_aa_in_every_theme_and_state() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for k in [Kind::Primary, Kind::Danger, Kind::Secondary] { + for state in [State::Idle, State::Hovered, State::Pressed] { + let lk = look(&t, k, state); + let fill = lk.fill.unwrap_or(t.canvas); + let c = contrast(lk.content, fill); + assert!(c >= 4.5, "{kind:?} {k:?} {state:?}: {c:.2}:1"); + } + } + // a ghost's text on the surfaces it sits on + let ghost = look(&t, Kind::Ghost, State::Idle); + assert!(ghost.fill.is_none()); + assert!(contrast(ghost.content, t.chrome) >= 4.5, "{kind:?} ghost on chrome"); + } +} + +#[test] +fn hover_and_press_are_darker_and_disabled_is_faded() { + let t = Tokens::for_kind(ThemeKind::Charcoal); + let luma = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); + let fill = |s| look(&t, Kind::Primary, s).fill.map(luma).unwrap_or(0); + assert!(fill(State::Hovered) < fill(State::Idle)); + assert!(fill(State::Pressed) < fill(State::Hovered)); + let disabled = look(&t, Kind::Primary, State::Disabled); + assert!(disabled.content.a() < 255, "faded"); +} + +#[test] +fn a_disabled_button_cannot_be_clicked() { + let at = pos2(40.0, 15.0); + let click = vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]; + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let mut disabled_clicked = false; + let _ = frames(click.clone(), |ui| { + if Button::new("Delete").danger().enabled(false).show_at(ui, place).clicked() { + disabled_clicked = true; + } + }); + assert!(!disabled_clicked, "on any frame"); + let mut seen = false; + let _ = frames(click, |ui| { + if Button::new("Delete").danger().show_at(ui, place).clicked() { + seen = true; + } + }); + assert!(seen, "an enabled one is"); +} + +#[test] +fn buttons_in_one_place_with_the_same_text_are_told_apart() { + // two "Delete mask" buttons side by side, placed by the caller: two widgets, not one + let (found, _) = frames(Vec::new(), |ui| { + let a = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(30.0, 30.0))); + let b = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(40.0, 0.0), vec2(30.0, 30.0))); + assert_ne!(a.id, b.id); + }); + assert_eq!(found.iter().filter(|(r, l)| r == "Button" && l == "Delete mask").count(), 2, "{found:?}"); +} + +#[test] +fn a_button_in_a_disabled_part_of_the_window_says_it_is_disabled() { + // (egui refuses the click there anyway: what the button adds is looking and reading disabled) + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut disabled = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.add_enabled_ui(false, |ui| { + let resp = Button::new("Delete").show_at(ui, Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0))); + // drawn as disabled: it asks for hover only, as a button turned off does + assert!(!resp.sense.senses_click(), "drawn as a button that can be clicked"); + }); + }); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + disabled = update.nodes.iter().find(|(_, n)| n.label() == Some("Delete")).map(|(_, n)| n.is_disabled()); + } + } + assert_eq!(disabled, Some(true), "a screen reader hears it disabled"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 29d0401c2..1185e94e5 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -95,8 +95,9 @@ fn export_dialog_is_unchanged_in_a_tall_window() { let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the // format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos" - // (#236), so it is 876 tall there) - let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 }; + // (#236), so it is 876 tall there. 8 more since its footer's buttons are the button + // component's medium size, 30 points, as the welcome's and every dialog's) + let height = if cfg!(target_os = "macos") { 884.0 } else { 854.0 }; assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } diff --git a/docs/whats-new.md b/docs/whats-new.md index d8611d7a6..054105e2e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### One kind of button +- Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent), + secondary, danger (filled red) and ghost, in three sizes, with an icon or without, and an icon alone (named for + screen readers). Each darkens a little when hovered and more when pressed, shows a ring where the keyboard is, and + its text reads at 4.5:1 or more on its fill in every theme. Dialog buttons are 30 points tall, like the welcome's. + ### A dialog's confirm button says it's the next step - In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive From 072bcfeaf128a81e9550e216bb87e53331297f58 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:31:45 +0200 Subject: [PATCH 213/315] Fix crop.rotateAspect turning a zero-size crop into a NaN rect Found while testing crates/engine/src/cmd/develop.rs:676 (crop.rotateAspect); also crates/geom/src/lib.rs:180 (Rect::fit_aspect) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/develop.rs | 5 +++-- crates/engine/src/tests.rs | 15 +++++++++++++++ crates/geom/src/lib.rs | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260..15c40ccb6 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -678,10 +678,11 @@ pub fn specs() -> Vec<CommandSpec> { let (w, h) = crop_dims(s, id); edit(s, "crop.rotateAspect", "Rotate Crop", |d| { let r = d.crop.geometry.rect_px(w, h); - let a = r.height() / r.width(); + // a degenerate crop has no aspect to rotate: fall back to the image's own + let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0); d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x)); let angle = d.crop.geometry.angle; - d.crop.geometry = crop_fit_angle(w, h, angle, Some(a)); + d.crop.geometry = crop_fit_angle(w, h, angle, a); Ok(()) }) }), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..d52c7245a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -424,6 +424,21 @@ fn degenerate_crops_render_with_spots_and_masks() { } } +/// `crop.rotateAspect` on a crop of no size took its aspect from 0/0 = NaN and stored a NaN crop +/// rectangle; it now falls back to the image's own aspect, a finite crop inside the image. +#[test] +fn rotate_aspect_of_degenerate_crop_stays_finite() { + for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.2, 0.8, 0.2]] { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": rect})).unwrap(); + s.execute("crop.rotateAspect", &json!({})).unwrap(); + let r = active_dev(&s).crop.geometry.rect; + assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?}: {r:?}"); + assert!(r.width() > 0.0 && r.height() > 0.0, "{rect:?}: {r:?}"); + assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= 1.0 && r.y1 <= 1.0, "{rect:?}: {r:?}"); + } +} + /// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow /// checks are on); extreme deltas and indices now move the mask to the bottom or the top. #[test] diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 4b8f623e2..14eaaf065 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -178,7 +178,7 @@ impl Rect { } /// Largest rectangle of aspect `a` (w/h) centred in `self`. pub fn fit_aspect(&self, a: f64) -> Rect { - if a <= 0.0 || self.is_empty() { + if !(a.is_finite() && a > 0.0) || self.is_empty() { return *self; } let (w, h) = (self.width(), self.height()); From 3080f71680c3bea2ad93d8fbd87411274a3c3fb0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:44:54 +0200 Subject: [PATCH 214/315] Keep crop finite when crop.set gets a huge rect Found while testing crates/geom/src/crop.rs:59 (CropGeometry::constrained, via crates/engine/src/cmd/develop.rs:524 crop.set) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/engine/src/tests.rs | 11 +++++++++++ crates/geom/src/crop.rs | 21 ++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..b12eb1efe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -424,6 +424,17 @@ fn degenerate_crops_render_with_spots_and_masks() { } } +/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop. +#[test] +fn crop_set_huge_rect_stays_finite() { + for rect in [[0.0, 0.0, 1e308, 1e308], [-1e308, -1e308, 1e308, 1e308]] { + let mut s = demo(); + let _ = s.execute("crop.set", &json!({"rect": rect})); + let r = active_dev(&s).crop.geometry.rect; + assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{rect:?}: {r:?}"); + } +} + /// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow /// checks are on); extreme deltas and indices now move the mask to the bottom or the top. #[test] diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index 857ea19b9..e6906a42b 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -77,7 +77,14 @@ impl CropGeometry { let s = max_inscribed_scale(w, h, self.angle, r.width(), r.height()); let nr = Rect::from_center(c, r.width() * s, r.height() * s); let _ = best; - CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle } + let out = CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle }; + if [out.rect.x0, out.rect.y0, out.rect.x1, out.rect.y1].iter().all(|v| v.is_finite()) { + return out; + } + // A rect too large for pixel maths (`inf * 0` above): the largest crop of its aspect that + // fits, else of the image's own. + let aspect = Some(self.rect.width() / self.rect.height() * (w / h)).filter(|a| a.is_finite() && *a > 0.0); + crop_fit_angle(w, h, self.angle, aspect) } /// This crop turned to `angle_deg` on a `w × h` image: kept as it is when it still fits inside @@ -211,4 +218,16 @@ mod tests { let p = t.apply(Point::new(0.0, 0.0)); assert!((p.x - 25.0).abs() < 1e-9 && (p.y - 25.0).abs() < 1e-9); } + + #[test] + fn constrained_huge_rect_is_finite_and_inside() { + for rect in [Rect::new(0.0, 0.0, 1e308, 1e308), Rect::new(-1e308, -1e308, 1e308, 1e308), Rect::new(0.0, 0.0, 1e308, 0.5)] { + for angle in [0.0, 30.0] { + let c = CropGeometry { rect, angle }.constrained(400.0, 300.0); + let r = c.rect; + assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?} {angle}: {r:?}"); + assert!(c.is_within_image(400.0, 300.0), "{rect:?} {angle}: {r:?}"); + } + } + } } From b5169e907c26fb09d6a649b9f2c39c068d85f191 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:53:53 +0200 Subject: [PATCH 215/315] LR-META-XMP: radial masks keep their inside/outside from Lightroom (fixes #1815) crs:Flipped="true" is how Lightroom Classic writes an ordinary radial mask (effect inside); it was read as our invert, so every imported radial applied outside the circle. Established from Lightroom Classic 15.6's exports of synthetic DNGs: - MaskGroupBasedCorrections, Version 2: Flipped is the effect-inside flag; inverting is the component's MaskInverted. - MaskGroupBasedCorrections without Version: inside whatever Flipped says (Lightroom rewrites both values as Flipped=true, Version=2), and its Feather is halved, as Lightroom rescales it on that upgrade. - Legacy CircularGradientBasedCorrections Radial Filter: outside by default, Flipped=true (Invert Mask) is inside. One mapping serves XMP sidecars, DNG-embedded XMP, presets and .lrcat catalog import. LightCraft writes no crs: masks, so there is no writer to match. --- crates/engine/src/crs_masks.rs | 162 +++++++++++++++++++++++++++++++-- docs/parity-checklist.md | 2 +- docs/whats-new.md | 8 ++ docs/xmp-interop.md | 2 +- 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b..382dbaa24 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -98,8 +98,46 @@ fn pt(x: f64, y: f64) -> Value { json!({"x": x, "y": y}) } -/// One mask component → our component JSON; `Err(kind)` names an unsupported one. -fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> { +/// Whether a `Mask/CircularGradient` component puts its effect inside the ellipse, given the +/// container it came from (`crs:Flipped` means different things in different formats). +/// +/// What Lightroom Classic does, from its exports of synthetic DNGs whose masks we wrote (issue #1815): +/// - **`MaskGroupBasedCorrections`, `crs:Version` ≥ 2** (what Lightroom writes today): an ordinary +/// radial — effect inside — is written `Flipped="true"`, so `Flipped` is the "inside" flag. +/// Inverting the mask is a separate field, `crs:MaskInverted` (see [`component`]). `Flipped="false"` +/// is read as outside, the same sense as the legacy filter's; Lightroom never wrote one for us. +/// - **`MaskGroupBasedCorrections` without `crs:Version`**: Lightroom renders the effect inside +/// whatever `Flipped` says, and rewrites both `Flipped="false"` and `"true"` as `Flipped="true"` +/// with `Version="2"`. So `Flipped` is ignored here. +/// - **`CircularGradientBasedCorrections`** (the process-2012 Radial Filter): its default effect was +/// outside, and "Invert Mask" — effect inside — was recorded as `Flipped="true"`. The same "inside" +/// flag as version 2, with outside as the default when it is missing. +fn radial_inside(m: &XmpValue, container: &str) -> bool { + let flipped = flag(m, "crs:Flipped"); + if container == "crs:MaskGroupBasedCorrections" { + if num(m, "crs:Version").is_some_and(|v| v >= 2.0) { flipped.unwrap_or(true) } else { true } + } else { + flipped.unwrap_or(false) + } +} + +/// A `Mask/CircularGradient` component's feather on our 0..100 scale. +/// +/// Lightroom halves `crs:Feather` when it upgrades a `MaskGroupBasedCorrections` radial without +/// `crs:Version` to version 2 (50 → 25, 100 → 50) and renders both the same, so the version-less +/// value is on twice the scale. Version 2's value is used as it is: its 25 and 50 put the middle of +/// Lightroom's fall-off at about 0.87 and 0.76 of the radius, ours at about 0.86 and 0.73. (The +/// shape differs: Lightroom's fall-off straddles the ellipse edge, ours ends at it.) The legacy +/// Radial Filter's value is used as it is: no observation says otherwise. +fn radial_feather(m: &XmpValue, container: &str) -> f64 { + let f = num(m, "crs:Feather").unwrap_or(50.0); + let versionless_group = container == "crs:MaskGroupBasedCorrections" && !num(m, "crs:Version").is_some_and(|v| v >= 2.0); + (if versionless_group { f / 2.0 } else { f }).clamp(0.0, 100.0) +} + +/// One mask component of a correction in `container` → our component JSON; `Err(kind)` names an +/// unsupported one. +fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String> { let what = text(m, "crs:What").unwrap_or(""); let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 { 1 => "subtract", @@ -123,8 +161,9 @@ fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> { "rx": ((r - l).abs() / 2.0 * sx).max(1e-3), "ry": ((b - t).abs() / 2.0 * sy).max(1e-3), "angle": g("Angle", 0.0), - "feather": g("Feather", 50.0).clamp(0.0, 100.0), - "invert": flag(m, "crs:Flipped").unwrap_or(false), + "feather": radial_feather(m, container), + // our radial's `invert` = effect outside; `crs:MaskInverted` stays the component's + "invert": !radial_inside(m, container), }) } "Mask/Paint" => { @@ -193,7 +232,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) { let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default(); let mut parts = Vec::new(); for m in comps { - match component(m, aspect) { + match component(m, container, aspect) { Ok(v) => parts.push(v), Err(kind) => skipped.push(kind), } @@ -392,7 +431,7 @@ mod tests { </rdf:Description></rdf:li> <rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2"> <crs:CorrectionMasks><rdf:Seq> - <rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/> + <rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true" crs:Version="2"/> <rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li> <rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/> </rdf:Seq></crs:CorrectionMasks> @@ -433,7 +472,8 @@ mod tests { let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); - assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true)); + assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); + assert!(!rad.components[0].invert); let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; assert!(lo < hi && *lo_feather > 0.0); let brush = &s.masks[2]; @@ -491,6 +531,114 @@ mod tests { assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); } + /// One radial correction in `container`, with `attrs` on its mask component (values written + /// for this test; the component attributes follow what Lightroom Classic writes). + fn radial_packet(container: &str, attrs: &str) -> String { + format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0"> + <crs:{container}><rdf:Seq> + <rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="0.25"> + <crs:CorrectionMasks><rdf:Seq> + <rdf:li crs:What="Mask/CircularGradient" crs:MaskValue="1" crs:Top="0.25" crs:Left="0.25" crs:Bottom="0.75" crs:Right="0.75" crs:Angle="0" crs:Midpoint="50" crs:Roundness="0" {attrs}/> + </rdf:Seq></crs:CorrectionMasks> + </rdf:Description></rdf:li> + </rdf:Seq></crs:{container}> +</rdf:Description></rdf:RDF></x:xmpmeta>"# + ) + } + + fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { + let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + assert!(unmapped.is_empty(), "{unmapped:?}"); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.masks.len(), 1, "{:?}", s.masks); + s + } + + /// (the radial's `invert`, its feather, the component's `invert`). + fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { + let c = &s.masks[0].components[0]; + let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; + (*invert, *feather, c.invert) + } + + /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). + fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { + let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; + let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); + let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); + (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) + } + + #[test] + fn lightroom_radial_flipped_is_inside() { + // as Lightroom Classic writes an ordinary radial mask today (#1815) + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); + // no MaskInverted at all reads the same + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + } + + #[test] + fn lightroom_radial_mask_inverted_is_outside() { + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); + // version 2 with Flipped="false": outside, the legacy sense of the flag + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); + assert_eq!(radial_of(&s), (true, 25.0, false)); + } + + #[test] + fn versionless_group_radial_is_inside_with_half_the_feather() { + // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" + for flipped in ["true", "false"] { + let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); + assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); + } + } + + #[test] + fn legacy_radial_filter_is_outside_unless_flipped() { + // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); + assert_eq!(radial_of(&s), (true, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); + assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); + assert_eq!(radial_of(&s), (false, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); + } + + #[test] + fn lrtemplate_radial_filter_reads_flipped() { + // .lrtemplate presets and catalog develop settings are the same Lua tables + let t = |flipped: &str| { + format!( + r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ + {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ + {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# + ) + }; + for (flipped, outside) in [("true", false), ("false", true)] { + let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); + } + } + /// Spot removal in the shape Lightroom Classic writes it (values written for this test): /// a healed circle with an explicit source, a cloned circle with an automatic source, a /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..62bcce12a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -167,7 +167,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..e15d65917 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-15 + +### Lightroom radial masks on the right side +- Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now + apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in + Lightroom stays inverted, and older Radial Filters keep their Effect Outside / Invert Mask setting. Photos imported + before this keep the masks they got: Photo ▸ Read Metadata from File reads a sidecar's again. + ## 2026-10-14 ### Hard-edged Heal and Clone spots diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..662b2e752 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -182,7 +182,7 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct | `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 | | `LocalToningHue` | `adjust.color_hue` | degrees | | `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point | -| `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape); `Flipped` → invert | +| `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape). `Flipped="true"` means effect inside: in `MaskGroupBasedCorrections` with `Version` 2 (what Lightroom writes for an ordinary radial) and in the legacy `CircularGradientBasedCorrections` Radial Filter, whose default (no `Flipped`) is outside. A `MaskGroupBasedCorrections` radial without `Version` is inside whatever `Flipped` says, and its `Feather` is halved (Lightroom rescales it that way when it upgrades one). Inverting is `MaskInverted` | | `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases | | `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed | | `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported | From 5c5370fc9bfabf810dce64a65d8e74f2428b1bce Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:54:17 +0200 Subject: [PATCH 216/315] LR-LIB-REMOVE-ALBUM: removing a photo from an album keeps it in its folder (fixes #1805) In an album the Delete key ran photo.delete, which moved the photo to Recently Deleted, so it also left its folder views. It now removes the selection from the album only (album.removePhotos), as in Lightroom; in a smart album it changes nothing. Photo > Remove from Album shows in normal albums with the Delete key; Photo > Delete Photo still deletes from the library. album.removePhotos defaults to the album on screen and returns {removed, album}. --- crates/engine/src/cmd/library.rs | 41 +++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_album_remove.rs | 105 ++++++++++++++++++ crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 18 +++ crates/ui-egui/src/menus.rs | 26 +++++ crates/ui-egui/src/shortcuts.rs | 5 + crates/ui-egui/src/tests_album_remove.rs | 134 +++++++++++++++++++++++ docs/parity-checklist.md | 6 +- docs/whats-new.md | 9 ++ 19 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/tests_album_remove.rs create mode 100644 crates/ui-egui/src/tests_album_remove.rs diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..39cfc3065 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1334,17 +1334,38 @@ pub fn specs() -> Vec<CommandSpec> { Ok(result) } ), - cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| { - let id = album_param(p, "id", "album.removePhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); - let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; - if al.is_smart() { - return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + cmd!( + "album.removePhotos", + "Remove from Album", + ["Photo"], + None, + "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", + has_selection, + |s, p| { + let id = match p.get("id") { + Some(_) => album_param(p, "id", "album.removePhotos")?, + None => match s.source { + LibrarySource::Album(a) => a, + _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), + }, + }; + let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; + if al.is_smart() { + return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + } + if al.folder { + return Err(bad("album.removePhotos", "album folders don't hold photos")); + } + let name = al.name.clone(); + let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); + let removed = al.photos.len().saturating_sub(photos.len()); + if removed > 0 { + s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; + } + Ok(json!({"removed": removed, "album": name})) } - let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); - s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; - ok() - }), + ), cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { let id = album_param(p, "id", "album.setCover")?; let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..8b45eeecc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -996,6 +996,8 @@ mod tests; #[cfg(test)] mod tests_album_order; #[cfg(test)] +mod tests_album_remove; +#[cfg(test)] mod tests_albums_local; #[cfg(test)] mod tests_color; diff --git a/crates/engine/src/tests_album_remove.rs b/crates/engine/src/tests_album_remove.rs new file mode 100644 index 000000000..041eb3812 --- /dev/null +++ b/crates/engine/src/tests_album_remove.rs @@ -0,0 +1,105 @@ +//! Removing photos from an album changes the album only (issue #1805): the photos stay in the +//! library, in their library folder and in the folder view of their folder on disk. Deleting a +//! photo (`photo.delete`) still removes it from all of them. + +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::{Selection, Session}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-album-remove-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png(p: &Path, seed: u8) { + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); + let b = + crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap(); + std::fs::write(p, b).unwrap(); +} + +/// A folder of three files imported into a new album "Trip": (session, folder, album id, photos). +fn imported(tag: &str) -> (Session, String, u64, Vec<PhotoId>) { + let dir = temp_dir(tag); + for (i, n) in ["a.png", "b.png", "c.png"].iter().enumerate() { + png(&dir.join(n), i as u8 * 40); + } + let folder = dir.to_string_lossy().to_string(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [folder], "mode": "add", "albumName": "Trip"})).unwrap(); + let album = r["album"].as_u64().expect("the import made the album"); + let mut ids: Vec<PhotoId> = r["imported"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(PhotoId).collect(); + ids.sort(); + assert_eq!(ids.len(), 3); + (s, folder, album, ids) +} + +/// How many photos the library folder view, the folder (disk) view and the album show. +fn counts(s: &mut Session, folder: &str, album: u64) -> (u64, u64, u64) { + let lib = s.execute("library.source", &json!({"kind": "libraryFolder", "path": folder})).unwrap()["count"].as_u64().unwrap(); + let disk = s.execute("library.browse", &json!({"path": folder})).unwrap()["photos"].as_u64().unwrap(); + let disk_visible = s.visible_cloned().len() as u64; + assert_eq!(disk, disk_visible, "browse reports what it shows"); + let al = s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap()["count"].as_u64().unwrap(); + (lib, disk, al) +} + +/// The reporter's steps: a folder added to an album, a photo removed in the album, then the folder +/// view: it still shows every photo of the folder. +#[test] +fn removing_from_an_album_keeps_the_photo_in_its_folder() { + let (mut s, folder, album, ids) = imported("keep"); + assert_eq!(counts(&mut s, &folder, album), (3, 3, 3)); + + // in the album (its source on screen), remove one photo; no `id`: the album on screen + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.selection = Selection::single(ids[1]); + let r = s.execute("album.removePhotos", &json!({})).unwrap(); + assert_eq!(r["removed"], 1, "{r}"); + assert_eq!(r["album"], "Trip", "{r}"); + assert!(!s.catalog.photo(ids[1]).unwrap().deleted, "still in the library"); + assert_eq!(counts(&mut s, &folder, album), (3, 3, 2), "(library folder, folder on disk, album)"); + + // removing what isn't in the album changes nothing (and makes no undo step) + let undo = s.undo.len(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + let r = s.execute("album.removePhotos", &json!({"ids": [ids[1].0]})).unwrap(); + assert_eq!(r["removed"], 0, "{r}"); + assert_eq!(s.undo.len(), undo); + let _ = std::fs::remove_dir_all(&folder); +} + +/// Delete (to Recently Deleted) is the way out of the library: the photo leaves its folder views +/// and the album with it. +#[test] +fn deleting_a_photo_still_removes_it_everywhere() { + let (mut s, folder, album, ids) = imported("delete"); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.selection = Selection::single(ids[0]); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(s.catalog.photo(ids[0]).unwrap().deleted); + assert_eq!(counts(&mut s, &folder, album), (2, 2, 2), "(library folder, folder on disk, album)"); + let n = s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap()["count"].as_u64().unwrap(); + assert_eq!(n, 1); + let _ = std::fs::remove_dir_all(&folder); +} + +/// Without an album on screen and without `id` there is nothing to remove from; smart albums +/// follow their rules. +#[test] +fn remove_from_album_refuses_what_it_cannot_do() { + let (mut s, folder, _album, ids) = imported("refuse"); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + s.selection = Selection::single(ids[0]); + assert!(s.execute("album.removePhotos", &json!({})).is_err(), "no album on screen"); + let smart = s.execute("album.createSmart", &json!({"name": "All", "rules": {}})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("album.removePhotos", &json!({"id": smart})).is_err(), "smart album"); + assert!(s.execute("album.removePhotos", &json!({"id": 9999})).is_err(), "no such album"); + assert!(s.execute("album.removePhotos", &json!({"id": "x"})).is_err(), "bad id"); + let _ = std::fs::remove_dir_all(&folder); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..d524ed5b6 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,6 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändern Sie ihre Regeln oder verwenden Sie Foto ▸ Foto löschen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..aa0fb7105 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,6 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto ▸ Eliminar foto" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..ae763e28c 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,6 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo ▸ Supprimer la photo" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..ad7755842 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,6 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 ▸ 写真を削除を使ってください" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..07d851014 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,6 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto ▸ Excluir foto" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..fa830f212 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,6 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото ▸ Удалить фото" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5429ac1f8 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,6 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото ▸ Видалити фото" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..a5eb13049 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,6 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 ▸ 删除照片”" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..a7cc6c567 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,6 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 ▸ 刪除照片」" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..edebce995 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -45,6 +45,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_album_remove; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_before_after; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..61d8ff39f 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -207,6 +207,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "dialog.captureTime", "photo.tagFromTracklog", "---", + "album.removePhotos", "photo.delete", "photo.restore", "photo.deletePermanently", @@ -286,12 +287,22 @@ fn host_supports(app: &LightcraftApp, id: &str) -> bool { "file.restoreLibrary" => app.services.restore_library.is_some(), "photo.restore" | "photo.deletePermanently" => selection_deleted(app), "photo.delete" => !selection_deleted(app), + // only in an album that holds its photos (not a smart one) + "album.removePhotos" => in_normal_album(app) && !selection_deleted(app), // only where the trash is on screen "library.emptyRecentlyDeleted" => app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted, _ => true, } } +/// The library shows an album that holds its photos (not a smart album, not a folder of albums). +pub(crate) fn in_normal_album(app: &LightcraftApp) -> bool { + match app.session.source { + lightcraft_engine::LibrarySource::Album(a) => app.session.catalog.album(a).is_some_and(|a| !a.folder && !a.is_smart()), + _ => false, + } +} + fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode { MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } } @@ -670,6 +681,13 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { // Shift+P picks and advances in Library; don't advertise it on Presets in those views. let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() }; + // in an album the Delete key removes from the album (issue #1805): the menu says so + let delete_key = crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "photo.delete"); + let shortcut = match e.id.as_str() { + "album.removePhotos" => delete_key, + "photo.delete" if matches!(app.session.source, lightcraft_engine::LibrarySource::Album(_)) => None, + _ => shortcut, + }; item(&e.id, Value::Null, live_label(app, &e.id, &e.label), shortcut, e.enabled, checked(app, &e.id)) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..722bd7f55 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1973,6 +1973,32 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> { v } +/// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), +/// which stay in the library and their folder; a smart album keeps them (its rules decide) and +/// says so. True when the key was handled here, false outside albums (then it deletes the photos +/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere. +pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false }; + let Some(album) = app.session.catalog.album(aid) else { return false }; + if album.is_smart() { + app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); + return true; + } + if app.session.targets(&json!({})).is_empty() { + return true; + } + match app.run("album.removePhotos", json!({"id": aid.0})) { + Ok(r) => { + let n = r["removed"].as_u64().unwrap_or(0); + let name = r["album"].as_str().unwrap_or_default().to_string(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name)); + } + Err(e) => app.toast(ctx, e), + } + true +} + /// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead /// of deleting; true when it did (the dialog's OK runs `photo.delete`). pub fn confirm_delete(app: &mut LightcraftApp) -> bool { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b10387c76..276840496 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -484,6 +484,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { R::RedEye => continue, _ => {} } + // in an album, Delete takes the photos out of the album only, as in Lightroom: they + // stay in the library and their folder (issue #1805) + if crate::menus::delete_in_album(app, ctx) { + continue; + } if crate::menus::confirm_delete(app) { continue; } diff --git a/crates/ui-egui/src/tests_album_remove.rs b/crates/ui-egui/src/tests_album_remove.rs new file mode 100644 index 000000000..9c6f9d81f --- /dev/null +++ b/crates/ui-egui/src/tests_album_remove.rs @@ -0,0 +1,134 @@ +//! In an album, the Delete key takes the selected photos out of the album only (issue #1805): they +//! stay in the library and in their folder, as in Lightroom. Photo ▸ Delete Photo still moves them +//! to Recently Deleted, from everywhere. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::headless::Headless; +use crate::menubar::MenuNode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + h.step(); + h.step(); + r["result"].clone() +} + +/// The Photo menu's items: (id, shortcut). +fn photo_menu(h: &Headless) -> Vec<(String, Option<String>)> { + let bar = crate::menubar::menu_bar(&h.app); + let photo = &bar.iter().find(|(t, _)| t == "Photo").expect("Photo menu").1; + photo + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, shortcut, .. } => Some((id.clone(), shortcut.clone())), + _ => None, + }) + .collect() +} + +fn garden(h: &Headless) -> u64 { + h.app.session.catalog.albums().find(|a| a.name == "Garden").map(|a| a.id.0).expect("the demo's Garden album") +} + +fn in_album(h: &Headless, album: u64, photo: PhotoId) -> bool { + h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).is_some_and(|a| a.photos.contains(&photo)) +} + +/// Given an album, when a photo is selected in it and Delete is pressed, then the photo leaves the +/// album and nothing else: it is not deleted, nothing asks, and All Photos still shows it. +#[test] +fn the_delete_key_in_an_album_removes_from_the_album_only() { + let mut h = demo(); + assert!(h.app.ui.settings.confirm_delete, "the delete confirmation is on by default"); + let album = garden(&h); + exec(&mut h, "library.source", json!({"kind": "album", "id": album})); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + + let r = h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, None, "removing from an album asks nothing (it is undoable and keeps the photo)"); + assert!(!in_album(&h, album, photo), "the photo left the album"); + assert!(!h.app.session.catalog.photo(photo).unwrap().deleted, "the photo is not deleted from the library"); + exec(&mut h, "library.source", json!({"kind": "all"})); + assert!(h.app.session.visible_cloned().contains(&photo), "All Photos still shows it"); + + // one undo brings it back into the album + exec(&mut h, "edit.undo", json!({})); + assert!(in_album(&h, album, photo)); +} + +/// In an album the Photo menu offers Remove from Album with the Delete key, and Delete Photo (no +/// key) still deletes from the library, after asking; outside albums there is no Remove from Album. +#[test] +fn the_photo_menu_in_an_album_has_remove_from_album_on_the_delete_key() { + let mut h = demo(); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let items = photo_menu(&h); + assert!(!items.iter().any(|(id, _)| id == "album.removePhotos"), "not outside albums: {items:?}"); + assert!(items.iter().any(|(id, sc)| id == "photo.delete" && sc.as_deref() == Some("Delete")), "{items:?}"); + + let album = garden(&h); + exec(&mut h, "library.source", json!({"kind": "album", "id": album})); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let items = photo_menu(&h); + assert!(items.iter().any(|(id, sc)| id == "album.removePhotos" && sc.as_deref() == Some("Delete")), "{items:?}"); + assert!(items.iter().any(|(id, sc)| id == "photo.delete" && sc.is_none()), "{items:?}"); + + // Photo ▸ Remove from Album (no params: the album on screen) + crate::menubar::run_item(&mut h.app, "album.removePhotos", json!({})).unwrap(); + assert!(!in_album(&h, album, photo)); + assert!(!h.app.session.catalog.photo(photo).unwrap().deleted); + + // Photo ▸ Delete Photo still deletes from the library: it asks, then OK deletes + let other = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [other.0]})); + crate::menubar::run_item(&mut h.app, "photo.delete", json!({})).unwrap(); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(other).unwrap().deleted, "Delete Photo removes it from the library"); + exec(&mut h, "library.source", json!({"kind": "all"})); + assert!(!h.app.session.visible_cloned().contains(&other)); +} + +/// A smart album's photos follow its rules: Delete there neither removes nor deletes anything. +#[test] +fn the_delete_key_in_a_smart_album_deletes_nothing() { + let mut h = demo(); + let r = exec(&mut h, "album.createSmart", json!({"name": "Rated", "rules": {"rating": 0, "ratingOp": "atLeast"}})); + let smart = r["id"].as_u64().expect("smart album id"); + exec(&mut h, "library.source", json!({"kind": "album", "id": smart})); + let photo = *h.app.session.visible_cloned().first().expect("the smart album shows photos"); + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let r = h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.dialog, None); + assert!(!h.app.session.catalog.photo(photo).unwrap().deleted); + assert!(h.app.session.visible_cloned().contains(&photo)); + assert!(!photo_menu(&h).iter().any(|(id, _)| id == "album.removePhotos")); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..58f42a890 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -108,7 +108,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | -| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. | @@ -548,7 +548,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-SIDEBARSECTIONS | Sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset` | View ▸ Sidebar Sections ▸ a checkable item per left-sidebar section, then Reset Sidebar Sections (see LR-BEHAV-SIDEBAR-HIDE) | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | | MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | off for a Local-only selection, with an Add to My Photos First hint item (issue #684) | -| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | Photo ▸ Remove from Album, shown in a normal album only, carries the ⌫ shortcut there (Delete Photo then has none) (issue #1805) | | MENU-PHOTO-RATE | Rate submenu | P0 | ✅ | `cmd:photo.rate` | | | MENU-PHOTO-FLAG | Flag submenu | P0 | ✅ | `cmd:photo.flag` | | | MENU-PHOTO-LABEL | Colour label submenu | P1 | ✅ | `cmd:photo.label`, `cmd:dialog.labelNames` | shows custom names; Edit Label Names… | @@ -634,7 +634,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-QUIT | Quit — ⌘Q | P0 | ✅ | `cmd:app.quit` | ⌘Q (Ctrl+Q off macOS) | | KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) | | KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native File ▸ Close Window | -| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | | +| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete`, `cmd:album.removePhotos` | in a normal album ⌫ removes from the album only (`album.removePhotos`, issue #1805); in a smart album it does nothing; elsewhere it deletes (to Recently Deleted) | | KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | | KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..56af86c48 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-15 + +### Delete in an album removes from the album only +- In an album, the Delete key now takes the selected photos out of the album, as in Lightroom. It used to move them + to Recently Deleted, so they also left their folder's view (issue #1805). They stay in the library and in their + folder; Edit ▸ Undo puts them back in the album. Photo ▸ Remove from Album does the same and now shows the key. + To delete photos from the library from an album, use Photo ▸ Delete Photo or right-click ▸ Delete Photo. In a smart + album, Delete changes nothing: its rules decide what it shows. + ## 2026-10-14 ### Hard-edged Heal and Clone spots From 70a12af9a295e8ccef0fa389591dca4d665bef88 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:57:42 +0200 Subject: [PATCH 217/315] LR-IMP-LOCAL: browsing a Local folder keeps or picks the active photo (#1447) library.browse (engine and the UI's background read) switched the source to the folder without touching the selection, so the previous view's photo stayed active: Photo Grid marked nothing, and Detail, the histogram and the info showed a photo the folder doesn't hold. Browsing now follows library.source's rule through a shared Session::keep_active_in_view: the active photo stays when the folder shows it, else the folder's first photo becomes active (also as the background read's batches arrive). --- crates/engine/src/cmd/browse.rs | 1 + crates/engine/src/cmd/library.rs | 7 +- crates/engine/src/lib.rs | 15 ++++ crates/engine/src/tests_import.rs | 20 +++++ crates/ui-egui/src/import.rs | 7 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/tests_browse_active.rs | 95 +++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 7 ++ 9 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/tests_browse_active.rs diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14b..24883056f 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -45,6 +45,7 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> { stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders }); s.source = LibrarySource::Folder; + s.keep_active_in_view(); let shown = s.visible().len(); Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()})) } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..a36749f4f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -362,11 +362,8 @@ pub fn specs() -> Vec<CommandSpec> { } other => return Err(bad("library.source", format!("unknown source `{other}`"))), }; - let vis = s.visible_cloned(); - if s.selection.active.is_none_or(|a| !vis.contains(&a)) { - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); - } - Ok(json!({"count": vis.len()})) + s.keep_active_in_view(); + Ok(json!({"count": s.visible().len()})) } ), cmd!( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..e2cec710b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -955,6 +955,21 @@ impl Session { self.visible().to_vec() } + /// After the source changes, the active photo follows the rule every source shares: it stays + /// when the view shows it, else the view's first photo becomes the active (and only selected) + /// one, or nothing when the view is empty. A view never keeps an active photo it doesn't show + /// (issue #1447: a Local folder kept a library photo active, so Detail and the info showed a + /// photo the grid didn't). + pub fn keep_active_in_view(&mut self) { + let active = self.selection.active; + let vis = self.visible(); + if active.is_some_and(|a| vis.contains(&a)) { + return; + } + let first = vis.first().copied(); + self.selection = first.map(Selection::single).unwrap_or_default(); + } + /// The visible photos without copying them, with their generation: equal generations mean /// the very same list (same catalog revision, source, filter, sort), so a frontend can cache /// whatever it derives from the list under that number. diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..983958ea2 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -917,6 +917,26 @@ fn missing_photos_skip_local_browse_records() { assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } +/// `library.browse` follows `library.source`'s rule for the active photo (issue #1447): a library +/// photo the folder doesn't show gives way to the folder's first photo; one it shows stays. +#[test] +fn browsing_a_folder_keeps_or_picks_the_active_photo() { + let dir = temp_dir("browse-active"); + write_png(&dir.join("a.png"), 1); + write_png(&dir.join("b.png"), 2); + let mut s = Session::with_demo().with_fs(); + let library = s.visible_cloned()[0]; + s.execute("library.select", &json!({"ids": [library.0]})).unwrap(); + s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap(); + let vis = s.visible_cloned(); + assert_eq!(vis.len(), 2); + assert_eq!(s.selection.active, Some(vis[0]), "the folder's first photo, not {library:?}"); + s.execute("library.select", &json!({"ids": [vis[1].0]})).unwrap(); + s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap(); + assert_eq!(s.selection.active, Some(vis[1]), "a photo the folder shows stays active"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Import a file that is in Recently Deleted (issues #298, #706): by default the photo comes /// back with its edits; `onDeleted: skip` leaves it there but the report says it is in the trash; /// `onDeleted: fresh` replaces the trashed record with a new one. diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..aa3df8313 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -484,6 +484,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { // already reading this folder: clicking it again must not restart the progress app.session.source = lightcraft_engine::LibrarySource::Folder; + app.session.keep_active_in_view(); return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); } if let Some(t) = app.scan.take() { @@ -492,6 +493,8 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> app.import = None; app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); app.session.source = lightcraft_engine::LibrarySource::Folder; + // the photos the library already knows from this folder show at once: keep or pick the active one + app.session.keep_active_in_view(); let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); let progress = std::sync::Arc::new(ScanProgress::default()); let (tx, rx) = std::sync::mpsc::channel(); @@ -703,6 +706,10 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sel) = &task.keep_selection { app.session.selection = sel.clone(); } + // a Local folder still on screen: its first photo becomes active once there is one (#1447) + if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { + app.session.keep_active_in_view(); + } } task.left = Some((app.session.selection.clone(), app.session.source)); if let Some(run) = &task.run { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..566687518 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,8 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_browse_active; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; diff --git a/crates/ui-egui/src/tests_browse_active.rs b/crates/ui-egui/src/tests_browse_active.rs new file mode 100644 index 000000000..f593883ff --- /dev/null +++ b/crates/ui-egui/src/tests_browse_active.rs @@ -0,0 +1,95 @@ +//! Showing a Local folder picks the active photo the way every other source does (issue #1447): +//! the current one when the folder shows it, else the folder's first photo, so Photo Grid marks +//! it, the info and histogram describe it, and Detail opens on it instead of a photo from +//! another source. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::headless::Headless; +use crate::state::ViewMode; + +const T: Duration = Duration::from_secs(10); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.view = ViewMode::PhotoGrid; + Headless::new(app, [1300.0, 900.0], 1.0) +} + +fn exec(h: &mut Headless, command: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command} {params}: {r}"); + h.settle(SETTLE); + r +} + +/// A folder of `n` small PNGs (different bytes each) in the temporary folder. +fn folder(name: &str, n: u8) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + for i in 0..n { + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 40 * i, 255]); + std::fs::write(dir.join(format!("p{i}.png")), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + } + dir +} + +fn active(h: &Headless) -> Option<PhotoId> { + h.app.session.selection.active +} + +#[test] +fn browsing_a_local_folder_makes_its_first_photo_active() { + let mut h = demo(); + h.settle(SETTLE); + let dir = folder("browse-active", 3); + // a library photo is active: the folder doesn't show it + let before = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [before.0]})); + exec(&mut h, "library.browse", json!({"path": dir.to_string_lossy()})); + let vis = h.app.session.visible_cloned(); + assert_eq!(vis.len(), 3); + assert_eq!(active(&h), Some(vis[0]), "the folder's first photo is active, not a photo the grid doesn't show"); + assert_eq!(h.app.session.selection.ids, vec![vis[0]], "and selected"); + // the grid marks it, and Detail opens on it + assert!(h.app.widgets.iter().any(|(w, _)| *w == format!("thumb:{}", vis[0].0))); + h.request("ui.set", json!({"view": "detail"}), T); + h.settle(SETTLE); + assert_eq!(active(&h), Some(vis[0])); + + // the folder shows the current photo again: it stays active + h.request("ui.set", json!({"view": "photoGrid"}), T); + exec(&mut h, "library.select", json!({"ids": [vis[2].0]})); + exec(&mut h, "library.source", json!({"kind": "all"})); + assert_ne!(active(&h), Some(vis[2]), "All Photos doesn't list Local photos"); + exec(&mut h, "library.select", json!({"ids": [vis[2].0]})); + exec(&mut h, "library.browse", json!({"path": dir.to_string_lossy()})); + assert_eq!(active(&h), Some(vis[2]), "a photo the folder shows stays active"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An album behaves the same (it already did: kept here as the rule's reference). +#[test] +fn choosing_an_album_keeps_or_picks_the_active_photo() { + let mut h = demo(); + h.settle(SETTLE); + let album = h.app.session.catalog.albums().find(|a| !a.folder && !a.is_smart() && a.photos.len() > 1).map(|a| (a.id, a.photos.clone())); + let (album, photos) = album.expect("the demo library has an album"); + let outside = h.app.session.visible_cloned().into_iter().find(|p| !photos.contains(p)).expect("a photo outside the album"); + exec(&mut h, "library.select", json!({"ids": [outside.0]})); + exec(&mut h, "library.source", json!({"kind": "album", "id": album.0})); + let vis = h.app.session.visible_cloned(); + assert_eq!(active(&h), vis.first().copied()); + exec(&mut h, "library.select", json!({"ids": [vis[1].0]})); + exec(&mut h, "library.source", json!({"kind": "all"})); + exec(&mut h, "library.source", json!({"kind": "album", "id": album.0})); + assert_eq!(active(&h), Some(vis[1])); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..f7fae7f4b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -78,7 +78,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..d8a34a0ed 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-15 + +### Local folders select a photo +- Showing a Local folder now makes its first photo the active one, as albums and the other sources do (issue + #1447), or keeps the photo you had when the folder holds it. Before, the photo from the previous view stayed active: + Photo Grid marked nothing, and Detail, the histogram and the photo info showed a photo that isn't in the folder. + ## 2026-10-14 ### Hard-edged Heal and Clone spots From f6c20bc0cc2ecce12799b8c5cf46e8fb8fb4cfef Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:16:27 +0200 Subject: [PATCH 218/315] =?UTF-8?q?LR-LIB-REMOVE-ALBUM:=20translations=20u?= =?UTF-8?q?se=20=E2=86=92=20for=20the=20menu=20path,=20not=20=E2=96=B8=20(?= =?UTF-8?q?#1805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index d524ed5b6..33b0c90bd 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2119,5 +2119,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändern Sie ihre Regeln oder verwenden Sie Foto ▸ Foto löschen" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändern Sie ihre Regeln oder verwenden Sie Foto → Foto löschen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index aa0fb7105..b9104c4e1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1996,5 +1996,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto ▸ Eliminar foto" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index ae763e28c..58c72d9d2 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1958,5 +1958,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo ▸ Supprimer la photo" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ad7755842..a80472995 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1962,5 +1962,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 ▸ 写真を削除を使ってください" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 07d851014..a1d83a8c2 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1999,5 +1999,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto ▸ Excluir foto" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto → Excluir foto" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fa830f212..52fe8ef48 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2152,5 +2152,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото ▸ Удалить фото" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото → Удалить фото" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 5429ac1f8..1adc92d49 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2152,5 +2152,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото ▸ Видалити фото" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото → Видалити фото" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a5eb13049..0a0c424b5 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1998,5 +1998,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 ▸ 删除照片”" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 → 删除照片”" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index a7cc6c567..367f94416 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1960,5 +1960,5 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", - "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 ▸ 刪除照片」" + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 → 刪除照片」" } From ba98dc66c924de8e60068243f4033e960a981501 Mon Sep 17 00:00:00 2001 From: tecari0 <121329615+tecari0@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:06:07 -0700 Subject: [PATCH 219/315] Detail: snap the 100% view to whole screen pixels (#1683) At 100% the photo's corner could sit half a physical pixel off, for example in a 1279.5 pt canvas on a 2x display. The GPU then resampled the photo with its bilinear filter and blurred it. On a 5152x7728 JPEG the 100% view kept 68% of the fine detail. fit_rect now rounds the corner to a whole physical pixel when the photo is 1:1 with the screen. Other zooms resample anyway, and a pinch must not jump, so they stay as they were. With the fix the same view matches the original (71.4 dB PSNR, 100% of the detail). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/detail.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..ecbd7f1ea 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -122,7 +122,16 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], }; let pan = bounded_pan(area, vec2(w, h), pan); let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); - Rect::from_center_size(c, vec2(w, h)) + let rect = Rect::from_center_size(c, vec2(w, h)); + // At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole + // physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width + // canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump. + let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5; + if matches!(zoom, Zoom::Percent(_)) && one_to_one { + let snap = |v: f32| (v * ppp).round() / ppp; + return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size()); + } + rect } /// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can @@ -2406,6 +2415,22 @@ mod preview_geometry_tests { assert!(frame.contains_rect(image)); } + /// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear + /// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off). + #[test] + fn percent_zoom_lands_on_whole_physical_pixels() { + let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0)); + for ppp in [1.0, 1.25, 2.0, 3.0] { + for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] { + let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan); + for v in [image.min.x, image.min.y] { + assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}"); + } + assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels"); + } + } + } + #[test] fn portrait_preview_aspect_fits_detail_area_without_stretching() { let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); From e5c6e17bdb8a46a523e0f7a0701e968d17cfdfcb Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Wed, 14 Oct 2026 19:11:34 -0500 Subject: [PATCH 220/315] LR-MASK-BRUSH: Shift-click draws a straight brush stroke from the last one A Shift-click with the brush paints a straight line from where the last stroke ended, tapering size, feather and flow when they changed in between, as one undo step. mask.brushStroke takes connect: true for it. --- crates/engine/src/cmd/masks.rs | 70 +++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 38 ++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 7 ++- crates/ui-egui/src/tests_masking.rs | 26 +++++++++++ docs/parity-checklist.md | 5 ++- docs/whats-new.md | 6 +++ 6 files changed, 144 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 25ff3f24d..31470ede1 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -150,6 +150,49 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas Ok(json!({"activeMask": active})) } +/// Most pieces a tapered ⇧-click segment is cut into. +const MAX_TAPER_STEPS: usize = 64; + +/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment +/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the +/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their +/// maximum, so the pieces join without building up. +fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> { + let Some(&first) = to.points.first() else { return vec![to] }; + let same = |a: f64, b: f64| (a - b).abs() <= 1e-9; + if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) { + let mut points = Vec::with_capacity(to.points.len() + 1); + points.push(from); + points.extend(to.points.iter().copied()); + return vec![BrushStroke { points, ..to }]; + } + // about 5% of size, or 2 points of feather or flow, per piece + let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 }; + let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0] + .into_iter() + .filter(|v| v.is_finite()) + .fold(1.0f64, f64::max); + let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS); + let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t; + let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t)); + let mut out: Vec<BrushStroke> = (0..steps) + .map(|i| { + let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64); + BrushStroke { + points: vec![at(t0), at(t1)], + size: lerp(last.size, to.size, t1), + feather: lerp(last.feather, to.feather, t1), + flow: lerp(last.flow, to.flow, t1), + ..to.clone() + } + }) + .collect(); + if to.points.len() > 1 { + out.push(to); + } + out +} + fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> { p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) } @@ -230,7 +273,7 @@ pub fn specs() -> Vec<CommandSpec> { "Brush Stroke", [], None, - "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}", + "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}", has_active, |s, p| { let pts: Vec<Point> = p @@ -250,9 +293,11 @@ pub fn specs() -> Vec<CommandSpec> { erase: bool_or(p, "erase", false), auto_mask: bool_or(p, "autoMask", false), }; + let connect = bool_or(p, "connect", false); + let mut connected = false; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); - masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { + let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { Some(i) => i, None => { @@ -265,7 +310,20 @@ pub fn specs() -> Vec<CommandSpec> { if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) = m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. })) { - strokes.push(stroke); + // the previous stroke's end and its brush values (not its points) + let anchor = strokes.last().filter(|_| connect).and_then(|l| { + Some(( + *l.points.last()?, + BrushStroke { points: Vec::new(), size: l.size, feather: l.feather, flow: l.flow, ..BrushStroke::default() }, + )) + }); + match anchor { + Some((from, last)) => { + strokes.extend(connected_strokes(from, &last, stroke)); + connected = true; + } + None => strokes.push(stroke), + } } else { m.components.push(MaskComponent { name: None, @@ -275,7 +333,11 @@ pub fn specs() -> Vec<CommandSpec> { }); } Ok(()) - }) + })?; + if let Some(o) = out.as_object_mut() { + o.insert("connected".into(), json!(connected)); + } + Ok(out) } ), cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..fd534e370 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -280,6 +280,44 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +/// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight +/// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new +/// values when it changed, and a plain dab when there is nothing to connect to. +#[test] +fn connected_brush_strokes_start_where_the_last_one_ended() { + use lightcraft_develop::MaskShape; + use lightcraft_geom::Point; + let mut s = demo(); + let id = s.active().unwrap(); + let r = s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "connect": true})).unwrap(); + assert_eq!(r["connected"], false, "nothing to connect to: a dab"); + let strokes = |s: &Session| { + let d = s.develop_of(id).unwrap(); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + strokes.clone() + }; + assert_eq!(strokes(&s)[0].points.len(), 1); + let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap(); + assert_eq!(r["connected"], true); + let line = strokes(&s); + assert_eq!(line.len(), 2); + assert_eq!(line[1].points, vec![Point::new(0.2, 0.5), Point::new(0.6, 0.5)]); + // a bigger, softer brush: the segment tapers from the last stroke's values to the new ones + let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "feather": 80, "connect": true})).unwrap(); + assert_eq!(r["connected"], true); + let all = strokes(&s); + let taper = &all[2..]; + assert!(taper.len() > 4, "{} pieces", taper.len()); + assert_eq!(taper[0].points[0], Point::new(0.6, 0.5), "from the last stroke's end"); + assert_eq!(*taper.last().unwrap().points.last().unwrap(), Point::new(0.6, 0.9), "to the click"); + assert!(taper.windows(2).all(|w| w[1].size >= w[0].size && w[1].feather >= w[0].feather && w[0].points[1] == w[1].points[0])); + assert!((taper.last().unwrap().size - 0.04).abs() < 1e-12 && (taper.last().unwrap().feather - 80.0).abs() < 1e-9); + assert!(taper[0].size > 0.02 && taper[0].size < 0.04); + // all of it is one undo step + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(strokes(&s).len(), 2); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..23339445e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1991,11 +1991,14 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons app.brush_live = None; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt); + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; let _ = app.run( "mask.brushStroke", json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask}), + "erase": erase, "autoMask": app.ui.brush_auto_mask, "connect": connect}), ); } return; diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 43e53932d..b09082e6e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -343,6 +343,32 @@ fn holding_the_brush_still_does_not_harden_the_spot() { h.settle(SETTLE); } +/// A ⇧-click with the brush paints a straight line from where the last stroke ended (issue #106 of the Classic +/// fork), one undo step; a plain click only paints a dab where it lands. +#[test] +fn shift_click_paints_a_straight_line_from_the_last_stroke() { + let mut h = detail("panel.masking"); + exec(&mut h, "tool.brush", json!({})); + brush_at(&mut h, 0.2, 0.5); + brush_at(&mut h, 0.8, 0.5); + assert!(mask_coverage(&h, 0, 50, 50) < 0.01, "two plain clicks leave the space between them alone"); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.5, "y": 0.2}, {"kind": "up", "x": 0.5, "y": 0.2}], "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 3, "{strokes:?}"); + assert_eq!(strokes[2].points.len(), 2, "from the last stroke's end to the click: {strokes:?}"); + for (x, y) in [(65, 35), (58, 27)] { + assert!(mask_coverage(&h, 0, x, y) > 0.5, "the line from (0.8, 0.5) to (0.5, 0.2) passes ({x}, {y})"); + } + assert!(mask_coverage(&h, 0, 35, 35) < 0.01, "and nowhere else"); + exec(&mut h, "edit.undo", json!({})); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 2, "one undo step takes the line back"); + h.settle(SETTLE); +} + #[test] fn brush_strokes_carry_auto_mask() { let mut h = detail("panel.masking"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..1cb5f513a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-MASK-BRUSH: ⇧-click draws a straight stroke from the last one; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -292,7 +292,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | LR-MASK-BRUSH: ⇧-click with the brush draws a straight stroke from where the last stroke ended, tapering when the brush changed in between (`cmd:mask.brushStroke` `connect`); status unchanged | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..eab1353d7 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-14 +### Straight brush strokes with ⇧-click +- With the brush, click once, then ⇧-click somewhere else: the brush paints a straight line between the two, and + further ⇧-clicks carry on from the last point, as in Lightroom. Change the brush size, feather or flow before the + ⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step. + `mask.brushStroke {connect: true}` does the same for agents. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 206680fb22044773131d2cf361c6b013fc4f0a49 Mon Sep 17 00:00:00 2001 From: Alex Chambers <alexchambers91@gmail.com> Date: Thu, 15 Oct 2026 02:24:48 +0200 Subject: [PATCH 221/315] LR-VIEW-BEFOREAFTER: animate both panes during click zoom Translate the shared animated image rectangle into the Before pane for whole-frame drawing, cached window tiles and input mapping. Translate the final target separately so render requests stay stable during the motion. Cover intermediate mesh bounds and final geometry in both layouts, including clicks in either pane, return to Fit and cached region tiles. Fixes #1821 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/detail.rs | 16 +- crates/ui-egui/src/tests_before_after.rs | 182 ++++++++++++++++++++++- docs/parity-checklist.md | 5 +- 3 files changed, 190 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 22820ac6b..3681bb56c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -426,9 +426,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); + let in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); let mut navigating = false; for &view_area in &areas { - let view_img = if view_area == main_area { img_rect } else { fit_rect(view_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan) }; + let view_img = in_pane(view_area, img_rect); if navigate_gesture(app, ui, &resp, view_area, view_img, native) { navigating = true; target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); @@ -436,6 +437,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { break; } } + let before_img = in_pane(areas[0], img_rect); + let before_target = in_pane(areas[0], target_rect); app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); @@ -551,11 +554,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) let mut window_views: Vec<WindowView> = Vec::new(); if window_ctx.frame_edge.is_some() { - let before_rect = |r: Rect, area: Rect| WindowView { slot: Slot::RegionBefore, before: true, img: r, target: r, visible: area }; let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; if split { - let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); - window_views.push(before_rect(br, areas[0])); + window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: areas[0] }); window_views.push(after); } else if show_before { window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); @@ -612,7 +613,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if split { // each view stays in its pane: zoomed in, a photo reaches past it (and would cover the // other view's margins with its whole-frame render, magnified and blurred) - let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); + let br = before_img; draw(Slot::Before, br, areas[0]); shown = draw(Slot::Main, img_rect, main_area); // the labels under each photo, or under its pane when the photo reaches past it @@ -753,10 +754,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // A click-zoom there centres both views on the point clicked (#1742). let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); let (map, img, pane) = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => { - let br = fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan); - (CanvasMap::new(&frame, br), br, *before) - } + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), _ => (map, img_rect, main_area), }; general_interaction(app, ui, &resp, &map, img, pane, native, aspect) diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 8a89098fe..02ee879a1 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -1,10 +1,11 @@ -//! Headless tests of clicks in the two-view Before / After layouts. +//! Headless tests of input and zoom animation in the two-view Before / After layouts. use std::time::Duration; use serde_json::json; -use crate::headless::Headless; +use crate::headless::{Headless, RenderGate}; +use crate::render::Slot; use crate::state::{BeforeAfter, ViewMode, Zoom}; use crate::{LightcraftApp, Services}; @@ -109,6 +110,183 @@ fn a_click_in_the_before_view_zooms_in_on_the_point_clicked() { } } +/// A texture mesh in its pane, excluding the Navigator's copy of the Main texture. +fn pane_mesh(h: &Headless, slot: Slot, pane: egui::Rect) -> egui::Rect { + let tex = &h.app.renderer.textures[&slot]; + let meshes: Vec<_> = h + .view + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == tex.tex.id() && c.clip_rect == pane => Some(m.calc_bounds()), + _ => None, + }) + .collect(); + assert_eq!(meshes.len(), 1, "{slot:?}: one image clipped to {pane:?}, got {meshes:?}"); + meshes[0] +} + +fn assert_rect(actual: egui::Rect, expected: egui::Rect) { + assert!( + (actual.min - expected.min).abs().max_elem() < 1.0 && (actual.max - expected.max).abs().max_elem() < 1.0, + "image bounds {actual:?}, expected {expected:?}" + ); +} + +/// Start with both textures ready, then hold replacement renders so worker timing cannot hide +/// a frame of the transition or replace a cached tile under the assertions. +fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2], std::rc::Rc<std::cell::Cell<bool>>) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + h.app.ui.pan = (0.5, 0.5); + h.app.ui.click_zoom = 300; + assert!(h.settle(SETTLE), "baseline renders settled"); + let before_id = h.app.renderer.textures[&Slot::Before].tex.id(); + let before = h + .view + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == before_id => Some(c.clip_rect), + _ => None, + }) + .expect("Before image on screen"); + let canvas = h.app.canvas_rect.expect("canvas"); + let shift = 2.0 * (canvas.center() - before.center()); + let after = before.translate(shift); + assert!(canvas.contains_rect(before) && canvas.contains_rect(after), "both clips stay in the canvas"); + assert!(!before.intersects(after), "pane clips do not overlap"); + let open = std::rc::Rc::new(std::cell::Cell::new(false)); + h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone()))); + (h, [before, after], open) +} + +fn queue_click(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]); + while !h.app.synthetic.is_empty() { + h.step(); + } + assert!(h.app.ui.zoom_anim, "the click started an animation"); +} + +fn check_click_animation(mode: BeforeAfter) { + let (mut h, [before, after], open) = animation_view(mode, Zoom::Fit); + let shift = before.center() - after.center(); + let fit = h.app.image_rect.expect("Fit image"); + let id = h.app.session.active().expect("photo"); + let photo = h.app.session.catalog.photo(id).expect("photo"); + let native = [photo.width as usize, photo.height as usize]; + let aspect = native[0] as f32 / native[1] as f32; + // Exercise clicks in both panes, including their return to Fit, without settling the motion. + for before_click in [false, true] { + for zoom_in in [true, false] { + let pos = if zoom_in { fit.center() + egui::vec2(fit.width() * 0.3, fit.height() * -0.25) } else { after.center() }; + queue_click(&mut h, if before_click { pos + shift } else { pos }); + assert_eq!(h.app.ui.zoom, if zoom_in { Zoom::Percent(300.0) } else { Zoom::Fit }); + if zoom_in { + let pan = h.app.ui.pan; + assert!((pan.0 - 0.8).abs() < 0.02 && (pan.1 - 0.25).abs() < 0.02, "{mode:?}: clicked point {pan:?}"); + } + let target = crate::panels::detail::fit_rect(after, aspect, h.app.ui.zoom, native, 1.0, h.app.ui.pan); + let mut intermediate = 0; + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.insert((h.app.renderer.wanted(Slot::Before), h.app.renderer.wanted(Slot::Main))); + let img = h.app.image_rect.expect("animated image"); + let between = img.width() > fit.width() + 1.0 && img.width() < native[0] as f32 * 3.0 - 1.0; + if between { + intermediate += 1; + } + if between || !h.app.ui.zoom_anim { + let a = pane_mesh(&h, Slot::Main, after); + let b = pane_mesh(&h, Slot::Before, before); + assert_rect(b, a.translate(shift)); + } + } + assert!(intermediate > 0, "{mode:?}: inspected intermediate animation frames"); + assert!(!h.app.ui.zoom_anim, "animation finished"); + assert_rect(h.app.image_rect.expect("settled image"), target); + assert_rect(pane_mesh(&h, Slot::Main, after), crate::panels::detail::fit_texture_rect(target, h.app.renderer.textures[&Slot::Main].size)); + assert_eq!(keys.len(), 1, "both panes request stable final-size renders throughout the animation: {keys:?}"); + } + } + open.set(true); + assert!(h.settle(SETTLE)); +} + +#[test] +fn both_before_after_panes_use_the_animated_rect_side_by_side() { + check_click_animation(BeforeAfter::SideBySide); +} + +#[test] +fn both_before_after_panes_use_the_animated_rect_top_bottom() { + check_click_animation(BeforeAfter::TopBottom); +} + +fn check_region_animation(mode: BeforeAfter) { + let (mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0)); + let shift = before.center() - after.center(); + let start = h.app.image_rect.expect("200 percent image"); + let tiles = [(Slot::RegionBefore, before, true), (Slot::Region, after, false)].map(|(slot, pane, is_before)| { + let tex = &h.app.renderer.textures[&slot]; + let tile = h.app.region_tiles[&(is_before, tex.key)]; + pane_mesh(&h, slot, pane); + (slot, pane, tile, tex.key) + }); + h.app.ui.zoom = Zoom::Percent(400.0); + h.app.ui.zoom_anim = true; + let mut intermediate = 0; + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.insert((h.app.renderer.wanted(Slot::RegionBefore), h.app.renderer.wanted(Slot::Region))); + let img = h.app.image_rect.expect("animated image"); + if img.width() > start.width() + 1.0 && img.width() < start.width() * 2.0 - 1.0 { + intermediate += 1; + } + if img.width() <= start.width() + 1.0 { + continue; + } + for (slot, pane, tile, key) in tiles { + assert_eq!(h.app.renderer.textures[&slot].key, key, "the cached tile stays on screen while renders wait"); + let full = if tile.before { img.translate(shift) } else { img }; + let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); + let w = tile.window; + let expected = egui::Rect::from_min_size( + full.min + egui::vec2(w.x as f32 / fw * full.width(), w.y as f32 / fh * full.height()), + egui::vec2(w.w as f32 / fw * full.width(), w.h as f32 / fh * full.height()), + ); + assert_rect(pane_mesh(&h, slot, pane), expected); + } + } + assert!(intermediate > 0, "{mode:?}: inspected intermediate region frames"); + assert!(!h.app.ui.zoom_anim); + assert!((h.app.image_rect.expect("settled image").width() - start.width() * 2.0).abs() < 1.0); + assert_eq!(keys.len(), 1, "region requests use the final target throughout the animation: {keys:?}"); + open.set(true); + assert!(h.settle(SETTLE)); +} + +#[test] +fn before_after_region_tiles_use_the_animated_rect_side_by_side() { + check_region_animation(BeforeAfter::SideBySide); +} + +#[test] +fn before_after_region_tiles_use_the_animated_rect_top_bottom() { + check_region_animation(BeforeAfter::TopBottom); +} + /// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { let mut sum = [0.0f32; 3]; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..97bdc4b9c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** patch (LR-VIEW-BEFOREAFTER click-zoom animation, #1821) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | From 62da9e85819870e63b1183dc44f3878849cd1574 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Wed, 14 Oct 2026 20:24:53 -0400 Subject: [PATCH 222/315] LRC-LIB-LIBFOLDERS: drag photos from the grid onto a Folders row to move them there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos dragged from the grid onto a Folders row move there on disk and in the library (`photo.moveToFolder`: files and XMP sidecars, virtual copies follow, never overwrites, one undo step), as Move Selected Photos Here in the row's menu does. - The row lights up, and the label following the pointer says where they go: "Move 2 photos to “Porto”" (onto an album the same gesture only adds them, so the two read apart). - A closed folder held under the pointer for 0.7 s opens, so a folder inside it can be reached. - Refused, with the reason on release and a not-allowed pointer: a row that covers several disks, and Local photos (not in the library). Photos already there say "Already in “Porto”". - A drop asks first, as Lightroom Classic does: "Move 2 photos to “Porto”?" (their files and XMP sidecars move on disk; Edit → Undo moves them back), with Don't ask again, kept in Settings as "Confirm before moving photos to another folder on disk" (on by default). The menu's Move Selected Photos Here, chosen by name, goes at once. A question left open while another library opens (the menu bar and agents reach past a dialog) moves nothing: photo ids are per library. - The toast says how many moved and that Edit → Undo moves them back; the menu item says the same. - Photos that leave the view leave the selection, so a Delete or edit next can't reach them unseen. For NAS and network shares: the copy across volumes now streams (it read the whole file into memory, a problem for a large raw or video) into a free hidden name that takes the photo's name only once complete (a share dropping mid-copy left a truncated file under the photo's name, which a retry reported as the photo already there). New engine tests cover a share going away: Undo with the share offline or read-only is refused with the reason and kept until it can run, nothing is deleted and the library stays linked; a connection lost partway through a move puts back what moved; a file the share can't give back rolls the whole Undo back; and a real move across devices where the machine has a second file system (`/dev/shm`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/lib.rs | 2 + crates/engine/src/rename.rs | 31 +- crates/engine/src/tests_move_to_share.rs | 286 ++++++++++++++++ crates/ui-egui/locales/de-formats.json | 4 + crates/ui-egui/locales/de.json | 9 + crates/ui-egui/locales/es-formats.json | 4 + crates/ui-egui/locales/es.json | 9 + crates/ui-egui/locales/fr-formats.json | 4 + crates/ui-egui/locales/fr.json | 9 + crates/ui-egui/locales/ja-formats.json | 4 + crates/ui-egui/locales/ja.json | 9 + crates/ui-egui/locales/pt-br-formats.json | 4 + crates/ui-egui/locales/pt-br.json | 9 + crates/ui-egui/locales/ru-formats.json | 4 + crates/ui-egui/locales/ru.json | 9 + crates/ui-egui/locales/uk-formats.json | 4 + crates/ui-egui/locales/uk.json | 9 + crates/ui-egui/locales/zh-hans-formats.json | 4 + crates/ui-egui/locales/zh-hans.json | 9 + crates/ui-egui/locales/zh-hant-formats.json | 4 + crates/ui-egui/locales/zh-hant.json | 9 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 34 ++ crates/ui-egui/src/panels/grid.rs | 18 +- crates/ui-egui/src/panels/left.rs | 134 +++++++- crates/ui-egui/src/panels/settings.rs | 1 + crates/ui-egui/src/state.rs | 15 + crates/ui-egui/src/tests_folder_drop.rs | 343 ++++++++++++++++++++ docs/parity-checklist.md | 2 +- 29 files changed, 969 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/tests_move_to_share.rs create mode 100644 crates/ui-egui/src/tests_folder_drop.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..8c8af3a08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1036,6 +1036,8 @@ mod tests_lightroom_folder; #[cfg(test)] mod tests_merge; #[cfg(test)] +mod tests_move_to_share; +#[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index e49355c08..80f9624c0 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -416,12 +416,31 @@ impl MoveFs for RealFs { } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - use std::io::Write; - let bytes = std::fs::read(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?; - f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| { - let _ = std::fs::remove_file(b); - }) + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed } fn remove_file(&self, p: &Path) -> std::io::Result<()> { diff --git a/crates/engine/src/tests_move_to_share.rs b/crates/engine/src/tests_move_to_share.rs new file mode 100644 index 000000000..ed824b29d --- /dev/null +++ b/crates/engine/src/tests_move_to_share.rs @@ -0,0 +1,286 @@ +//! Moving photos to a folder on a NAS or network share (`photo.moveToFolder`, which dropping them +//! on a Folders row runs), where the share can go away between the move and its undo: +//! +//! * The share offline when Undo runs: Undo is refused with the reason and keeps the step; +//! nothing is deleted, the library still points at the files where they are, and once the share +//! is back the same Undo moves them home. +//! * The share read-only when Undo runs: the same. +//! * The connection lost partway through a move: the files already moved go back, the library is +//! unchanged and no history step is left. +//! * One file the share can't give back during Undo: the files already brought back go out again, +//! so every photo points at its file and the step can run once the share answers. +//! * Across devices (a share is another volume): the file is copied, checked and only then +//! removed; the copy goes to a hidden name and takes the photo's name only once complete. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-share-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("local")).unwrap(); + std::fs::create_dir_all(dir.join("share")).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn add_file(s: &mut Session, path: &str, body: &str) -> PhotoId { + std::fs::write(path, body).unwrap(); + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn file_of(s: &Session, id: PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +/// Two photos on the local disk moved to the share. +fn moved_to_share(dir: &Scratch) -> (Session, PhotoId, PhotoId) { + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), "A"); + std::fs::write(dir.path("local/a.xmp"), "<edits/>").unwrap(); + let b = add_file(&mut s, &dir.path("local/b.jpg"), "B"); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("share")})).unwrap(); + assert_eq!(r["moved"], 2); + (s, a, b) +} + +#[test] +fn undo_with_the_share_offline_is_refused_keeps_the_step_and_works_once_it_is_back() { + let dir = Scratch::new("offline"); + let (mut s, a, b) = moved_to_share(&dir); + let steps = s.undo.len(); + // the share goes offline: its folder is no longer there + let away = dir.0.join("share-unmounted"); + std::fs::rename(dir.0.join("share"), &away).unwrap(); + let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "says why: {e}"); + assert_eq!(s.undo.len(), steps, "the step stays, to undo once the share is back"); + // nothing deleted, nothing half-moved, and the library still points at the share + assert!(away.join("a.jpg").is_file() && away.join("a.xmp").is_file() && away.join("b.jpg").is_file()); + assert!(!dir.0.join("local/a.jpg").exists() && !dir.0.join("local/b.jpg").exists()); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("share/b.jpg")); + // the share is back: the same Undo moves them home, sidecar and all + std::fs::rename(&away, dir.0.join("share")).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(std::fs::read_to_string(dir.path("local/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("local/a.xmp").is_file() && dir.0.join("local/b.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("local/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("local/b.jpg")); +} + +#[cfg(unix)] +#[test] +fn undo_from_a_read_only_share_is_refused_and_deletes_nothing() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("readonly"); + let (mut s, a, _) = moved_to_share(&dir); + let steps = s.undo.len(); + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("share/probe"), "x").is_ok(); + if !root { + assert!(s.execute("edit.undo", &json!({})).is_err()); + assert_eq!(s.undo.len(), steps, "the step stays"); + assert!(dir.0.join("share/a.jpg").is_file() && dir.0.join("share/b.jpg").is_file(), "nothing deleted"); + assert!(!dir.0.join("local/a.jpg").exists(), "no second copy left behind"); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + } + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o755)).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(dir.0.join("local/a.jpg").is_file()); +} + +/// The model volume spells paths with `/`; the code under test joins them with the host's separator. +fn host(path: &str) -> String { + path.replace('/', std::path::MAIN_SEPARATOR_STR) +} + +fn model_session(fs_files: &[&str]) -> (Session, Vec<PhotoId>) { + let mut s = Session::new(); + let ids = fs_files + .iter() + .map(|path| { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: host(path) }, &name, "JPEG", 1, 1, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }) + .collect(); + (s, ids) +} + +fn plans(s: &Session, ids: &[PhotoId], into: &str) -> Vec<crate::rename::RenamePlan> { + ids.iter() + .map(|id| { + let from = file_of(s, *id); + let name = std::path::Path::new(&from).file_name().unwrap().to_string_lossy().to_string(); + crate::rename::RenamePlan { + id: id.0, + from: name.clone(), + to: name.clone(), + from_path: Some(from), + to_path: Some(host(&format!("{into}/{name}"))), + } + }) + .collect() +} + +/// Every photo points at a file the model volume holds (or, for `offline`, one it held). +fn assert_linked(s: &Session, fs: &crate::rename::tests::FakeFs, offline: &[&str]) { + let files = fs.listing(); + for p in s.catalog.photos() { + let Source::File { path } = &p.source else { continue }; + let path = path.replace('\\', "/"); + assert!( + files.iter().any(|(f, _)| *f == path) || offline.contains(&path.as_str()), + "photo {} points at {path}, which isn't there: {files:?}", + p.id.0 + ); + } +} + +#[test] +fn a_connection_lost_partway_through_a_move_puts_back_what_moved() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B"), ("/local/c.jpg", "C")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg", "/local/c.jpg"]); + let plans = plans(&s, &ids, "/share"); + // the share stops answering when the second file goes + fs.fail.borrow_mut().push("/local/b.jpg".into()); + let steps = s.undo.len(); + assert!(s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).is_err()); + assert_eq!( + fs.listing(), + vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into()), ("/local/c.jpg".into(), "C".into())], + "all back home" + ); + assert_eq!(s.undo.len(), steps, "no step for a move that didn't happen"); + assert_linked(&s, &fs, &[]); +} + +#[test] +fn a_file_the_share_cannot_give_back_rolls_the_whole_undo_back() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg"]); + let plans = plans(&s, &ids, "/share"); + s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).unwrap(); + assert_eq!(fs.listing(), vec![("/share/a.jpg".into(), "A".into()), ("/share/b.jpg".into(), "B".into())]); + // b can't be read back: the share answers for a but not for b + fs.fail.borrow_mut().push("/share/b.jpg".into()); + let e = s.undo_step_with(&fs).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "{e}"); + // a came back before b failed: it went out again, so the step is whole and every photo + // points at its file, none lost + assert_linked(&s, &fs, &[]); + let contents: Vec<String> = fs.listing().into_iter().map(|(_, c)| c).collect(); + assert_eq!(contents, vec!["A".to_string(), "B".to_string()], "both files still exist once: {:?}", fs.listing()); + // the share answers again: undoing whatever is left brings both home + fs.fail.borrow_mut().clear(); + while s.undo_step_with(&fs).is_ok() && fs.listing().iter().any(|(p, _)| p.starts_with("/share")) {} + assert_eq!(fs.listing(), vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into())]); + assert_linked(&s, &fs, &[]); +} + +/// Where this machine has a second file system to write to (Linux's `/dev/shm`), a real move +/// across devices: copied, checked, the original removed, and Undo copies it back. Skipped where +/// there is none (a network share can't be mounted in a test). +#[test] +fn a_move_across_devices_copies_checks_and_removes_and_undoes_the_same_way() { + let dir = Scratch::new("devices"); + let Some(other) = other_device(&dir.0) else { return }; + let share = other.join(format!("lc-share-other-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&share); + std::fs::create_dir_all(&share).unwrap(); + let mut s = Session::new(); + let body: String = (0..300_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), &body); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": share.to_string_lossy()})); + let moved = + r.is_ok() && std::fs::read_to_string(share.join("a.jpg")).ok().as_deref() == Some(body.as_str()) && !dir.0.join("local/a.jpg").exists(); + let undone = moved + && s.execute("edit.undo", &json!({})).is_ok() + && std::fs::read_to_string(dir.0.join("local/a.jpg")).ok().as_deref() == Some(body.as_str()); + let gone_from_share = !share.join("a.jpg").exists(); + let _ = std::fs::remove_dir_all(&share); + assert!(moved, "moved across devices: {r:?}"); + assert!(undone, "and back"); + assert!(gone_from_share, "no copy left on the other device"); +} + +/// A writable folder on another device than `here`, if there is one. +fn other_device(here: &std::path::Path) -> Option<std::path::PathBuf> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let dev = std::fs::metadata(here).ok()?.dev(); + ["/dev/shm", "/run/shm"] + .iter() + .map(std::path::PathBuf::from) + .find(|p| { + std::fs::metadata(p).is_ok_and(|m| m.dev() != dev) + && std::fs::write(p.join(format!(".lc-probe-{}", std::process::id())), b"x").is_ok() + }) + .inspect(|p| { + let _ = std::fs::remove_file(p.join(format!(".lc-probe-{}", std::process::id()))); + }) + } + #[cfg(not(unix))] + { + let _ = here; + None + } +} + +#[test] +fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() { + use crate::rename::MoveFs; + let dir = Scratch::new("copy"); + let body: Vec<u8> = (0..(3usize << 20) + 17).map(|i| (i % 251) as u8).collect(); + let (from, to) = (dir.0.join("local/big.raw"), dir.0.join("share/big.raw")); + std::fs::write(&from, &body).unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &to).unwrap(); + assert_eq!(std::fs::read(&to).unwrap(), body); + // nothing left under a hidden name beside it + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from("big.raw")], "{left:?}"); + // a partial left by a copy the share cut off doesn't block the next copy + let stale = dir.0.join(format!("share/.again.raw.lc-partial-{}-0", std::process::id())); + std::fs::write(&stale, b"cut off").unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &dir.0.join("share/again.raw")).unwrap(); + assert_eq!(std::fs::read(dir.0.join("share/again.raw")).unwrap(), body); + std::fs::remove_file(&stale).unwrap(); + std::fs::remove_file(dir.0.join("share/again.raw")).unwrap(); + // a copy that can't start (its source unreadable) leaves nothing at all under the photo's name + let gone = dir.0.join("local/missing.raw"); + assert!(crate::rename::RealFs.copy_no_replace(&gone, &dir.0.join("share/missing.raw")).is_err()); + assert!(!dir.0.join("share/missing.raw").exists()); + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left.len(), 1, "no partial file left: {left:?}"); + // a name taken is never replaced, and what is there stays + std::fs::write(&to, b"other").unwrap(); + assert!(crate::rename::RealFs.copy_no_replace(&from, &to).is_err()); + assert_eq!(std::fs::read(&to).unwrap(), b"other"); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..3c61bb5e7 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Alle {n} ausgewählten Fotos werden um denselben Betrag verschoben.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Bereits in der Bibliothek unter „Zuletzt gelöscht“: {} Foto{} ausgewählt · Rechtsklick → Wiederherstellen, oder endgültig löschen, um neu zu importieren", "Already in the library: {} photo{} selected in All Photos": "Bereits in der Bibliothek: {} Foto{} in „Alle Fotos“ ausgewählt", + "Already in “{}”": "Bereits in „{}“", "Auto ({})": "Automatisch ({})", "Auto Import: added {} photo{}": "Automatischer Import: {} Foto{} hinzugefügt", "Auto Sync: edits apply to {n} photos": "Automatische Synchronisierung: Bearbeitungen gelten für {n} Fotos", @@ -61,6 +62,9 @@ "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", "Import failed: {e}": "Import fehlgeschlagen: {e}", "Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}", + "Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben", + "Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..6276cce40 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -53,6 +53,9 @@ "Add to Favorites": "Zu Favoriten hinzufügen", "Add to Local": "Zu „Lokal“ hinzufügen", "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", + "Can't be moved here": "Hierher nicht verschiebbar", + "This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben", "Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen", "Add to Target Album": "Zu Zielalbum hinzufügen", "Add to this mask": "Zu dieser Maske hinzufügen", @@ -288,6 +291,12 @@ "Concrete Cool": "Kühler Beton", "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", + "Move Photos": "Fotos verschieben", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Don't ask again": "Nicht mehr fragen", + "Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein", + "Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen", "Constrain Crop": "Zuschnitt beschränken", "Continue": "Fortfahren", "Continue Without Saving": "Ohne Speichern fortfahren", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..bb053c0ac 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Las {n} fotos seleccionadas se desplazan la misma cantidad.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Ya en la biblioteca, en Eliminadas recientemente: {0} foto{1} seleccionada{1} · clic derecho → Restaurar, o Eliminar definitivamente para importar de nuevo", "Already in the library: {} photo{} selected in All Photos": "Ya en la biblioteca: {0} foto{1} seleccionada{1} en Todas las fotos", + "Already in “{}”": "Ya está en “{}”", "Auto ({})": "Automático ({})", "Auto Import: added {} photo{}": "Importación automática: {0} foto{1} añadida{1}", "Auto Sync: edits apply to {n} photos": "Sincronización automática: las ediciones se aplican a {n} fotos", @@ -61,6 +62,9 @@ "Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}", "Import failed: {e}": "Error de importación: {e}", "Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”", + "Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..31f39c747 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -38,6 +38,9 @@ "Add to Favorites": "Añadir a Favoritos", "Add to Local": "Añadir a Local", "Add to My Photos": "Añadir a Mis fotos", + "Can't be moved here": "No se pueden mover aquí", + "This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas", "Add to Selected Photos": "Añadir a las fotos seleccionadas", "Add to Target Album": "Añadir al álbum de destino", "Add to this mask": "Añadir a esta máscara", @@ -268,6 +271,12 @@ "Concrete Cool": "Hormigón frío", "Confirm before deleting": "Confirmar antes de eliminar", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Sus archivos y archivos XMP auxiliares se mueven en el disco. Editar → Deshacer los devuelve.", + "Don't ask again": "No volver a preguntar", + "Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco", "Constrain Crop": "Restringir recorte", "Continue": "Continuar", "Continue Without Saving": "Continuar sin guardar", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..89c3d0a4a 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Les {n} photos sélectionnées se déplacent de la même valeur.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Déjà dans la bibliothèque, dans « Supprimés récemment » : {} photo{} sélectionnée(s) · clic droit → Restaurer, ou Supprimer définitivement pour importer à nouveau", "Already in the library: {} photo{} selected in All Photos": "Déjà dans la bibliothèque : {} photo{} sélectionnée(s) dans Toutes les photos", + "Already in “{}”": "Déjà dans « {} »", "Auto ({})": "Automatique ({})", "Auto Import: added {} photo{}": "Importation automatique : {} photo{} ajoutée(s)", "Auto Sync: edits apply to {n} photos": "Synchronisation automatique : les modifications s’appliquent à {n} photos", @@ -65,6 +66,9 @@ "Imported {n} preset{}": "{n} préréglage{} importé(s)", "Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)", + "Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »", + "Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Le profil XMP « {profile} » n’a pas été appliqué à {path} ; les couleurs peuvent différer de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La géométrie Lightroom de {path} n’a pas été conservée ; {mode} sera recalculé.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée est ignorée, car Upright est désactivé.", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..909918d11 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -38,6 +38,9 @@ "Add to Favorites": "Ajouter aux favoris", "Add to Local": "Ajouter à Local", "Add to My Photos": "Ajouter à Mes photos", + "Can't be moved here": "Impossible de les déplacer ici", + "This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les", "Add to Selected Photos": "Ajouter aux photos sélectionnées", "Add to Target Album": "Ajouter à l'album cible", "Add to this mask": "Ajouter à ce masque", @@ -262,6 +265,12 @@ "Concrete Cool": "Béton froid", "Confirm before deleting": "Confirmer avant de supprimer", "Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment", + "Move Photos": "Déplacer les photos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Leurs fichiers et fichiers annexes XMP sont déplacés sur le disque. Modifier → Annuler les remet en place.", + "Don't ask again": "Ne plus demander", + "Another library was opened since the photos were dropped: nothing was moved": "Une autre bibliothèque a été ouverte depuis le dépôt des photos : rien n’a été déplacé", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Réglages → Confirmer avant de déplacer des photos vers un autre dossier sur le disque le réactive", + "Confirm before moving photos to another folder on disk": "Confirmer avant de déplacer des photos vers un autre dossier sur le disque", "Constrain Crop": "Contraindre le recadrage", "Continue": "Continuer", "Continue Without Saving": "Continuer sans enregistrer", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..a27c45666 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -13,6 +13,7 @@ "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", + "Already in “{}”": "すでに「{}」にあります", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", "Automatic ({} MB)": "自動({}MB)", "Click {}:1": "クリック時 {}:1", @@ -39,7 +40,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}", "Matches: {}": "一致条件:{}", "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "{n}枚を「{1}」に移動{0:.0}", + "Move {n} photo{} to “{}”?": "{n}枚を「{1}」に移動しますか?{0:.0}", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}", "Named ({named_n})": "名前付き({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)", "Preset: {name}": "プリセット:{name}", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..fa5e8d38c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -39,6 +39,9 @@ "Add to Favorites": "お気に入りに追加", "Add to Local": "ローカルに追加", "Add to My Photos": "マイフォトに追加", + "Can't be moved here": "ここには移動できません", + "This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください", "Add to Selected Photos": "選択した写真に追加", "Add to Target Album": "追加先アルバムに追加", "Add to this mask": "このマスクに追加", @@ -258,6 +261,12 @@ "Concrete Cool": "クールコンクリート", "Confirm before deleting": "削除前に確認", "Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認", + "Move Photos": "写真を移動", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "ファイルと XMP サイドカーがディスク上で移動します。編集 → 取り消す で元に戻せます。", + "Don't ask again": "今後確認しない", + "Another library was opened since the photos were dropped: nothing was moved": "写真をドロップした後に別のライブラリが開かれました。何も移動していません", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「ディスク上の別のフォルダに移動する前に確認」で元に戻せます", + "Confirm before moving photos to another folder on disk": "ディスク上の別のフォルダに移動する前に確認", "Constrain Crop": "切り抜きを制限", "Continue": "続行", "Contrast": "コントラスト", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..ab97f7ec5 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -17,6 +17,7 @@ "All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {0} foto{1} selecionada{1} · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo", "Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {0} foto{1} selecionada{1} em Todas as fotos", + "Already in “{}”": "Já está em “{}”", "Auto ({})": "Automático ({})", "Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos", "Automatic ({} MB)": "Automático ({} MB)", @@ -56,7 +57,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}", "Matches: {}": "Correspondências: {}", "Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} para “{1}”", + "Move {n} photo{} to “{}”?": "Mover {n} foto{0} para “{1}”?", "Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.", "Named ({named_n})": "Nomeadas ({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados", "Preset: {name}": "Predefinição: {name}", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..3d577d9b1 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -40,6 +40,9 @@ "Add to Favorites": "Adicionar aos favoritos", "Add to Local": "Adicionar a Local", "Add to My Photos": "Adicionar às Minhas fotos", + "Can't be moved here": "Não podem ser movidas para cá", + "This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as", "Add to Selected Photos": "Adicionar às fotos selecionadas", "Add to Target Album": "Adicionar ao álbum de destino", "Add to this mask": "Adicionar a esta máscara", @@ -270,6 +273,12 @@ "Concrete Cool": "Concreto frio", "Confirm before deleting": "Confirmar antes de excluir", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Os arquivos e os arquivos XMP auxiliares são movidos no disco. Editar → Desfazer os traz de volta.", + "Don't ask again": "Não perguntar novamente", + "Another library was opened since the photos were dropped: nothing was moved": "Outra biblioteca foi aberta depois que as fotos foram soltas: nada foi movido", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos para outra pasta no disco o reativa", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos para outra pasta no disco", "Constrain Crop": "Restringir recorte", "Continue": "Continuar", "Continue Without Saving": "Continuar sem salvar", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..27170aa6c 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -29,6 +29,7 @@ "All {n} selected photos move by the same amount.": "Все выбранные фото ({n}) сдвигаются на одно и то же значение.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в библиотеке, в «Недавно удалённых»: выбрано фото: {}{:.0} · щёлкните правой кнопкой → Восстановить или Удалить навсегда, чтобы импортировать заново", "Already in the library: {} photo{} selected in All Photos": "Уже в библиотеке: выбрано фото в разделе «Все фото»: {}{:.0}", + "Already in “{}”": "Уже в «{}»", "Auto ({})": "Авто ({})", "Auto Import: added {} photo{}": "Автоимпорт: добавлено фото: {}{:.0}", "Auto Sync: edits apply to {n} photos": "Автосинхронизация: правки применяются к фото: {n}", @@ -64,6 +65,9 @@ "Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}", "Import failed: {e}": "Ошибка импорта: {e}", "Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}", + "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрия Lightroom для {path} не сохранена; режим {mode} будет пересчитан.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование игнорируется, поскольку Upright выключен.", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..b6f7a2e11 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -23,6 +23,9 @@ "Add to Album": "Добавить в альбом", "Add to Favorites": "Добавить в избранное", "Add to My Photos": "Добавить в «Мои фото»", + "Can't be moved here": "Сюда переместить нельзя", + "This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте", "Add to Selected Photos": "Добавить к выбранным фото", "Add to Target Album": "Добавить в целевой альбом", "Add to this mask": "Добавить к этой маске", @@ -221,6 +224,12 @@ "Concrete Cool": "Холодный бетон", "Confirm before deleting": "Подтверждать удаление", "Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»", + "Move Photos": "Перемещение фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Их файлы и XMP-файлы перемещаются на диске. Правка → Отменить вернёт их.", + "Don't ask again": "Больше не спрашивать", + "Another library was opened since the photos were dropped: nothing was moved": "После перетаскивания фото была открыта другая библиотека: ничего не перемещено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Настройки → «Подтверждать перемещение фото в другую папку на диске» включает его снова", + "Confirm before moving photos to another folder on disk": "Подтверждать перемещение фото в другую папку на диске", "Constrain Crop": "Ограничить кадрирование", "Continue Without Saving": "Продолжить без сохранения", "Contrast": "Контрастность", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..91eaeacc3 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -27,6 +27,7 @@ "All {n} selected photos move by the same amount.": "Усі вибрані фото ({n}) зміщуються на однакове значення.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в бібліотеці, у нещодавно видалених: вибрано фото: {}{:.0} · права кнопка → Відновити або Видалити назавжди, щоб імпортувати заново", "Already in the library: {} photo{} selected in All Photos": "Уже в бібліотеці: вибрано фото в усіх фото: {}{:.0}", + "Already in “{}”": "Уже в «{}»", "Auto ({})": "Автоматично ({})", "Auto Import: added {} photo{}": "Автоімпорт: додано фото: {}{:.0}", "Auto Sync: edits apply to {n} photos": "Автосинхронізація: зміни застосовуються до фото: {n}", @@ -65,6 +66,9 @@ "Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}", "Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}", "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)", + "Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профіль XMP «{profile}» не застосовано до {path}; кольори можуть відрізнятися від Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрію Lightroom для {path} не збережено; режим {mode} буде перераховано.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрію Lightroom для {path} не збережено; збережене перетворення ігнорується, оскільки Upright вимкнено.", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..5b1c7443f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -376,6 +376,9 @@ "Add to Favorites": "Додати до улюблених", "Add to Local": "Додати до локальних", "Add to My Photos": "Додати до моїх фото", + "Can't be moved here": "Сюди перемістити не можна", + "This row covers more than one disk: drop on a folder inside it": "Цей рядок охоплює кілька дисків: перетягніть на теку всередині нього", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальних фото ще немає в бібліотеці: спершу «Додати до моїх фото», потім переміщуйте", "Add to Selected Photos": "Додати до вибраних фото", "Add to Target Album": "Додати до цільового альбому", "Add to this mask": "Додати до цієї маски", @@ -547,6 +550,12 @@ "Concrete Cool": "Холодний бетон", "Confirm before deleting": "Підтверджувати перед видаленням", "Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених", + "Move Photos": "Переміщення фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Їхні файли та XMP-файли переміщуються на диску. Редагування → Скасувати поверне їх.", + "Don't ask again": "Більше не питати", + "Another library was opened since the photos were dropped: nothing was moved": "Після перетягування фото було відкрито іншу бібліотеку: нічого не переміщено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Налаштування → «Підтверджувати перед переміщенням фото до іншої теки на диску» вмикає його знову", + "Confirm before moving photos to another folder on disk": "Підтверджувати перед переміщенням фото до іншої теки на диску", "Constrain Crop": "Обмежити кадрування", "Continue": "Продовжити", "Convert to DNG": "Перетворити на DNG", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..ae4d75cd4 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -13,6 +13,7 @@ "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Already in “{}”": "已在“{}”中", "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", "Automatic ({} MB)": "自动({0} MB)", "Click {}:1": "单击 {0}:1", @@ -39,7 +40,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}", "Matches: {}": "匹配项:{0}", "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", + "Move {n} photo{} to “{}”": "将 {n} 张照片移到“{1}”{0:.0}", + "Move {n} photo{} to “{}”?": "将 {n} 张照片移到“{1}”?{0:.0}", "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", "Preset: {name}": "预设:{name}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..41f470bb1 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -40,6 +40,9 @@ "Add to Favorites": "添加到收藏夹", "Add to Local": "添加到本地", "Add to My Photos": "添加到我的照片", + "Can't be moved here": "无法移到这里", + "This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动", "Add to Selected Photos": "添加到所选照片", "Add to Target Album": "添加到目标相册", "Add to this mask": "添加到此蒙版", @@ -268,6 +271,12 @@ "Concrete Cool": "冷调混凝土", "Confirm before deleting": "删除前确认", "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Move Photos": "移动照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它们的文件和 XMP 附属文件会在磁盘上移动。编辑 → 撤销 可移回。", + "Don't ask again": "不再询问", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片后打开了另一个图库:未移动任何内容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "设置 → “将照片移到磁盘上其他文件夹前确认”可重新开启", + "Confirm before moving photos to another folder on disk": "将照片移到磁盘上其他文件夹前确认", "Constrain Crop": "限制裁剪", "Continue": "继续", "Continue Without Saving": "不保存继续", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..6adf54e47 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -14,6 +14,7 @@ "Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}", "Adding photos… {} of {}": "正在新增照片… {}/{}", "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。", + "Already in “{}”": "已在「{}」中", "Auto ({})": "自動({})", "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", "Automatic ({} MB)": "自動({} MB)", @@ -53,7 +54,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}", "Matches: {}": "符合條件:{}", "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "將 {n} 張照片移到「{1}」{0:.0}", + "Move {n} photo{} to “{}”?": "將 {n} 張照片移到「{1}」?{0:.0}", "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定", "Preset: {name}": "預設集:{name}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..b08c03662 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -40,6 +40,9 @@ "Add to Favorites": "加入喜好項目", "Add to Local": "加入本機", "Add to My Photos": "加入我的照片", + "Can't be moved here": "無法移到這裡", + "This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動", "Add to Selected Photos": "加入選取的照片", "Add to Target Album": "加入目標相簿", "Add to this mask": "加入此遮色片", @@ -268,6 +271,12 @@ "Concrete Cool": "冷調混凝土", "Confirm before deleting": "刪除前確認", "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", + "Move Photos": "移動照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。", + "Don't ask again": "不再詢問", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟", + "Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認", "Constrain Crop": "限制裁切範圍", "Continue": "繼續", "Continue Without Saving": "繼續使用但不儲存", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..a84eb6c15 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,8 @@ mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_folder_drop; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..fe13ec8b4 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -254,6 +254,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", + Dialog::MovePhotos { .. } => "Move Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", @@ -1118,6 +1119,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } + Dialog::MovePhotos { ids, name, dont_ask, .. } => { + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui.ctx(), "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); + } d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), Dialog::LightroomCatalog { folder, catalog } => { ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); @@ -1234,6 +1244,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::MovePhotos { .. } => "Move", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1322,6 +1333,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::panels::grid::toast_skipped(app, ctx, &r); close = true; } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = &dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + close = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + close = true; + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1722,6 +1745,17 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso Dialog::Merge { opts } => crate::merge::start_final(app, opts), Dialog::Import { opts } => crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { + // another library opened meanwhile (from the menu bar, an agent): these numbers are + // other photos there + if *library != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); + } + if *dont_ask { + app.ui.settings.ask_before_move = false; + } + crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) + } // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it Dialog::LightroomCatalog { catalog, .. } => { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..216c36f61 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -856,6 +856,13 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { /// While photos are dragged: a badge at the pointer; the drag ends when the button is up /// (drop targets act on the release frame, before this runs). +/// What the label following dragged photos says this frame instead of how many there are: where a +/// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]). +pub fn drag_hint(ctx: &egui::Context, text: String) { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text))); +} + pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(ids) = &app.ui.dragging_photos else { return }; let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); @@ -867,13 +874,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let n = ids.len(); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + // a drop target under the pointer says where they would go (only this frame's) + let frame = ctx.cumulative_frame_nr(); + let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text); + let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)); egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)) - .color(Color32::WHITE) - .font(t.semibold(12.0)), - ); + let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend()); + crate::widgets::register(ui.ctx(), "dragPhotos", label.rect); }); }); } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..495b99b02 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1771,6 +1771,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s }; let mark = n.label.map(crate::theme::label_color); let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); @@ -1792,6 +1793,134 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s row_menu(app, &resp, n); } +/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it +/// can be reached. +const SPRING_OPEN_SECONDS: f64 = 0.7; + +/// A Folders row while photos are dragged from the grid: a release moves their files there on +/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where +/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, +/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, +/// not in the library) and a release says why; photos already there say so. +fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { + return; + } + spring_open(ui, n, open_id, open); + let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); + let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); + let refused = if !n.selectable { + Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) + } else if photos.iter().any(|p| p.local) { + Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) + } else { + None + }; + let here = |p: &lightcraft_catalog::Photo| match &p.source { + lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), + _ => false, + }; + let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); + let count = photos.len(); + let released = ui.input(|i| i.pointer.any_released()); + let hint = match (&refused, all_here) { + (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), + (None, true) => crate::i18n::tr_format!("Already in “{}”", name), + (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), + }; + super::grid::drag_hint(ui.ctx(), hint); + if let Some(why) = refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), why); + app.ui.dragging_photos = None; + } + return; + } + if all_here { + if released { + app.ui.dragging_photos = None; + } + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + app.ui.dragging_photos = None; + // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person + // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once + if app.ui.settings.ask_before_move { + let library = app.session.library_generation(); + app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); + } else { + move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); + } + } +} + +/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders +/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after +/// leaving it (or after another drag). +fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + if *open || n.children.is_empty() { + return; + } + let key = egui::Id::new("folder-spring-open"); + let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); + // held without a break: over this row last frame too + let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { + Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, + _ => now, + }; + ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); + if now - since >= SPRING_OPEN_SECONDS { + *open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); + } else { + // keep the clock running while the pointer rests + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). +/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches +/// photos no longer on screen. How many moved, or why none did. +pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result<u64, String> { + let mut params = json!({"folder": path}); + if let Some(ids) = ids { + params["ids"] = json!(ids); + } + let r = app.run("photo.moveToFolder", params)?; + let visible: std::collections::HashSet<lightcraft_catalog::PhotoId> = app.session.visible_cloned().into_iter().collect(); + let sel = &mut app.session.selection; + sel.ids.retain(|id| visible.contains(id)); + if sel.active.is_some_and(|a| !visible.contains(&a)) { + sel.active = sel.ids.first().copied(); + } + Ok(r["moved"].as_u64().unwrap_or(0)) +} + +/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them +/// back, or that they were there already. +pub(crate) fn moved_text(n: u64, name: &str) -> String { + if n == 0 { + crate::i18n::tr_format!("Already in “{}”", name) + } else { + crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) + } +} + +/// [`move_photos`], saying how it went in a toast. +fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { + match move_photos(app, ids, path) { + Ok(n) => app.toast(ctx, moved_text(n, name)), + Err(e) => app.toast(ctx, e), + } +} + /// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the /// startup disk. fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1937,10 +2066,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) .clicked() { - match app.run("photo.moveToFolder", json!({"folder": path})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), - Err(e) => app.toast(ui.ctx(), e), - } + move_photos_here(app, ui.ctx(), None, path, &name); ui.close(); } if app.services.reveal.is_some() diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b95b55b19..a734f6f1c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -142,6 +142,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); + check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); heading(ui, t, crate::i18n::tr("External Editing")); row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..948269738 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -246,6 +246,9 @@ pub struct AppSettings { /// `confirmDelete: false` by default (#1239). #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, + /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask + /// again" turns it off). + pub ask_before_move: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is @@ -292,6 +295,7 @@ impl Default for AppSettings { recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: true, + ask_before_move: true, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -990,6 +994,17 @@ pub enum Dialog { ConfirmDelete { count: usize, }, + /// Confirm moving photos dropped on a Folders row into that folder on disk + /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. + MovePhotos { + ids: Vec<u64>, + folder: String, + name: String, + dont_ask: bool, + /// The library the photos were dropped in (`Session::library_generation`): ids are + /// numbered per library, so in another one they name other photos. + library: u64, + }, /// Confirm taking a folder's photos out of the library (`library.removeFolder`). RemoveFolder { path: String, diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests_folder_drop.rs new file mode 100644 index 000000000..9b4cb860a --- /dev/null +++ b/crates/ui-egui/src/tests_folder_drop.rs @@ -0,0 +1,343 @@ +//! Photos dragged from the grid onto a Folders row move there on disk: the row lights up, the +//! dragged label says where they will go, a closed folder held under the pointer opens, and a +//! release asks first (unless the person said not to), then moves the files (one undo step). +//! Photos that can't go there, or are there already, say so and stay where they are. + +use std::path::{Path, PathBuf}; + +use egui::{Pos2, Rect}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +/// Folders `trip` and `home` in a fresh temporary folder, with a photo file in each. +fn folders(tag: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("lc-folder-drop-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (trip, home) = (root.join("trip"), root.join("home")); + for (dir, file) in [(&trip, "beach.png"), (&home, "garden.png")] { + std::fs::create_dir_all(dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(file), png).unwrap(); + } + (root, trip, home) +} + +/// The photos at `paths` in the catalog (`local`: browsed, not in the library), in the grid with +/// the sidebar out, tall enough for the Folders section. +fn app(paths: &[(PathBuf, bool)]) -> Headless { + use lightcraft_catalog::{Op, Photo, Source}; + let mut session = lightcraft_engine::Session::new().with_fs(); + for (path, local) in paths { + let id = session.catalog.alloc_photo_id(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let mut p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, &name, "PNG", 8, 8, "2026-01-01T10:00:00"); + p.local = *local; + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1600.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["folders"]}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..6 { + h.step(); + } + h +} + +fn widget(h: &Headless, id: &str) -> Option<Rect> { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn folder_row(h: &Headless, dir: &Path) -> Option<Rect> { + widget(h, &format!("source:libfolder:{}", dir.to_string_lossy())) +} + +fn id_of(h: &Headless, file: &str) -> u64 { + h.app.session.catalog.photos().find(|p| p.file_name == file).map(|p| p.id.0).unwrap() +} + +fn press(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); +} + +/// Move the pointer, pressed, from `from` to `to` over a few frames. +fn glide(h: &mut Headless, from: Pos2, to: Pos2) { + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } +} + +fn release(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); +} + +/// What the dragged label says this frame instead of the count, if a drop target set it. +fn hint(h: &Headless) -> Option<String> { + h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text) +} + +/// Click a widget the way a person does (press and release on it). +fn click(h: &mut Headless, id: &str) { + let at = widget(h, id).unwrap_or_else(|| panic!("no {id}")).center(); + press(h, at); + release(h, at); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default() +} + +#[test] +fn photos_dropped_on_a_folder_move_there_on_disk() { + let (root, trip, home) = folders("move"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + // the two folders sit in one that starts closed: only it is there to drop on + let parent = folder_row(&h, &root).unwrap_or_else(|| { + panic!("the folder holding both: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::<Vec<_>>()) + }); + assert!(folder_row(&h, &home).is_none(), "closed"); + // Given the beach photo dragged from the grid and held over the closed folder + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, parent.center()); + assert_eq!(h.app.ui.dragging_photos, Some(vec![beach])); + for _ in 0..60 { + h.step(); + } + // Then it opens, so a folder inside it can be reached + let target = folder_row(&h, &home).expect("held over, the folder opened"); + // When it is brought over home: the label says where it will go + glide(&mut h, parent.center(), target.center()); + assert_eq!(hint(&h).as_deref(), Some("Move 1 photo to “home”")); + // and released there: it asks first, nothing moved yet + release(&mut h, target.center()); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })), "{:?}", h.app.ui.dialog); + assert!(trip.join("beach.png").is_file(), "not before Move"); + h.step(); + click(&mut h, "button:dialogOk"); + // Then the file moved, the photo follows it, and the toast says Undo moves it back + assert!(home.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk"); + let photo = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap(); + assert!(matches!(&photo.source, lightcraft_catalog::Source::File { path } if Path::new(path) == home.join("beach.png"))); + assert!(toast(&h).starts_with("Moved 1 photo to “home”. Edit → Undo moves them back."), "{}", toast(&h)); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + // and one Undo moves it back + h.app.run("edit.undo", json!({})).unwrap(); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "back where it was"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn local_photos_are_refused_with_the_reason() { + let (root, trip, home) = folders("local"); + // the garden photo imported; the beach one only looked at under Local, where the grid shows it + let mut h = app(&[(home.join("garden.png"), false)]); + let r = h.request( + "engine.execute", + json!({"command": "library.browse", "params": {"path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + // (the folder is read in the background) + let shown = |h: &Headless| { + h.app.session.catalog.photos().any(|p| p.file_name == "beach.png") && h.app.widgets.iter().any(|(w, _)| w.starts_with("thumb:")) + }; + h.step_until(std::time::Duration::from_secs(20), |h| shown(h)); + let beach = id_of(&h, "beach.png"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap().local); + let target = folder_row(&h, &home).expect("the imported folder"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + // Then the row says it can't take them, and a release says why + assert_eq!(hint(&h).as_deref(), Some("Can't be moved here")); + release(&mut h, target.center()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "stays"); + assert!(toast(&h).contains("Add to My Photos"), "{}", toast(&h)); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_already_in_the_folder_stay_put() { + let (root, trip, home) = folders("here"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + // open the folder holding both + let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap(); + h.app.synthetic.push(egui::Event::PointerMoved(toggle.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: toggle.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + release(&mut h, toggle.center()); + let target = folder_row(&h, &home).expect("opened"); + let garden = id_of(&h, "garden.png"); + h.app.ui.dragging_photos = Some(vec![garden]); + h.app.synthetic.push(egui::Event::PointerMoved(target.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: target.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert_eq!(hint(&h).as_deref(), Some("Already in “home”")); + let undo = h.app.session.undo.len(); + release(&mut h, target.center()); + assert!(home.join("garden.png").is_file()); + assert_eq!(h.app.session.undo.len(), undo, "nothing done"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_that_leave_the_view_leave_the_selection() { + let (root, trip, home) = folders("selection"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + // shown by folder: trip's two photos, both selected + let r = h.request( + "engine.execute", + json!({"command": "library.source", "params": {"kind": "libraryFolder", "path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + let (beach, dunes) = (id_of(&h, "beach.png"), id_of(&h, "dunes.png")); + h.app.session.selection = + lightcraft_engine::Selection { ids: vec![lightcraft_catalog::PhotoId(beach)], active: Some(lightcraft_catalog::PhotoId(beach)) }; + for _ in 0..4 { + h.step(); + } + let target = folder_row(&h, &home).expect("the other folder is listed beside it"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + release(&mut h, target.center()); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file(), "moved"); + // it left trip's view, and the selection with it: a Delete next can't reach it unseen + assert!(!h.app.session.selection.contains(lightcraft_catalog::PhotoId(beach)), "{:?}", h.app.session.selection); + assert!(h.app.session.visible_cloned().contains(&lightcraft_catalog::PhotoId(dunes))); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_folder_left_and_come_back_to_waits_again_before_opening() { + let (root, trip, home) = folders("spring"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let parent = folder_row(&h, &root).unwrap(); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + // over the closed folder for a moment, then away, longer than it waits + glide(&mut h, from, parent.center()); + glide(&mut h, parent.center(), from); + for _ in 0..60 { + h.step(); + } + // back over it: it doesn't open at once + glide(&mut h, from, parent.center()); + assert!(folder_row(&h, &home).is_none(), "came back to: it waits again"); + for _ in 0..60 { + h.step(); + } + assert!(folder_row(&h, &home).is_some(), "held: it opens"); + release(&mut h, parent.center()); + let _ = std::fs::remove_dir_all(&root); +} + +/// Drag the beach photo from the grid onto `target` and let go. +fn drop_beach(h: &mut Headless, beach: u64, target: Rect) { + let from = widget(h, &format!("thumb:{beach}")).unwrap().center(); + press(h, from); + glide(h, from, target.center()); + release(h, target.center()); + h.step(); +} + +#[test] +fn cancelling_the_question_moves_nothing() { + let (root, trip, home) = folders("cancel"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap_or_else(|| folder_row(&h, &root).unwrap()); + let target = if folder_row(&h, &home).is_some() { + target + } else { + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + folder_row(&h, &home).unwrap() + }; + let undo = h.app.session.undo.len(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + assert_eq!(h.app.session.undo.len(), undo); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn dont_ask_again_moves_the_next_drop_at_once() { + let (root, trip, home) = folders("dont-ask"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let target = folder_row(&h, &home).unwrap(); + let beach = id_of(&h, "beach.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach)); + drop_beach(&mut h, beach, target); + // the question, with Don't ask again ticked + click(&mut h, "check:moveDontAsk"); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file()); + assert!(!h.app.ui.settings.ask_before_move, "kept in Settings"); + // the next drop goes at once + let dunes = id_of(&h, "dunes.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(dunes)); + h.step(); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, dunes, target); + assert!(h.app.ui.dialog.is_none(), "not asked"); + assert!(home.join("dunes.png").is_file(), "moved at once"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_question_left_open_while_another_library_opens_moves_nothing() { + let (root, trip, home) = folders("other-library"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + // another library opens meanwhile (the menu bar and agents reach past a dialog) + let other = root.join("other-library"); + h.app.session.open_library(other.join("lib"), false).unwrap(); + let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("Another library was opened"), "{r}"); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..561711147 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -696,7 +696,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | From af4c5e102c9f97529f582003b99c21b89067e906 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Wed, 14 Oct 2026 19:44:15 -0600 Subject: [PATCH 223/315] fix(import): report and optimize Lightroom catalogs (#1406) Task: LRC-LIB-CATALOG-IMPORT Count offline records, Local promotions and virtual copies correctly. Keep destination, warnings and errors in a persistent import result, with Show in Library clearing filters. Preserve library-switch ownership, reimport identities, existing edits and one-step undo. Avoid repeated missing-original probes and unnecessary staged-photo, catalog-operation and develop-patch copies. Preserve upstream phased progress and typed cancellation. Address Astra review findings with regression coverage. Validated the combined main tree with all eight cargo xtask ci gates (3,462 passing test executions; zero failures), fresh 30k import/persistence/metadata checks, duplicate-free same-session and reopened reimports, and visually inspected headless screenshots. Historical performance comparisons apply to the f730805d production changes before main integration; native Windows import/GUI acceptance remains unqualified. Generated with Codex. --- crates/develop/src/presets.rs | 4 +- crates/engine/src/lib.rs | 12 +- crates/engine/src/lightroom_catalog.rs | 144 +++++++++++++++++++- crates/engine/src/lightroom_job.rs | 45 ++++-- crates/engine/src/tests.rs | 33 +++++ crates/ui-egui/locales/de-formats.json | 5 +- crates/ui-egui/locales/de.json | 7 +- crates/ui-egui/locales/es-formats.json | 5 +- crates/ui-egui/locales/es.json | 7 +- crates/ui-egui/locales/fr-formats.json | 5 +- crates/ui-egui/locales/fr.json | 7 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 7 +- crates/ui-egui/locales/pt-br-formats.json | 5 +- crates/ui-egui/locales/pt-br.json | 7 +- crates/ui-egui/locales/ru-formats.json | 5 +- crates/ui-egui/locales/ru.json | 7 +- crates/ui-egui/locales/uk-formats.json | 5 +- crates/ui-egui/locales/uk.json | 7 +- crates/ui-egui/locales/zh-hans-formats.json | 5 +- crates/ui-egui/locales/zh-hans.json | 7 +- crates/ui-egui/locales/zh-hant-formats.json | 5 +- crates/ui-egui/locales/zh-hant.json | 7 +- crates/ui-egui/src/export_task.rs | 32 +++-- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/lightroom_import.rs | 116 ++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 32 +++++ crates/ui-egui/src/panels/settings.rs | 4 + crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_open_lightroom.rs | 6 +- crates/ui-egui/src/tests_switch_library.rs | 20 +++ docs/gaps.md | 7 +- docs/lightroom-catalog-import.md | 26 +++- docs/parity-checklist.md | 7 +- 34 files changed, 543 insertions(+), 63 deletions(-) diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index acaa303fc..85f27d321 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::borrow::Cow; use crate::DevelopSettings; @@ -185,8 +186,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { /// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped /// through the control specs. pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { - let base = s.to_json(); - let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) }; + let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) }; // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); for c in crate::controls::CONTROLS { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..0f9be6e52 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -506,16 +506,22 @@ impl Session { } fn apply_catalog(&mut self, op: Op) -> Result<Op> { - let inv = self.catalog.apply(op.clone())?; + // Retain only cache IDs while the catalog consumes the operation. Cloning an import + // batch here also cloned every photo, although commit already keeps the journal copy. + let mut invalidated = Vec::new(); let mut pending = vec![&op]; while let Some(op) = pending.pop() { match op { - Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), - Op::AddPhoto { photo } => self.media.forget(photo.id), + Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id), + Op::AddPhoto { photo } => invalidated.push(photo.id), Op::Batch { ops } => pending.extend(ops), _ => {} } } + let inv = self.catalog.apply(op)?; + for id in invalidated { + self.media.forget(id); + } Ok(inv) } diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a3..e841362ea 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -698,14 +698,22 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let mut ops = Vec::new(); let now = (s.clock)(); let mut missing = Vec::new(); + // Normal import counts probed originals; catalog import also adds offline records, + // distinct originals with identical bytes, and virtual copies. + let mut imported = files.imported.len(); + let mut promoted = HashSet::new(); let mut unmapped = BTreeMap::new(); let mut preserved = 0; // Masters precede their virtual copies. let mut photos: Vec<_> = data.photos.iter().collect(); photos.sort_by_key(|p| number(&p.image, "masterImage") != 0); + let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect(); let mut planned: HashMap<PhotoId, Photo> = HashMap::new(); for src in photos { let master = number(&src.image, "masterImage"); + if master == 0 && missing_sources.contains(&key(&src.path)) { + missing.push(src.path.clone()); + } let indexed = index .photos .get(&identity(&data, src)) @@ -736,9 +744,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } else { let id = s.catalog.alloc_photo_id(); let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone()); - if missing_sources.contains(&key(&src.path)) { - missing.push(src.path.clone()); - } let mut p = Photo::new( id, Source::File { path: src.path.clone() }, @@ -807,7 +812,21 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if keep_develop { (p.develop, p.edited) = previous_develop; } + // Missing files bypass normal-file preparation, including its Local promotion. + // Import still joins the library, and a virtual copy must inherit that membership. + let was_local = p.local; + p.local = false; + let photo_id = p.id; + // Only virtual-copy masters need a second owned photo while operations are staged. + // Keeping every photo here doubled the new-record working set on large catalogs. + if copy_masters.contains(&src.source_id) { + planned.insert(photo_id, p.clone()); + } if s.catalog.photo(p.id).is_some() { + if was_local && promoted.insert(p.id) { + imported += 1; + ops.push(Op::SetLocal { id: p.id, local: false }); + } ops.extend([ Op::SetRating { id: p.id, rating: p.rating }, Op::SetFlag { id: p.id, flag: p.flag }, @@ -817,10 +836,10 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex ]); ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() }); } else { - ops.push(Op::AddPhoto { photo: Box::new(p.clone()) }); + imported += 1; + ops.push(Op::AddPhoto { photo: Box::new(p) }); } - by_path.entry(key(&src.path)).or_insert(p.id); - planned.insert(p.id, p); + by_path.entry(key(&src.path)).or_insert(photo_id); } let mut albums = HashMap::new(); for collection in &data.collections { @@ -893,7 +912,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } @@ -938,12 +957,62 @@ mod tests { warnings: Vec::new(), } } + #[test] + fn offline_local_master_and_copy_join_library_with_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let master_id = s.catalog.alloc_photo_id(); + let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); + master.local = true; + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); + s.undo.clear(); + let before = s.catalog.photo(master_id).unwrap().clone(); + let mut index = ImportIndex::default(); + let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); + let mut import = |s: &mut crate::Session| { + let undo0 = s.undo.len(); + apply_prepared( + s, + data.clone(), + ApplyPrepared { + update_existing: false, + files: Default::default(), + index: &mut index, + archive_path: None, + missing_sources: &missing_sources, + undo0, + }, + ) + .unwrap() + .report + }; + let first = import(&mut s); + assert_eq!(first["mapping"]["1"], master_id.0); + assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); + assert_eq!(first["missing"], json!(["/missing/test.ARW"])); + assert_eq!(s.catalog.len(), 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + assert_eq!(s.undo.len(), 1); + let second = import(&mut s); + assert_eq!(second["imported"], 0); + assert_eq!(second["mapping"], first["mapping"]); + s.undo_step().unwrap(); + s.undo_step().unwrap(); + assert_eq!(s.catalog.len(), 1); + assert_eq!(s.catalog.photo(master_id), Some(&before)); + assert!(s.catalog.albums().next().is_none()); + let third = import(&mut s); + assert_eq!(third["imported"], 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + } + #[test] fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { let mut s = crate::Session::new(); let data = sample(); let r = apply(&mut s, data.clone(), false).unwrap(); assert_eq!(r["photos"], 2); + assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); assert_eq!(r["missing"].as_array().unwrap().len(), 1); let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); assert_eq!(master.rating, 5); @@ -1241,6 +1310,10 @@ mod tests { s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); let second = apply(&mut s, data, true).unwrap(); assert_eq!(first["mapping"], second["mapping"]); + assert_eq!(first["imported"], 2); + assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); + assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); + assert_eq!(second["library"], dir.to_string_lossy().as_ref()); assert_eq!(s.catalog.len(), 2); assert_eq!(s.catalog.albums().count(), 2); assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); @@ -1359,6 +1432,63 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.lrcat"); + let root = dir.join("offline"); + let tables = photo_tables((root.to_str().unwrap(), "")); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a known-missing original must not be probed".into()) + })); + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0); + let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(result.report["imported"], 1); + assert_eq!(result.report["failed"], json!([])); + assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); + let photo = s.catalog.photos().next().unwrap(); + assert_eq!(photo.rating, 5); + assert_eq!((photo.width, photo.height), (20, 10)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn probed_original_is_counted_once_and_reimport_adds_nothing() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + let source = dir.join("source.lrcat"); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) + })); + for expected in [1, 0] { + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(completion.report["imported"], expected); + assert_eq!(completion.report["missing"], json!([])); + assert_eq!(completion.report["failed"], json!([])); + assert_eq!(s.catalog.len(), 1); + } + assert_eq!(probes.load(Ordering::Relaxed), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_root_missing_here_is_found_relative_to_the_catalog() { let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 5e7cb02d4..8de1cc806 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -247,23 +247,14 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(cancelled()); } - let paths: Vec<String> = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .map(|photo| photo.path.clone()) - .collect(); - let missing_sources = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .filter(|photo| !Path::new(&photo.path).is_file()) - .map(|photo| normalize(&photo.path)) - .collect(); - // the catalog step's last unit (validation, and looking for each original), then the photos + let (paths, missing_sources) = original_paths(&data, cancel)?; + // Catalog entries name files, never folders. Missing originals already have enough + // catalog metadata for placeholders; probing them again only adds failed I/O and errors. + // Complete the catalog step before the separate read/add photo progress starts. self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); self.progress.photos.store(true, Ordering::Relaxed); - let normal = self.import.prepare(&paths, cancel); + self.import.total.fetch_add(paths.len(), Ordering::Relaxed); + let normal = self.import.prepare_files(paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); return Err(cancelled()); @@ -323,6 +314,30 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } }) } +fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(Vec<String>, HashSet<String>)> { + let mut paths = Vec::new(); + let mut missing = HashSet::new(); + let mut seen = HashSet::new(); + for photo in &data.photos { + if cancel.load(Ordering::Relaxed) { + return Err(cancelled()); + } + if lightroom_catalog::number_for_job(&photo.image, "masterImage") != 0 { + continue; + } + let key = normalize(&photo.path); + if !seen.insert(key.clone()) { + continue; + } + if Path::new(&photo.path).is_file() { + paths.push(photo.path.clone()); + } else { + missing.insert(key); + } + } + Ok((paths, missing)) +} + fn normalize(path: &str) -> String { let path = path.replace('\\', "/"); if cfg!(windows) { path.to_lowercase() } else { path } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..f881cc71b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6,6 +6,39 @@ fn demo() -> Session { Session::with_demo() } +#[test] +fn nested_catalog_operations_invalidate_sources_after_success_and_on_undo_redo() { + use crate::media::SourceLevel; + use lightcraft_catalog::{Op, PhotoId}; + let mut s = demo(); + let id = s.active().unwrap(); + let image = std::sync::Arc::new(lightcraft_raster::Rgb32f::new(1, 1)); + let cache = |s: &mut Session| { + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + s.media.insert(id, level, image.clone()); + } + }; + cache(&mut s); + // An invalid operation must not throw away usable sources. + assert!(s.apply_catalog(Op::Batch { ops: vec![Op::RemovePhoto { id }, Op::RemovePhoto { id: PhotoId(u64::MAX) }] }).is_err()); + assert!(s.catalog.photo(id).is_some(), "the failed batch restores the removed photo"); + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + assert!(s.media.has_source(id, level), "the failed batch keeps usable sources"); + } + s.commit("Remove", Op::Batch { ops: vec![Op::Batch { ops: vec![Op::RemovePhoto { id }] }] }).unwrap(); + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + assert!(!s.media.has_source(id, level)); + } + cache(&mut s); + s.undo_step().unwrap(); + assert!(s.catalog.photo(id).is_some()); + assert!(!s.media.has_source(id, SourceLevel::Full), "undo AddPhoto must invalidate the source too"); + cache(&mut s); + s.redo_step().unwrap(); + assert!(s.catalog.photo(id).is_none()); + assert!(!s.media.has_source(id, SourceLevel::Full)); +} + fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { (*s.develop_of(s.active().unwrap()).unwrap()).clone() } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..bced728d5 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "{count} more warnings.": "{count} weitere Warnungen.", + "{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..21e934f2e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2118,5 +2118,10 @@ "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Lightroom Catalog Import": "Lightroom-Katalogimport", + "Destination library": "Zielbibliothek", + "Show in Library": "In Bibliothek anzeigen", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-Kataloge werden in die geöffnete Bibliothek importiert; es wird kein eigener Katalogordner erstellt.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Diese Fotos befinden sich nur im Arbeitsspeicher. Öffne eine Bibliothek auf dem Datenträger und importiere den Katalog erneut, um sie zu speichern." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..f42a6b67d 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "{count} more warnings.": "{count} advertencias más.", + "{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..22d18117a 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1995,5 +1995,10 @@ "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Lightroom Catalog Import": "Importación de catálogo de Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos solo están en memoria. Abre una biblioteca en disco e importa el catálogo de nuevo para guardarlas.", + "Show in Library": "Mostrar en la biblioteca", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Los catálogos de Lightroom se importan a la biblioteca abierta; no se crea una carpeta de catálogo separada.", + "Destination library": "Biblioteca de destino" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..00e8736f5 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "{count} more warnings.": "{count} avertissements supplémentaires.", + "{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..016b6bcd3 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1957,5 +1957,10 @@ "Adding to Apple Photos": "Ajout à Apple Photos", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Lightroom Catalog Import": "Importation de catalogue Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ces photos sont uniquement en mémoire. Ouvrez une bibliothèque sur disque et importez à nouveau le catalogue pour les enregistrer.", + "Show in Library": "Afficher dans la bibliothèque", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Les catalogues Lightroom sont importés dans la bibliothèque ouverte ; aucun dossier de catalogue distinct n’est créé.", + "Destination library": "Bibliothèque de destination" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..e7a61e9ef 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "{count} more warnings.": "ほかに{count}件の警告があります。", + "{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..b53142ed5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1961,5 +1961,10 @@ "Adding to Apple Photos": "Appleの写真に追加中", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Lightroom Catalog Import": "Lightroomカタログの読み込み", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "これらの写真はメモリ内にのみあります。ディスク上のライブラリを開き、カタログを再度読み込んで保存してください。", + "Show in Library": "ライブラリに表示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroomカタログは開いているライブラリに読み込まれます。別のカタログフォルダーは作成されません。", + "Destination library": "読み込み先ライブラリ" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..e42baa907 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "{count} more warnings.": "Mais {count} avisos.", + "{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente." } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..2c30c3433 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1998,5 +1998,10 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Lightroom Catalog Import": "Importação de catálogo do Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos estão apenas na memória. Abra uma biblioteca no disco e importe o catálogo novamente para salvá-las.", + "Show in Library": "Mostrar na biblioteca", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Os catálogos do Lightroom são importados para a biblioteca aberta; nenhuma pasta de catálogo separada é criada.", + "Destination library": "Biblioteca de destino" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..3d4230f6b 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "{count} more warnings.": "Других предупреждений: {count}.", + "{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..21bbfa629 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2151,5 +2151,10 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Lightroom Catalog Import": "Импорт каталога Lightroom", + "Destination library": "Библиотека назначения", + "Show in Library": "Показать в библиотеке", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom импортируются в открытую библиотеку; отдельная папка каталога не создаётся.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Эти фотографии находятся только в памяти. Откройте библиотеку на диске и импортируйте каталог повторно, чтобы сохранить их." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..c87222131 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "{count} more warnings.": "Інших попереджень: {count}.", + "{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..a99951e0b 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2151,5 +2151,10 @@ "Adding to Apple Photos": "Додавання до Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Lightroom Catalog Import": "Імпорт каталогу Lightroom", + "Destination library": "Бібліотека призначення", + "Show in Library": "Показати в бібліотеці", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom імпортуються у відкриту бібліотеку; окрема папка каталогу не створюється.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ці фотографії є лише в пам’яті. Відкрийте бібліотеку на диску та імпортуйте каталог повторно, щоб зберегти їх." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..3f8f77f40 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "{count} more warnings.": "另有 {count} 条警告。", + "{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..61ae900ff 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1997,5 +1997,10 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Lightroom Catalog Import": "Lightroom 目录导入", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "这些照片仅保存在内存中。请打开磁盘上的图库并再次导入目录以保存照片。", + "Show in Library": "在图库中显示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目录会导入到当前打开的图库中,不会创建单独的目录文件夹。", + "Destination library": "目标图库" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..afdcd7bbb 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,8 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "{count} more warnings.": "另有 {count} 則警告。", + "{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..bf59e775a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1959,5 +1959,10 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Lightroom Catalog Import": "Lightroom 目錄匯入", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。", + "Show in Library": "在圖庫中顯示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。", + "Destination library": "目標圖庫" } diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 97ed3b78d..84a5e1d6b 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -263,25 +263,39 @@ mod contact_sheet_tests { })), ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + // Exercise the menu/picker/background PDF workflow with a small procedural original. + // A full-size demo makes this test's deadline depend on unrelated concurrent renders. + let demo = lightcraft_engine::Session::with_demo(); + let id = demo.active().unwrap(); + let mut photo = (**demo.catalog.photo(id).unwrap()).clone(); + photo.width = 96; + photo.height = 64; + let mut session = lightcraft_engine::Session::new(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + session.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let mut app = LightcraftApp::new(session, services); app.run("dialog.contactSheet", json!({})).unwrap(); let Some(Dialog::ContactSheet { options }) = app.ui.dialog.as_mut() else { panic!("no contact sheet settings") }; options.paper = "letter".into(); options.landscape = true; - let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); - let dialog = h.app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); - assert_eq!(h.app.pending_picks.len(), 1); + let dialog = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &dialog).unwrap(); + assert_eq!(app.pending_picks.len(), 1); + let ctx = egui::Context::default(); + crate::pick::poll(&mut app, &ctx); + assert!(app.pending_picks.is_empty()); + assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); let deadline = Instant::now() + Duration::from_secs(20); loop { - // The activity stack lays out text, which egui only allows inside a frame. - h.step(); - if h.app.last_export_result.is_some() { + // Poll the real worker without starting unrelated gallery renders each frame. + poll(&mut app, &ctx); + if app.export.is_none() { break; } assert!(Instant::now() < deadline, "export did not finish"); std::thread::sleep(Duration::from_millis(10)); } + let result = app.last_export_result.as_ref().unwrap_or_else(|| panic!("export failed: {:?}", app.ui.toast)); let req = asked.lock().unwrap(); assert_eq!(req[0].kind, PickKind::Save); assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]); @@ -289,7 +303,7 @@ mod contact_sheet_tests { let b = bytes.lock().unwrap(); assert!(b.starts_with(b"%PDF-1.4")); assert!(String::from_utf8_lossy(&b).contains("/MediaBox [0 0 792.00 612.00]")); - assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1); + assert_eq!(result["files"][0]["photos"], 1); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..03b13e242 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -383,6 +383,8 @@ pub struct LightcraftApp { pub lightroom: Option<lightroom_import::LightroomTask>, /// Last terminal Lightroom result, exposed by the command's status/wait response. pub lightroom_last: Option<Value>, + /// A completed import result waiting for another modal dialog to close. + pub(crate) lightroom_result: Option<state::Dialog>, /// The library generation the window title and the recent list were last updated for. pub shown_library: Option<u64>, /// A background export in progress. @@ -476,6 +478,7 @@ impl LightcraftApp { sync_run: None, lightroom: None, lightroom_last: None, + lightroom_result: None, shown_library: None, export: None, tasks: Default::default(), diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 195e63e2a..d7ea85122 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -243,6 +243,9 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, let kind = task.kind; if let Some(o) = value.as_object_mut() { o.entry("path").or_insert_with(|| json!(task.path)); + if let Some(library) = app.session.library.as_ref().filter(|l| l.on_disk) { + o.entry("library").or_insert_with(|| json!(library.dir.to_string_lossy())); + } } let message = if let Some(e) = value.get("error").and_then(Value::as_str) { let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}")); @@ -280,11 +283,61 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed) } }; + if matches!(kind, Kind::Import) && value.get("cancelled") != Some(&Value::Bool(true)) { + app.lightroom_result = Some(result_dialog(&value, &message)); + show_result(app); + } app.lightroom_last = Some(value); app.lightroom = None; app.toast_for(ctx, message, 6.0); } +fn show_result(app: &mut LightcraftApp) { + if app.ui.dialog.is_none() { + app.ui.dialog = app.lightroom_result.take(); + } +} + +fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { + let report = value.get("report").unwrap_or(value); + let mut details = Vec::new(); + if let Some(photos) = report["photos"].as_u64() { + details.push(crate::i18n::tr_format!( + "{photos} catalog records processed; {collections} collections.", + photos = photos, + collections = report["collections"].as_u64().unwrap_or(0) + )); + } + if let Some(failures) = report["failed"].as_array() { + details + .extend(failures.iter().take(4).map(|failure| format!("{}: {}", failure[0].as_str().unwrap_or(""), failure[1].as_str().unwrap_or("")))); + } + let missing = report["missing"].as_array().map_or(0, Vec::len); + if missing > 0 { + details.push(crate::i18n::tr_format!( + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + missing = missing + )); + } + if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) { + details.push(warning.to_string()); + } + if let Some(warnings) = report["warnings"].as_array() { + // Results can contain one warning per image; keep the dialog bounded. + details.extend(warnings.iter().take(8).filter_map(Value::as_str).map(str::to_string)); + if warnings.len() > 8 { + details.push(crate::i18n::tr_format!("{count} more warnings.", count = warnings.len() - 8)); + } + } + let first = value.get("error").is_none().then(|| report["mapping"].as_object()?.values().filter_map(Value::as_u64).min()).flatten(); + crate::state::Dialog::LightroomResult { + summary: summary.to_string(), + library: report["library"].as_str().or_else(|| value["library"].as_str()).map(str::to_string), + details, + first, + } +} + #[cfg(not(target_arch = "wasm32"))] fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result<Value, String> { busy(app)?; @@ -348,6 +401,7 @@ pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context /// Poll owner-thread work, commit a prepared import, and finish its archive index. pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + show_result(app); let Some(mut task) = app.lightroom.take() else { return }; let message = match task.rx.try_recv() { Ok(m) => m, @@ -553,6 +607,68 @@ mod tests { } } + #[test] + fn lightroom_result_stays_readable_and_shows_photos_hidden_by_a_filter() { + let mut h = demo(); + let id = h.app.session.catalog.photos().next().unwrap().id.0; + h.app.session.source = lightcraft_engine::LibrarySource::Picks; + h.app.session.filter.text = "no-photo-has-this-name".into(); + h.app.session.filter.only = vec![lightcraft_catalog::PhotoId(u64::MAX)]; + h.app.session.filter.labels = vec![lightcraft_catalog::ColorLabel::Red]; + h.app.session.filter.rule_set = Some(lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::rules::Match::All, + rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }], + }); + let (task, _tx, _, _) = fake_import(&h); + let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"}); + terminal(&mut h.app, &h.view.ctx, &task, value); + assert!(h.app.ui.toast.as_ref().unwrap().0.contains("1 photos added")); + h.step(); + h.step(); + let text = h.painted_text().join(" "); + assert!(text.contains("Destination library"), "{text}"); + assert!(text.contains("C:/Pictures/LightCraft Library"), "{text}"); + assert!(text.contains("1 originals are missing"), "{text}"); + assert!(text.contains("Show in Library"), "{text}"); + // The activity and toast can go away; the modal result remains until dismissed. + h.app.ui.toast = None; + h.step(); + assert!(h.app.ui.dialog.is_some()); + let dialog = h.app.ui.dialog.clone().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default()); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(id)); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid); + assert!(!h.app.session.visible().is_empty()); + } + + #[test] + fn lightroom_failure_stays_readable_without_offering_a_success_action() { + let mut h = demo(); + let (task, _tx, _, _) = fake_import(&h); + terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"})); + h.step(); + h.step(); + assert!(h.painted_text().join(" ").contains("exceeds 1 GiB import limit")); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { first: None, .. }))); + assert!(!h.app.widgets.iter().any(|(id, _)| id == "button:dialogCancel")); + } + + #[test] + fn lightroom_result_waits_for_an_existing_dialog() { + let mut h = demo(); + h.app.ui.dialog = Some(crate::state::Dialog::About); + let (task, _tx, _, _) = fake_import(&h); + terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog could not be read"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::About))); + assert!(h.app.lightroom_result.is_some()); + h.app.ui.dialog = None; + tick(&mut h.app, &h.view.ctx); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { .. }))); + assert!(h.app.lightroom_result.is_none()); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..fab26e573 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -226,6 +226,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::RenameAlbum { .. } => "Rename Album", Dialog::Rename { .. } => "Rename Photos", Dialog::Import { .. } => "Import Photos", + Dialog::LightroomResult { .. } => "Lightroom Catalog Import", Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", @@ -345,6 +346,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), + Dialog::LightroomResult { summary, library, details, .. } => { + ui.label(summary.as_str()); + ui.add_space(8.0); + if let Some(library) = library { + ui.label(crate::i18n::tr("Destination library")); + ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); + ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); + } else { + ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); + } + for detail in details.iter() { + ui.add(egui::Label::new(detail).wrap().selectable(true)); + } + } Dialog::SystemInfo { rows } => { egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { for (k, v) in rows.iter() { @@ -1199,6 +1214,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); let informational = unusable_model + || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || matches!( dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew @@ -1210,6 +1226,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let cancel = match &dlg { Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close", Dialog::SamModel { .. } => "Not Now", + Dialog::LightroomResult { .. } => "Close", _ => "Cancel", }; if !informational { @@ -1227,6 +1244,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); add_label.as_str() } + Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", Dialog::ContactSheet { .. } => "Choose PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", @@ -1552,6 +1570,20 @@ pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option<u pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> { match dlg { + Dialog::LightroomResult { first, .. } => { + if let Some(first) = first { + app.run("library.source", json!({"kind": "all"}))?; + let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; + // The filter command merges a patch; these defaults are omitted by serde. + filter["only"] = json!([]); + filter["labels"] = json!([]); + filter["ruleSet"] = serde_json::Value::Null; + app.run("library.filter", filter)?; + app.run("library.select", json!({"ids": [first]}))?; + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + Ok(serde_json::Value::Null) + } Dialog::SamModel { then, .. } => { if app.session.segmenter.installed() { // installed: start what the user was doing diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b95b55b19..6c38df7c7 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -956,6 +956,10 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> app.session.close_library().map_err(|e| e.to_string())?; let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); app.lightroom_last = None; + app.lightroom_result = None; + if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { + app.ui.dialog = None; + } app.renderer.forget_all(); app.ui.compare = None; if let Err(err) = opened { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..c43b08101 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -783,6 +783,13 @@ pub struct NameEdit { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// A Lightroom import result stays readable after its activity row disappears. + LightroomResult { + summary: String, + library: Option<String>, + details: Vec<String>, + first: Option<u64>, + }, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests_open_lightroom.rs index 028801b56..cddfa2bef 100644 --- a/crates/ui-egui/src/tests_open_lightroom.rs +++ b/crates/ui-egui/src/tests_open_lightroom.rs @@ -88,8 +88,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { // library open now. (This fake catalog then fails to read: only its path matters here.) assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); h.step(); - assert!(h.app.ui.dialog.is_none()); - h.step_until(T, |h| h.app.lightroom.is_none()); + // A fast read failure can already have replaced the offer with its lasting result. + assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. }))); + assert!(h.step_until(T, |h| h.app.lightroom.is_none())); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed"))); let last = h.app.lightroom_last.clone().unwrap_or_default(); assert_eq!(last["path"], json!(lrcat), "{last}"); assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 8c5208e3d..d1ad85c96 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -55,6 +55,26 @@ fn app_in(dir: &std::path::Path) -> LightcraftApp { LightcraftApp::new(session, Services::default()) } +#[test] +fn switching_libraries_dismisses_the_previous_lightroom_result() { + let dir = temp("switch-lightroom-result"); + let mut app = app_in(&dir); + let result = || crate::state::Dialog::LightroomResult { + summary: "Imported into first".into(), + library: Some(dir.join("first").to_string_lossy().into_owned()), + details: Vec::new(), + first: Some(1), + }; + app.ui.dialog = Some(result()); + app.lightroom_result = Some(result()); + app.lightroom_last = Some(json!({"imported": 1})); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Second"})).unwrap(); + assert!(app.ui.dialog.is_none(), "the old destination and photo ID must not act on the new library"); + assert!(app.lightroom_result.is_none()); + assert!(app.lightroom_last.is_none()); + let _ = std::fs::remove_dir_all(dir); +} + fn recent_submenu(app: &LightcraftApp) -> Vec<crate::menubar::MenuNode> { use crate::menubar::MenuNode; let bar = crate::menubar::menu_bar(app); diff --git a/docs/gaps.md b/docs/gaps.md index ee6e58d1c..e63fcbf6b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** patch (gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -27,7 +27,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | -| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | @@ -91,3 +91,6 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | | 2026-10-13 | minor | Gap 8 narrows: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (`docs/nikon-lens-corrections.md`) | +| 2026-10-12 | minor | Catalog migration reports offline records as additions, keeps the destination/result readable, skips missing-original probes and stages only masters needed for virtual copies. Approximate rendering and database/table limits remain. | +| 2026-10-13 | minor | Catalog import review: offline Local records join All Photos, redundant operation/settings copies removed, and result invalidation across library switches covered. Approximate rendering and database/table limits remain. | +| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 1f9deeea4..5ad4383fd 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -15,9 +15,21 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path= lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat" ``` +Import adds records to the currently open LightCraft library; it does not create a separate +folder named after the Lightroom catalog. The desktop result stays open until dismissed and +shows the destination library, the number of records added, missing originals and warnings. +**Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay +readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result +is also available through `library.importLightroom {"status":true}` during that session. + Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata, hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop -settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft +settings are imported. Missing originals remain catalogued for relinking, count as added records and are reported +separately from failures reading files that are present. Known-missing originals are not probed +again. Previously browsed Local records join All Photos even when their originals are offline; +their promotion is counted once and undone together with the catalog import. +Virtual copies count as added records too; reimport reports zero additions when all +records already exist. Existing LightCraft edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import identity prevents reimport from duplicating virtual copies and collections. One undo step reverses catalog changes; any saved recovery archives remain available. @@ -32,8 +44,10 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un `Interop/lightroom-index.json` records imported identities. Source catalogs and originals are never overwritten. -Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per -table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format +Each native `.lrcat` database and WAL file is independently limited to 1 GiB +(1,073,741,824 bytes). This bounds the current whole-file reader; referenced photo sizes and +photo counts are not part of that limit. XMP packets are capped at 16 MiB and each table at one million rows. +The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. Optional history, keyword and collection tables that exceed limits are skipped with warnings; required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed @@ -61,6 +75,12 @@ Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone is not evaluated by the importer; LightCraft's Auto control remains available after migration. +New photo records are moved into staged operations; only masters needed for virtual copies +retain a second owned record while the import commits. +Applying catalog operations collects affected source-cache IDs before consuming the operations, +avoiding another full operation copy. Full-strength develop patches reuse their JSON without +serializing an unused copy of the current settings. + Native desktop inspection/import runs on a cancellable background worker with progress. Only prepared catalog operations commit on the owner thread; archive index writing stays on a worker. Late results from a closed or switched library are rejected, including reopening the same path. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..a434d28cf 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -692,7 +692,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also a catalog whose tables are all still in its WAL: the main file's schema format 0 is an empty schema), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | @@ -823,3 +823,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: lasting destination/result, offline records and virtual copies count as added; redundant missing-file probes and staged photo copies removed. Status and summary counts unchanged. | +| 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | +| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | From 728471fcf59f4ae75739069fe9bd5baabdb8f339 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Wed, 14 Oct 2026 17:52:13 +0200 Subject: [PATCH 224/315] Before/After: a click on the Before view zooms in on the point clicked (fixes #1742) --- crates/ui-egui/src/panels/detail.rs | 10 +++--- crates/ui-egui/src/tests_before_after.rs | 39 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..22820ac6b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -750,14 +750,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // side by side / stacked: Before shows the same frame, so a click in its view (white // balance, colour samples, targeted adjustment) means the same point of the photo. // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + // A click-zoom there centres both views on the point clicked (#1742). let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let map = match (split, areas.first(), pointer) { + let (map, img, pane) = match (split, areas.first(), pointer) { (true, Some(before), Some(q)) if before.contains(q) => { - CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan)) + let br = fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan); + (CanvasMap::new(&frame, br), br, *before) } - _ => map, + _ => (map, img_rect, main_area), }; - general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect) + general_interaction(app, ui, &resp, &map, img, pane, native, aspect) } } } diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 50ec66cf4..8a89098fe 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -70,6 +70,45 @@ fn wb_selector_picks_the_same_point_in_the_before_view() { } } +/// A click at Fit zooms in on the point clicked whichever view of a side-by-side or top/bottom +/// Before / After layout it lands in: both views then show the same part of the photo. A click +/// on the Before view used to be measured against the After view's rectangle, so it zoomed in on +/// the photo's edge (#1742). +#[test] +fn a_click_in_the_before_view_zooms_in_on_the_point_clicked() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + let fit = |h: &mut Headless, mode: BeforeAfter| { + h.app.ui.before_after = mode; + h.app.ui.zoom = Zoom::Fit; + h.app.ui.pan = Default::default(); + h.settle(SETTLE); + (h.app.image_rect.expect("After view on screen"), h.app.canvas_rect.expect("canvas")) + }; + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] { + let (img, canvas) = fit(&mut h, mode); + let shift = match mode { + BeforeAfter::SideBySide => egui::vec2(img.left() + img.right() - 2.0 * canvas.center().x, 0.0), + _ => egui::vec2(0.0, img.top() + img.bottom() - 2.0 * canvas.center().y), + }; + assert!(shift.length() > 100.0, "{mode:?}: two views, {shift:?} apart"); + // off the middle of the photo, so that a zoom on another point shows + let after = img.center() + egui::vec2(img.width() * 0.3, img.height() * -0.25); + let mut pans = Vec::new(); + for before_view in [false, true] { + fit(&mut h, mode); + click_at(&mut h, if before_view { after - shift } else { after }); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{mode:?}: the click zoomed in ({:?})", h.app.ui.zoom); + pans.push(h.app.ui.pan); + } + let (a, b) = (pans[0], pans[1]); + assert!((a.0 - 0.8).abs() < 0.02 && (a.1 - 0.25).abs() < 0.02, "{mode:?}: the After view zooms in on the point clicked ({a:?})"); + assert!((b.0 - a.0).abs() < 1e-3 && (b.1 - a.1).abs() < 1e-3, "{mode:?}: the Before view zooms in on the same point ({b:?}, not {a:?})"); + } +} + /// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { let mut sum = [0.0f32; 3]; From 5d88e64783d4078ce9b495c9a26c7b7569b9952e Mon Sep 17 00:00:00 2001 From: Alex Chambers <alexchambers91@gmail.com> Date: Thu, 15 Oct 2026 02:24:48 +0200 Subject: [PATCH 225/315] LR-VIEW-BEFOREAFTER: animate both panes during click zoom Translate the shared animated image rectangle into the Before pane for whole-frame drawing, cached window tiles and input mapping. Translate the final target separately so render requests stay stable during the motion. Cover intermediate mesh bounds and final geometry in both layouts, including clicks in either pane, return to Fit and cached region tiles. Fixes #1821 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/detail.rs | 16 +- crates/ui-egui/src/tests_before_after.rs | 182 ++++++++++++++++++++++- docs/parity-checklist.md | 3 +- 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 22820ac6b..3681bb56c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -426,9 +426,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); + let in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); let mut navigating = false; for &view_area in &areas { - let view_img = if view_area == main_area { img_rect } else { fit_rect(view_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan) }; + let view_img = in_pane(view_area, img_rect); if navigate_gesture(app, ui, &resp, view_area, view_img, native) { navigating = true; target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); @@ -436,6 +437,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { break; } } + let before_img = in_pane(areas[0], img_rect); + let before_target = in_pane(areas[0], target_rect); app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); @@ -551,11 +554,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) let mut window_views: Vec<WindowView> = Vec::new(); if window_ctx.frame_edge.is_some() { - let before_rect = |r: Rect, area: Rect| WindowView { slot: Slot::RegionBefore, before: true, img: r, target: r, visible: area }; let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; if split { - let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); - window_views.push(before_rect(br, areas[0])); + window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: areas[0] }); window_views.push(after); } else if show_before { window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); @@ -612,7 +613,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if split { // each view stays in its pane: zoomed in, a photo reaches past it (and would cover the // other view's margins with its whole-frame render, magnified and blurred) - let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); + let br = before_img; draw(Slot::Before, br, areas[0]); shown = draw(Slot::Main, img_rect, main_area); // the labels under each photo, or under its pane when the photo reaches past it @@ -753,10 +754,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // A click-zoom there centres both views on the point clicked (#1742). let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); let (map, img, pane) = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => { - let br = fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan); - (CanvasMap::new(&frame, br), br, *before) - } + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), _ => (map, img_rect, main_area), }; general_interaction(app, ui, &resp, &map, img, pane, native, aspect) diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 8a89098fe..02ee879a1 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -1,10 +1,11 @@ -//! Headless tests of clicks in the two-view Before / After layouts. +//! Headless tests of input and zoom animation in the two-view Before / After layouts. use std::time::Duration; use serde_json::json; -use crate::headless::Headless; +use crate::headless::{Headless, RenderGate}; +use crate::render::Slot; use crate::state::{BeforeAfter, ViewMode, Zoom}; use crate::{LightcraftApp, Services}; @@ -109,6 +110,183 @@ fn a_click_in_the_before_view_zooms_in_on_the_point_clicked() { } } +/// A texture mesh in its pane, excluding the Navigator's copy of the Main texture. +fn pane_mesh(h: &Headless, slot: Slot, pane: egui::Rect) -> egui::Rect { + let tex = &h.app.renderer.textures[&slot]; + let meshes: Vec<_> = h + .view + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == tex.tex.id() && c.clip_rect == pane => Some(m.calc_bounds()), + _ => None, + }) + .collect(); + assert_eq!(meshes.len(), 1, "{slot:?}: one image clipped to {pane:?}, got {meshes:?}"); + meshes[0] +} + +fn assert_rect(actual: egui::Rect, expected: egui::Rect) { + assert!( + (actual.min - expected.min).abs().max_elem() < 1.0 && (actual.max - expected.max).abs().max_elem() < 1.0, + "image bounds {actual:?}, expected {expected:?}" + ); +} + +/// Start with both textures ready, then hold replacement renders so worker timing cannot hide +/// a frame of the transition or replace a cached tile under the assertions. +fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2], std::rc::Rc<std::cell::Cell<bool>>) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + h.app.ui.pan = (0.5, 0.5); + h.app.ui.click_zoom = 300; + assert!(h.settle(SETTLE), "baseline renders settled"); + let before_id = h.app.renderer.textures[&Slot::Before].tex.id(); + let before = h + .view + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == before_id => Some(c.clip_rect), + _ => None, + }) + .expect("Before image on screen"); + let canvas = h.app.canvas_rect.expect("canvas"); + let shift = 2.0 * (canvas.center() - before.center()); + let after = before.translate(shift); + assert!(canvas.contains_rect(before) && canvas.contains_rect(after), "both clips stay in the canvas"); + assert!(!before.intersects(after), "pane clips do not overlap"); + let open = std::rc::Rc::new(std::cell::Cell::new(false)); + h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone()))); + (h, [before, after], open) +} + +fn queue_click(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]); + while !h.app.synthetic.is_empty() { + h.step(); + } + assert!(h.app.ui.zoom_anim, "the click started an animation"); +} + +fn check_click_animation(mode: BeforeAfter) { + let (mut h, [before, after], open) = animation_view(mode, Zoom::Fit); + let shift = before.center() - after.center(); + let fit = h.app.image_rect.expect("Fit image"); + let id = h.app.session.active().expect("photo"); + let photo = h.app.session.catalog.photo(id).expect("photo"); + let native = [photo.width as usize, photo.height as usize]; + let aspect = native[0] as f32 / native[1] as f32; + // Exercise clicks in both panes, including their return to Fit, without settling the motion. + for before_click in [false, true] { + for zoom_in in [true, false] { + let pos = if zoom_in { fit.center() + egui::vec2(fit.width() * 0.3, fit.height() * -0.25) } else { after.center() }; + queue_click(&mut h, if before_click { pos + shift } else { pos }); + assert_eq!(h.app.ui.zoom, if zoom_in { Zoom::Percent(300.0) } else { Zoom::Fit }); + if zoom_in { + let pan = h.app.ui.pan; + assert!((pan.0 - 0.8).abs() < 0.02 && (pan.1 - 0.25).abs() < 0.02, "{mode:?}: clicked point {pan:?}"); + } + let target = crate::panels::detail::fit_rect(after, aspect, h.app.ui.zoom, native, 1.0, h.app.ui.pan); + let mut intermediate = 0; + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.insert((h.app.renderer.wanted(Slot::Before), h.app.renderer.wanted(Slot::Main))); + let img = h.app.image_rect.expect("animated image"); + let between = img.width() > fit.width() + 1.0 && img.width() < native[0] as f32 * 3.0 - 1.0; + if between { + intermediate += 1; + } + if between || !h.app.ui.zoom_anim { + let a = pane_mesh(&h, Slot::Main, after); + let b = pane_mesh(&h, Slot::Before, before); + assert_rect(b, a.translate(shift)); + } + } + assert!(intermediate > 0, "{mode:?}: inspected intermediate animation frames"); + assert!(!h.app.ui.zoom_anim, "animation finished"); + assert_rect(h.app.image_rect.expect("settled image"), target); + assert_rect(pane_mesh(&h, Slot::Main, after), crate::panels::detail::fit_texture_rect(target, h.app.renderer.textures[&Slot::Main].size)); + assert_eq!(keys.len(), 1, "both panes request stable final-size renders throughout the animation: {keys:?}"); + } + } + open.set(true); + assert!(h.settle(SETTLE)); +} + +#[test] +fn both_before_after_panes_use_the_animated_rect_side_by_side() { + check_click_animation(BeforeAfter::SideBySide); +} + +#[test] +fn both_before_after_panes_use_the_animated_rect_top_bottom() { + check_click_animation(BeforeAfter::TopBottom); +} + +fn check_region_animation(mode: BeforeAfter) { + let (mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0)); + let shift = before.center() - after.center(); + let start = h.app.image_rect.expect("200 percent image"); + let tiles = [(Slot::RegionBefore, before, true), (Slot::Region, after, false)].map(|(slot, pane, is_before)| { + let tex = &h.app.renderer.textures[&slot]; + let tile = h.app.region_tiles[&(is_before, tex.key)]; + pane_mesh(&h, slot, pane); + (slot, pane, tile, tex.key) + }); + h.app.ui.zoom = Zoom::Percent(400.0); + h.app.ui.zoom_anim = true; + let mut intermediate = 0; + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.insert((h.app.renderer.wanted(Slot::RegionBefore), h.app.renderer.wanted(Slot::Region))); + let img = h.app.image_rect.expect("animated image"); + if img.width() > start.width() + 1.0 && img.width() < start.width() * 2.0 - 1.0 { + intermediate += 1; + } + if img.width() <= start.width() + 1.0 { + continue; + } + for (slot, pane, tile, key) in tiles { + assert_eq!(h.app.renderer.textures[&slot].key, key, "the cached tile stays on screen while renders wait"); + let full = if tile.before { img.translate(shift) } else { img }; + let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); + let w = tile.window; + let expected = egui::Rect::from_min_size( + full.min + egui::vec2(w.x as f32 / fw * full.width(), w.y as f32 / fh * full.height()), + egui::vec2(w.w as f32 / fw * full.width(), w.h as f32 / fh * full.height()), + ); + assert_rect(pane_mesh(&h, slot, pane), expected); + } + } + assert!(intermediate > 0, "{mode:?}: inspected intermediate region frames"); + assert!(!h.app.ui.zoom_anim); + assert!((h.app.image_rect.expect("settled image").width() - start.width() * 2.0).abs() < 1.0); + assert_eq!(keys.len(), 1, "region requests use the final target throughout the animation: {keys:?}"); + open.set(true); + assert!(h.settle(SETTLE)); +} + +#[test] +fn before_after_region_tiles_use_the_animated_rect_side_by_side() { + check_region_animation(BeforeAfter::SideBySide); +} + +#[test] +fn before_after_region_tiles_use_the_animated_rect_top_bottom() { + check_region_animation(BeforeAfter::TopBottom); +} + /// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { let mut sum = [0.0f32; 3]; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a434d28cf..1a8e9ba1d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | @@ -790,6 +790,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | From cebaade144b79c2ce40c83ea708cf7ce3e789823 Mon Sep 17 00:00:00 2001 From: Myrarc <44331096+Myrarc@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:50:22 +0700 Subject: [PATCH 226/315] CI-RUST195: keep strict Clippy clean on Rust 1.95 Move nested conditions into match guards and simplify equivalent Boolean expressions without changing validation or short-circuit behavior. Keep the explicit permitted Testkit dev-dependency arm in the layer checker. Co-Authored-By: OpenAI Codex <noreply@openai.com> --- crates/codecs/src/jpeg.rs | 14 ++++++-------- crates/engine/src/camera_preview.rs | 2 +- crates/engine/src/media.rs | 2 +- crates/engine/src/sidecar.rs | 8 ++++---- crates/faces/src/net/shape.rs | 6 +----- crates/meta/src/gpx.rs | 6 +----- crates/meta/src/xmp.rs | 12 +++++------- crates/pipeline/src/local.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 2 +- xtask/src/layers.rs | 7 ++----- 10 files changed, 23 insertions(+), 38 deletions(-) diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index e40cf7e73..ce2d0480b 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -75,14 +75,12 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> { let end = (p + 2 + len).min(b.len()); let seg = &b[start..end]; match marker { - 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => { - if seg.len() >= 6 && m.components == 0 { - m.precision = seg[0]; - m.sof = marker; - m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; - m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; - m.components = seg[5]; - } + 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => { + m.precision = seg[0]; + m.sof = marker; + m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; + m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; + m.components = seg[5]; } 0xE1 => { if seg.starts_with(b"Exif\0\0") && m.exif.is_none() { diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..400774184 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -136,7 +136,7 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) // into the baseline exposure let white = m.apply([1.0; 3]); let k = (white[0] + white[1] + white[2]) / 3.0; - if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) { + if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { return None; } let m = Mat3(m.0.map(|r| r.map(|v| v / k))); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0688410ed..0d0677b53 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -1525,7 +1525,7 @@ impl crate::Session { /// says and what a smart preview renders with. Only for raws edited in Kelvin. fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { let Some(p) = self.catalog.photo(id) else { return }; - if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) { + if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { return; } let read = (info.as_shot_temp, info.as_shot_tint); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..cd09f8813 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -502,10 +502,10 @@ pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Op /// Photo ids touched by an op (for auto-write). pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) { match op { - Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } => { - if !out.contains(id) { - out.push(*id); - } + Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } + if !out.contains(id) => + { + out.push(*id); } Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)), _ => {} diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index d76271bc7..89ffaba7a 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -186,11 +186,7 @@ pub(super) fn check(n: &Node, g: &Graph) -> Result<()> { floats(n, g, 2)?; } } - "Dropout" => { - if !(0.0..1.0).contains(&float(n, "ratio", 0.5)) { - return shape_err("Dropout ratio is invalid"); - } - } + "Dropout" if !(0.0..1.0).contains(&float(n, "ratio", 0.5)) => return shape_err("Dropout ratio is invalid"), "MaxPool" => { pair(n, "kernel_shape", 1)?; pair(n, "strides", 1)?; diff --git a/crates/meta/src/gpx.rs b/crates/meta/src/gpx.rs index e63499033..abfc9ebd4 100644 --- a/crates/meta/src/gpx.rs +++ b/crates/meta/src/gpx.rs @@ -114,11 +114,7 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> { match name.as_slice() { b"gpx" if path.is_empty() => seen_gpx = true, b"trk" if parent == Some(b"gpx") => log.tracks += 1, - b"trkseg" if parent == Some(b"trk") => { - if log.points.last().is_some_and(|p| p.segment == segment) { - segment += 1; - } - } + b"trkseg" if parent == Some(b"trk") && log.points.last().is_some_and(|p| p.segment == segment) => segment += 1, b"trkpt" if parent == Some(b"trkseg") => { let mut p = Pending::default(); for a in e.attributes().with_checks(false).flatten() { diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..924b5fce0 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -252,13 +252,11 @@ fn parse_tree(s: &str) -> Result<Node, XmpError> { stack.push(node); } } - Event::End(_) => { - if stack.len() > 1 { - let n = stack.pop().expect("len > 1"); - scopes.pop(); - if let Some(parent) = stack.last_mut() { - parent.children.push(n); - } + Event::End(_) if stack.len() > 1 => { + let n = stack.pop().expect("len > 1"); + scopes.pop(); + if let Some(parent) = stack.last_mut() { + parent.children.push(n); } } Event::Text(t) => { diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index a99530777..e46085955 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -147,7 +147,7 @@ pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64) /// edited on a rendered photo or a raw without matrices never pass for Kelvin when pasted onto one that has them. pub fn normalize_wb(d: &mut DevelopSettings, info: &SourceInfo) { use lightcraft_develop::{WbMode, WbScale}; - if !(info.raw && !info.relative_wb) { + if !info.raw || info.relative_wb { d.wb.scale = WbScale::Legacy; return; } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..a7c8c1968 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -780,7 +780,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the render failed while a stand-in is up: what is on screen is all there will be (also while // a library change retries it) let failed = (!rendering - && !(show_before && !split) + && (!show_before || split) && !matches!(shown, "render" | "none" | "hover") && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) .then(|| app.renderer.failure_of(Slot::Main, id)) diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 714b47a2f..88113e27b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -191,11 +191,8 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } - } + Some(Class::Testkit) if d.kind != DepKind::Dev => out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }), + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { From cfd14ea7c1ce0629c43d7dd553382f1b23187de7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:05:23 -0400 Subject: [PATCH 227/315] Batch 36: the Polish format catalog (#1100) lists the strings this batch's other PRs add, in English until translated; lockfile at 0.8.0 for the slideshow crate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 2 +- crates/ui-egui/locales/pl-formats.json | 33 +++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cfce6f82..db6efd50e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3463,7 +3463,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-raster", "serde", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 88622823b..849fe3142 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -247,5 +247,36 @@ "{n} originals opened in the other editor": "Oryginały otwarte w innym edytorze: {n}", "{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem", "· {in_source} identical copies": "· identyczne kopie: {in_source}", - "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}" + "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}", + " in {g} group{}": " in {g} group{}", + " · kept, not applied: {}": " · kept, not applied: {}", + " — kept but not applied: {}": " — kept but not applied: {}", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "Effect: {name}": "Effect: {name}", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking ▸ Effect)", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "{n} preset{}": "{n} preset{}", + "{count} more warnings.": "{count} more warnings.", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "Applied {name}": "Applied {name}", + "Delete “{name}”?": "Delete “{name}”?", + "Updated {name}": "Updated {name}", + "Exported to {path}": "Exported to {path}", + "Saved template {name}": "Saved template {name}", + "Imported {name}": "Imported {name}", + "Skipped {problem}": "Skipped {problem}", + "…and {more} more": "…and {more} more", + "Template preview: {name}": "Template preview: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} JPEG files", + "{s} s of AV1 video": "{s} s of AV1 video", + "Slideshow exported: {what}": "Slideshow exported: {what}", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data" } From da7534a2edad975a976cc75d05adb487ebc0681e Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:28:53 -0400 Subject: [PATCH 228/315] Batch 36: the slideshow template test drops the cached listing after writing a file by hand (flaky when the write lands in the listing's clock tick) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/tests_slide_template.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/tests_slide_template.rs index 3adfcdcca..f7a899c43 100644 --- a/crates/engine/src/tests_slide_template.rs +++ b/crates/engine/src/tests_slide_template.rs @@ -265,6 +265,9 @@ fn bad_files_are_reported_and_skipped() { // out-of-range numbers in a hand-edited file are clamped, never applied as they are std::fs::write(folder.join("wild.json"), doc(json!({"layout": {"margins": 9, "shadowAngle": 1e300}, "playback": {"slideDuration": 1e9}}))) .unwrap(); + // written by hand, not through LightCraft: the folder's time may not have moved since the + // listing above (same clock tick), so the cached listing is dropped as a write would drop it + s.slide_templates_cache = None; let t = s.slide_template("user:wild").unwrap(); assert_eq!(t.look.layout.margin_left, lightcraft_slideshow::MARGIN_MAX); assert_eq!(t.look.playback.slide_duration, crate::slideshow::SLIDE_MAX); From 6cc00728430f219a8c2498f2b1e647b5e437a6c5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:31:00 -0400 Subject: [PATCH 229/315] Batch 36: the Czech and Polish format catalogs list the strings this batch adds, in English until translated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/cs-formats.json | 38 +++++++++++++++++++++++++- crates/ui-egui/locales/pl-formats.json | 7 ++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index f4bf1ceb7..1a5fdae97 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -247,5 +247,41 @@ "· {trashed} in Recently Deleted": "· {trashed} v nedávno odstraněných", "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ již obsahuje „{name}“. Sloučit obě slova?", "• {assumption}": "• {assumption}", - "… and {more} more": "… a dalších {more}" + "… and {more} more": "… a dalších {more}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "new, inside {parent}": "new, inside {parent}", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", + " in {g} group{}": " in {g} group{}", + " · kept, not applied: {}": " · kept, not applied: {}", + " — kept but not applied: {}": " — kept but not applied: {}", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "Effect: {name}": "Effect: {name}", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking ▸ Effect)", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "{n} preset{}": "{n} preset{}", + "{count} more warnings.": "{count} more warnings.", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "Applied {name}": "Applied {name}", + "Delete “{name}”?": "Delete “{name}”?", + "Updated {name}": "Updated {name}", + "Exported to {path}": "Exported to {path}", + "Saved template {name}": "Saved template {name}", + "Imported {name}": "Imported {name}", + "Skipped {problem}": "Skipped {problem}", + "…and {more} more": "…and {more} more", + "Template preview: {name}": "Template preview: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} JPEG files", + "{s} s of AV1 video": "{s} s of AV1 video", + "Slideshow exported: {what}": "Slideshow exported: {what}", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data" } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 849fe3142..db5dbf564 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -278,5 +278,10 @@ "{s} s of AV1 video": "{s} s of AV1 video", "Slideshow exported: {what}": "Slideshow exported: {what}", " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data" + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "new, inside {parent}": "new, inside {parent}", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back." } From a740c1f3901cb63e6d24b599531ec8cb0735b960 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:33:28 -0400 Subject: [PATCH 230/315] Batch 36: format catalogs list the strings this batch adds, in English until translated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/cs-formats.json | 8 +++++++- crates/ui-egui/locales/pl-formats.json | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 1a5fdae97..d3ce48fc6 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -283,5 +283,11 @@ "{s} s of AV1 video": "{s} s of AV1 video", "Slideshow exported: {what}": "Slideshow exported: {what}", " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data" + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})" } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index db5dbf564..682d3c33f 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -283,5 +283,11 @@ "Create the keyword “{name}”": "Create the keyword “{name}”", "new, inside {parent}": "new, inside {parent}", "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back." + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})" } From 211255770f0e8c156c45fb76acd1397878c3fa3e Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:35:34 -0400 Subject: [PATCH 231/315] Batch 36: #1510's rename follows the optional name #1665 gave person_from; #1780's test sets #974's preview_checked; rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/face_recognize.rs | 10 +++++++++- crates/engine/src/cmd/mod.rs | 2 +- crates/raw/src/vendor/nef.rs | 3 ++- crates/ui-egui/src/lib.rs | 12 ++++++------ crates/ui-egui/src/tests_rename_person.rs | 4 ++-- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index a1f1dc65d..7e9e459cf 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -1020,7 +1020,15 @@ mod imp { s.faces.scanned.insert(b.0); let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]); let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap(); - let replace = Op::SetContent { id: a, width, height, file_size: 1234, content_hash: Some("edited".into()), preview_only: None }; + let replace = Op::SetContent { + id: a, + width, + height, + file_size: 1234, + content_hash: Some("edited".into()), + preview_only: None, + preview_checked: String::new(), + }; s.commit("Reload Changed Files", replace).unwrap(); assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten"); assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again"); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a9c35cf16..41e3b0929 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -35,8 +35,8 @@ mod prefs; mod preset_files; pub mod previews; mod query; -mod slideshow; pub mod recheck; +mod slideshow; mod sync; mod xmp; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 745375087..f4e568cb3 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -56,7 +56,8 @@ fn nrw_wb(mn: Option<&makernote::MakerNote>) -> Option<[f32; 3]> { let (kelvin, r, g1, g2, bl) = (at(0)?, at(1)?, at(2)?, at(3)?, at(4)?); let sane = |v: u32| (64..=2560).contains(&v); let mirrored = at(15)? == kelvin; - (mirrored && (2000..=12000).contains(&kelvin) && g1 == 512 && g2.abs_diff(512) <= 8 && sane(r) && sane(bl)).then(|| [r as f32 / 256.0, 1.0, bl as f32 / 256.0]) + (mirrored && (2000..=12000).contains(&kelvin) && g1 == 512 && g2.abs_diff(512) <= 8 && sane(r) && sane(bl)) + .then(|| [r as f32 / 256.0, 1.0, bl as f32 / 256.0]) } /// Nikon CropArea is `[left, top, width, height]` in sensor pixels (maker-note tag documentation). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 63b3dc102..d4520bfa8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,10 +55,10 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] -mod tests_canvas_keys; -#[cfg(test)] mod tests_browse_active; #[cfg(test)] +mod tests_canvas_keys; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; @@ -129,10 +129,10 @@ mod tests_quit_unsaved; #[cfg(test)] mod tests_recheck; #[cfg(test)] -mod tests_sam_setup; -#[cfg(test)] mod tests_rename_person; #[cfg(test)] +mod tests_sam_setup; +#[cfg(test)] mod tests_scroll; #[cfg(test)] mod tests_slideshow; @@ -565,10 +565,10 @@ impl LightcraftApp { if self.ui.last_person.as_deref().is_some_and(same) { self.ui.last_person = Some(to.to_string()); } - if let Some((name, photo)) = self.ui.person_from.clone() + if let Some((Some(name), photo)) = self.ui.person_from.clone() && same(&name) { - self.ui.person_from = Some((to.to_string(), photo)); + self.ui.person_from = Some((Some(to.to_string()), photo)); } } diff --git a/crates/ui-egui/src/tests_rename_person.rs b/crates/ui-egui/src/tests_rename_person.rs index 9a69b1409..4ad1f5871 100644 --- a/crates/ui-egui/src/tests_rename_person.rs +++ b/crates/ui-egui/src/tests_rename_person.rs @@ -93,7 +93,7 @@ fn rename_person_from_the_card_and_the_page() { let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; *to = "Ada Lovelace".into(); h.app.ui.last_person = Some("Jane Doe".into()); - h.app.ui.person_from = Some(("Jane Doe".into(), ids[0].0)); + h.app.ui.person_from = Some((Some("Jane Doe".into()), ids[0].0)); h.app.session.execute("library.filter", &json!({"person": "jane doe"})).unwrap(); h.step(); click(&mut h, "button:dialogOk"); @@ -102,7 +102,7 @@ fn rename_person_from_the_card_and_the_page() { assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("Ada Lovelace")); assert_eq!(h.app.ui.person_page.as_deref(), Some("Ada Lovelace")); assert_eq!(h.app.ui.last_person.as_deref(), Some("Ada Lovelace")); - assert_eq!(h.app.ui.person_from.as_ref().map(|(n, _)| n.as_str()), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_from.as_ref().and_then(|(n, _)| n.as_deref()), Some("Ada Lovelace")); assert_eq!(h.app.session.filter.person.as_deref(), Some("Ada Lovelace")); h.app.session.execute("edit.undo", &json!({})).unwrap(); assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); From 2b3b6e7871ffec548eeb1bf2ca079ed6100a1297 Mon Sep 17 00:00:00 2001 From: robmdunn <1900604+robmdunn@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:17:23 -0400 Subject: [PATCH 232/315] LR-IMP-CAMERA-COVERAGE: measured image area for Nikon bodies without CropArea Nikon bodies from before ~2016 record no CropArea, so the decoder kept their whole sensor data, less trailing dark columns. Where that data holds masked lines above black or optical black, they were developed as a coloured or dark bar on that edge: the D7000 and D5100's 36 columns at ~600 (#976), the D800's 14 (#914, the top in portrait), the D600 / D610's 8 (#1236), the D3200's 46, and others. MEASURED_BODIES (crates/raw/src/vendor/nef.rs) now gives 44 more model / data size / depth groups the frame of their camera JPEG inside the data, next to the D4, D7100 and D90 entries already there: D7000, D5100, D800, D800E, Df, the D4's 12-bit mode, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200 and Nikon 1 V1; and, on best-guess frames short of the evidence rule, each with a comment naming its evidence, the D3100 (3 of 4 clean files agree), D5000 (4 of 5, and the D90's sensor), D40 (2 of 2), D50 and D70 (one file each, agreeing) and D70s (next to them, between pixels). Every best-guess frame keeps the masked lines out by several pixels. An entry matches model, data size and depth exactly. Bodies whose data holds only picture beyond the camera frame get no entry and render as before. `cargo xtask raw-frame FILE...` (crates/engine/examples/raw_frame.rs) measures a body: it aligns each raw's embedded camera JPEG with the sensor data (half-size luminance, gradient correlation, sub-pixel peak; weak, ambiguous or between-pixel peaks are flagged), counts the edge lines that hold no picture and reports what the decoder's crop keeps beyond the camera frame. A masked line is flat and its two CFA phases agree within the noise or within 0.4% of white, so filler with a small fixed offset between them (D90, D5000) counts. It prints entries only with evidence: at least 3 clean files agreeing exactly, of the model (12- and 14-bit pooled) or, with at least one of its own, with sibling models of the same readout (data and JPEG size) that agree on the same frame. On the D7500, which records its CropArea, it finds that area exactly; it declines the Z f, whose JPEG is distortion-corrected. It works for any make whose raw embeds a full-size camera JPEG: on Sony, Canon CR2 / CR3, Pentax and Samsung files it finds the decoder's own crop (relative to the active area). Every Nikon raw on raw.pixls.us without CropArea was surveyed this way (its 138 CC0 samples, plus 124 other publicly posted sample files; only the measurements are committed). Bodies whose data holds only picture beyond the frame need no entry (D750, D5300, D5500, D3300, D2X, D2Hs, D3X, D5200, Coolpix P6000, Nikon 1 AW1 / J3 / V2 / V3, the D800's crop modes); raw-parity.md lists them, and no longer claims every NEF coding but HE decodes. measured_bodies_are_consistent checks every entry lies inside its data and is listed once; measured_older_dx_bodies_image_area covers the D200, D3200, Nikon 1 V1 and best-guess entries. Fixes #976 Fixes #914 Fixes #1236 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/examples/raw_frame.rs | 499 ++++++++++++++++++++++++++++ crates/raw/src/vendor/nef.rs | 168 +++++++++- docs/gaps.md | 6 +- docs/parity-checklist.md | 2 +- docs/raw-parity.md | 7 +- docs/whats-new.md | 7 + xtask/src/main.rs | 20 ++ 7 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 crates/engine/examples/raw_frame.rs diff --git a/crates/engine/examples/raw_frame.rs b/crates/engine/examples/raw_frame.rs new file mode 100644 index 000000000..ce6c63e85 --- /dev/null +++ b/crates/engine/examples/raw_frame.rs @@ -0,0 +1,499 @@ +//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no +//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame -- +//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries +//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record +//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 / +//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most +//! Fujifilm, Panasonic and Olympus files) get the edge bands only. +//! +//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size +//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is +//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak +//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths +//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the +//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several +//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG +//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off); +//! a smaller preview is not aligned. +//! +//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark* +//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples +//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the +//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A +//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a +//! survey aid: a dark band can also be deep shadow, so read it with the frame. +use std::collections::BTreeMap; + +use lightcraft_codecs::DecodeOptions; +use lightcraft_raw::RawData; + +/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3 +/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower). +const MIN_SCORE: f64 = 0.5; +const MIN_MARGIN: f64 = 0.02; +/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment. +const MAX_FRACTION: f64 = 0.35; +/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep +/// up to ~280 px of masked border). +const MAX_SEARCH: usize = 192; +/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine. +const FULL_SEARCH: usize = 2048; +/// Edge bands are counted up to this many lines from each edge. +const MAX_BAND: usize = 128; +/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area), +/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own +/// files agree on the same frame. +const MIN_FILES: usize = 3; + +struct Frame { + jpeg: (usize, usize), + /// Sub-pixel offset of the JPEG's top left corner in sensor pixels. + at: (f64, f64), + score: f64, + margin: f64, +} + +impl Frame { + fn whole(&self) -> (usize, usize) { + (self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize) + } + fn clean(&self) -> bool { + let frac = |v: f64| (v - v.round()).abs(); + self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION + } +} + +/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge. +#[derive(Clone, Copy, Default)] +struct Band { + dark: usize, + masked: usize, +} + +impl Band { + fn total(self) -> usize { + self.dark + self.masked + } +} + +struct Measured { + model: String, + size: (usize, usize), + bits: u32, + black: f64, + white: f64, + /// The crop the decoder applies, in sensor-data coordinates: x, y, width, height. + crop: (usize, usize, usize, usize), + /// Left, right, top, bottom. + bands: [Band; 4], + frame: Result<Frame, String>, + /// With a clean frame: what the crop holds beyond it. + beyond: Option<[Beyond; 4]>, +} + +impl Measured { + /// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture. + fn in_crop(&self) -> [usize; 4] { + let (w, h) = self.size; + let (x, y, cw, ch) = self.crop; + let [l, r, t, b] = self.bands.map(Band::total); + [l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))] + } +} + +/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are +/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the +/// camera, harmless). +#[derive(Clone, Copy, Default)] +struct Beyond { + masked: usize, + dark: usize, + picture: usize, +} + +fn main() { + let args: Vec<String> = std::env::args().skip(1).collect(); + let json = args.iter().any(|a| a == "--json"); + let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect(); + if files.is_empty() { + eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)"); + std::process::exit(2); + } + // model, data size, JPEG size -> (bit depth, frame) of each file + let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new(); + for f in files { + let m = match measure(f) { + Ok(m) => m, + Err(e) => { + if json { + println!("{}", serde_json::json!({ "file": f, "error": e })); + } else { + println!("{f}: skipped: {e}"); + } + continue; + } + }; + let in_crop = m.in_crop(); + if json { + let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked }); + let frame = match &m.frame { + Ok(fr) => serde_json::json!({ + "jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean() + }), + Err(e) => serde_json::json!({ "error": e }), + }; + println!( + "{}", + serde_json::json!({ + "file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white, + "crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3], + "bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) }, + "in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] }, + "beyond_frame": m.beyond.map(|b| { + let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture }); + serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) }) + }), + "frame": frame, + }) + ); + } else { + let frame = match &m.frame { + Ok(fr) => format!( + "JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}", + fr.jpeg.0, + fr.jpeg.1, + fr.at.0, + fr.at.1, + fr.score, + fr.margin, + if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" } + ), + Err(e) => format!("no frame: {e}"), + }; + println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits); + let side = |name: &str, b: Band| match (b.dark, b.masked) { + (0, 0) => None, + (d, 0) => Some(format!("{name} {d} dark")), + (0, k) => Some(format!("{name} {k} masked")), + (d, k) => Some(format!("{name} {d} dark + {k} masked")), + }; + let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect(); + let shown: Vec<String> = + ["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect(); + println!( + " edge bands: {}; decoder crop ({}, {}, {}, {}){}", + if bands.is_empty() { "none".to_string() } else { bands.join(", ") }, + m.crop.0, + m.crop.1, + m.crop.2, + m.crop.3, + match m.beyond { + Some(b) => { + let parts: Vec<String> = ["left", "right", "top", "bottom"] + .iter() + .zip(b) + .filter(|(_, b)| b.masked + b.dark + b.picture > 0) + .map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture)) + .collect(); + if parts.is_empty() { + "; nothing beyond the camera frame".to_string() + } else { + format!("; beyond the camera frame: {}", parts.join(", ")) + } + } + None if shown.is_empty() => String::new(), + None => format!(" still includes edge bands: {} lines", shown.join(", ")), + } + ); + } + if let Ok(fr) = m.frame { + groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr)); + } + } + if json { + return; + } + println!(); + // each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree + let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups + .iter() + .filter_map(|(key, frames)| { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + let first = *offsets.first()?; + offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len()))) + }) + .collect(); + for (key, frames) in &groups { + let (model, size, jpeg) = key; + let clean = frames.iter().filter(|(_, f)| f.clean()).count(); + let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect(); + depths.sort_unstable(); + depths.dedup(); + let label = format!("{model} {}×{}", size.0, size.1); + let Some(&((x, y), _)) = agreed.get(key) else { + if clean == 0 { + println!("{label}: no clean alignment in {} files; no entry", frames.len()); + } else { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + println!("{label}: files disagree: {offsets:?}; no entry"); + } + continue; + }; + let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() }; + println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len()); + // siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on + // the same frame + let siblings: Vec<(&str, usize)> = agreed + .iter() + .filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y)) + .map(|((m, _, _), (_, n))| (m.as_str(), *n)) + .collect(); + let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>(); + if clean < MIN_FILES { + if pooled < MIN_FILES { + println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled); + continue; + } + let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect(); + println!(" with siblings of the same readout and frame: {}", names.join(", ")); + } + for bits in depths { + println!( + " MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},", + size.0, size.1, jpeg.0, jpeg.1 + ); + } + } +} + +fn measure(path: &str) -> Result<Measured, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?; + let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) }; + if raw.cpp != 1 || raw.cfa.is_none() { + return Err("not colour-filter sensor data".into()); + } + let (w, h) = (raw.width, raw.height); + if w < 4 || h < 4 || d.len() < w.saturating_mul(h) { + return Err("sensor data shorter than its size".into()); + } + let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0); + let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0); + let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0)); + let bands = [ + band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white), + band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white), + band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white), + band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white), + ]; + // the decoder's crop is relative to the active area: in sensor-data coordinates here + let a = raw.active_area; + let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height); + let frame = frame(&bytes, d, w, h); + let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| { + let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg); + let kinds = |lines: Vec<Line>| { + lines.iter().fold(Beyond::default(), |mut b, l| { + match kind(l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => b.picture += 1, + } + b + }) + }; + let col = |x: usize| line(h, |i| at(x, i)); + let row = |y: usize| line(w, |i| at(i, y)); + [ + kinds((c.x..fx.min(c.x + c.width)).map(col).collect()), + kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()), + kinds((c.y..fy.min(c.y + c.height)).map(row).collect()), + kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()), + ] + }); + Ok(Measured { + model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(), + size: (w, h), + bits: raw.bits, + black, + white, + crop: (c.x, c.y, c.width, c.height), + bands, + frame, + beyond, + }) +} + +/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart) +/// and the number of sample pairs. +struct Line { + mean: f64, + pattern: f64, + spread: f64, + noise: f64, + pairs: f64, +} + +fn line(n: usize, at: impl Fn(usize) -> f64) -> Line { + let stride = ((n / 512).max(1)).div_ceil(2) * 2; + let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64); + let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect(); + for &i in &idx { + even += at(i); + odd += at(i + 1); + k += 1.0; + } + let (even, odd) = (even / k.max(1.0), odd / k.max(1.0)); + for &i in &idx { + var += (at(i) - even).powi(2); + noise += (at(i + 2) - at(i)).powi(2) / 2.0; + } + Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k } +} + +enum Kind { + Dark, + Masked, + Picture, +} + +fn kind(l: &Line, black: f64, white: f64) -> Kind { + let range = white - black; + let slack = white / 4096.0; + if l.mean < black + range * 0.01 { + Kind::Dark + } else if l.mean > black + range * 0.02 + && l.mean < white * 0.25 + // patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler) + && (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004) + && l.spread < 1.5 * l.noise + slack + && l.spread < white * 0.004 + { + Kind::Masked + } else { + Kind::Picture + } +} + +fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band { + let mut b = Band::default(); + for l in lines { + match kind(&l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => break, + } + } + b +} + +fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> { + let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?; + let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?; + let (jw, jh) = (jpeg.image.width, jpeg.image.height); + if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 { + return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data")); + } + // half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike + let (rw, rh) = (w / 2, h / 2); + let raw_y: Vec<f64> = (0..rh * rw) + .map(|i| { + let (x, y) = (2 * (i % rw), 2 * (i / rw)); + let s: f64 = + [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum(); + s.sqrt() + }) + .collect(); + let (pw, ph) = (jw / 2, jh / 2); + let px = &jpeg.image.data; + let jpg_y: Vec<f64> = (0..ph * pw) + .map(|i| { + let (x, y) = (2 * (i % pw), 2 * (i / pw)); + let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)] + .iter() + .map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0))) + .sum(); + s.sqrt() + }) + .collect(); + let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH)); + let grad = |img: &[f64], stride: usize, x: usize, y: usize| { + let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0); + (at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y)) + }; + let normalise = |v: Vec<f64>| { + let n = v.len().max(1) as f64; + let mean = v.iter().sum::<f64>() / n; + let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12); + v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>() + }; + // gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset + let sample = |step: usize| { + let points: Vec<(usize, usize)> = + (0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect(); + let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect()); + (points, gj) + }; + let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| { + let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect()); + gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64 + }; + let fine = sample(2); + // every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset + // around the best of those + let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1]; + let mut coarse_best = f64::NEG_INFINITY; + let (mut cx, mut cy) = (0, 0); + let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH { + (0..=sx, 0..=sy) + } else { + let coarse = sample(8); + for dy in (0..=sy).step_by(4) { + for dx in (0..=sx).step_by(4) { + let v = score(&coarse, dx, dy); + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = v; + } + if v > coarse_best { + (coarse_best, cx, cy) = (v, dx, dy); + } + } + } + (cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy)) + }; + for dy in window.1.clone() { + for dx in window.0.clone() { + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = score(&fine, dx, dy); + } + } + } + let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY); + let (mut bx, mut by) = (0, 0); + for dy in 0..=sy { + for dx in 0..=sx { + if at(dx, dy) > at(bx, by) { + (bx, by) = (dx, dy); + } + } + } + let best = at(bx, by); + let runner_up = (0..=sy) + .flat_map(|dy| (0..=sx).map(move |dx| (dx, dy))) + .filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1) + .map(|(dx, dy)| at(dx, dy)) + .fold(f64::NEG_INFINITY, f64::max); + // parabola through the peak and its neighbours (none at the window's edge) + let refine = |a: f64, b: f64, c: f64| { + let k = a - 2.0 * b + c; + if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 } + }; + let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 }; + let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 }; + Ok(Frame { + jpeg: (jw, jh), + at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)), + score: best, + margin: if runner_up.is_finite() { best - runner_up } else { best }, + }) +} diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 7d5b24a4f..271f9c71f 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -87,11 +87,88 @@ struct MeasuredBody { /// the masked-column test keeps them and they develop as a pink band on the right. The camera's 4288 × 2848 JPEG /// sits at (10, 10). Green saturates at 3840–3851 while red and blue run on to 4095; one level of 3830 keeps /// clipped highlights white. +/// * **NIKON D7000** and **NIKON D5100** (and the Coolpix A, the same readout): the data is 4992 × 3280, the picture its +/// first 4948 columns, then 36 masked ones at one level (~600, with a black level of 0) and 8 of 0. The masked +/// columns sit above the dark-column trim's threshold, so without this area they show as a coloured bar on the right +/// edge (the top in portrait; issue #976). The camera's 4928 × 3264 JPEG sits at (8, 8): in the D5100's 3 CC0 +/// samples, and in the D7000's one clean one, with the D5100 and Coolpix A as siblings (see below). +/// * **The other bodies** below keep masked or dark lines inside the whole data (a survey of every Nikon raw on raw.pixls.us): +/// each crop is its camera JPEG's frame as `cargo xtask raw-frame` measures it on raw.pixls.us CC0 samples (the +/// D200, D3200 and Nikon 1 V1 also on other publicly posted sample files), with at least 3 clean +/// files agreeing: of the model (bit depths pooled: both read out the same area), or, with at least +/// one of its own, together with models of the same readout (data and JPEG size) that agree on the same frame +/// (*siblings*). The D4's 12-bit mode reads out the 14-bit area measured above; the Df, the same readout, agrees on +/// (8, 6) in 6 files. Bodies whose crop holds only picture beyond the camera frame have no entry (no change). const MEASURED_BODIES: &[MeasuredBody] = &[ MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) }, + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None }, MeasuredBody { model: "NIKON D7100", size: (4812, 3212), bits: 14, crop: (6, 6, 4800, 3200), white: None }, MeasuredBody { model: "NIKON D90", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) }, + MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 12, crop: (8, 8, 4928, 3264), white: None }, + MeasuredBody { model: "NIKON D5100", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + // siblings: D7000 (4), D5100 (3); masked: right 36 + MeasuredBody { model: "COOLPIX A", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + // 6 files each; masked: left 2, right 2 + MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + // 6 and 4 files; masked: right up to 32 + MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None }, + // D2X (1) and D2Xs (2) as siblings; dark: right 1 + MeasuredBody { model: "NIKON D2Xs", size: (4320, 2868), bits: 12, crop: (16, 10, 4288, 2848), white: None }, + // D3000, D40X, D60, D80: one file each, siblings; masked: right 4, bottom 3 + MeasuredBody { model: "NIKON D3000", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D40X", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D60", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D80", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + // 3 files; masked: left 1, right 3 (the D80's data size, framed elsewhere) + MeasuredBody { model: "NIKON D200", size: (3904, 2616), bits: 12, crop: (16, 12, 3872, 2592), white: None }, + // 22 files; masked: right 46 + MeasuredBody { model: "NIKON D3200", size: (6080, 4012), bits: 12, crop: (8, 6, 6016, 4000), white: None }, + // 8 clean of 19, the rest ~0.3 px between pixels on the same frame; dark: bottom 2 + MeasuredBody { model: "NIKON 1 V1", size: (3904, 2606), bits: 12, crop: (12, 6, 3872, 2592), white: None }, + // Best guesses, short of the evidence rule; every frame below keeps the masked lines out by several pixels. + // D3100: 3 of 4 clean files at (24, 6), the 4th at (22, 7); masked: left 6, right 22 + MeasuredBody { model: "NIKON D3100", size: (4672, 3084), bits: 12, crop: (24, 6, 4608, 3072), white: None }, + // D5000: 4 of 5 clean files at (11, 10), the 5th at (11, 12); the D90's sensor, whose own files also align at + // x = 11; masked: right 42 (the same filler as the D90's) + MeasuredBody { model: "NIKON D5000", size: (4352, 2868), bits: 12, crop: (11, 10, 4288, 2848), white: None }, + // D40: 2 of 2 clean files at (16, 9); masked: right 1. The D50 / D70 / D70s share its data size but not its frame + MeasuredBody { model: "NIKON D40", size: (3040, 2014), bits: 12, crop: (16, 9, 3008, 2000), white: None }, + // D50, D70, D70s (one readout): one clean D50 and one clean D70 file at (15, 7); the D70s's 2 files align + // between pixels next to it, at (15.4, 6.9) and (15.6, 6.4); masked: right 1 + MeasuredBody { model: "NIKON D50", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None }, + MeasuredBody { model: "NIKON D70", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None }, + MeasuredBody { model: "NIKON D70s", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None }, + // 3 files; dark: bottom 4 + MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 12, crop: (4, 4, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 14, crop: (4, 4, 4928, 3280), white: None }, + // 4 files; dark: bottom up to 4 + MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 12, crop: (8, 8, 6000, 4000), white: None }, + MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 14, crop: (8, 8, 6000, 4000), white: None }, + // 4 files each; masked: right 8 + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + // D800: 6 files; D800E: 2, with the D800 as sibling; masked: right 14 (issue #914, the top in portrait) + MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 12, crop: (8, 6, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D800E", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None }, + // 3 files; dark: bottom 1 + MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 12, crop: (12, 8, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 14, crop: (12, 8, 7360, 4912), white: None }, + // 6 files; masked: left 2, right 42 (the D4's readout) + MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: None }, ]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { @@ -297,6 +374,22 @@ mod tests { /// [`nef_with_note`] in either byte order (file and maker note). #[allow(clippy::too_many_arguments)] fn nef_in(order: ByteOrder, compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> { + nef_model_in(order, "NIKON TEST", compression, bits, strips, w, h, rps, note) + } + + /// [`nef_in`] from camera `model`. + #[allow(clippy::too_many_arguments)] + fn nef_model_in( + order: ByteOrder, + model: &str, + compression: u16, + bits: u16, + strips: Vec<Vec<u8>>, + w: u32, + h: u32, + rps: u32, + note: Option<IfdBuilder>, + ) -> Vec<u8> { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -309,7 +402,7 @@ mod tests { raw.set_image(ImageData::Strips { rows_per_strip: rps, strips }); let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into())); - ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); if let Some(mn) = note { let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec(); bytes.extend(TiffWriter::new(order, false).write(&[mn]).unwrap()); @@ -457,9 +550,76 @@ mod tests { assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280))); let developed = d4.develop(crate::Method::Bilinear).unwrap(); assert_eq!((developed.width, developed.height), (4928, 3280)); - for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] { - assert_eq!(other.crop, Rect::new(0, 0, w, h)); - assert_ne!(other.white, vec![15520.0]); + // the 12-bit mode reads out the same area but keeps the white level from its data + let d4_12 = decode_as("NIKON D4", 12); + assert_eq!(d4_12.crop, Rect::new(8, 6, 4928, 3280)); + assert_ne!(d4_12.white, vec![15520.0]); + let other = decode_as("NIKON D5", 14); + assert_eq!(other.crop, Rect::new(0, 0, w, h)); + assert_ne!(other.white, vec![15520.0]); + } + + /// Issue #976: the D7000 and D5100 record no CropArea, and their 36 masked columns at ~600 were developed as a + /// coloured bar. Their measured area applies to the measured models, data sizes and depths only. + #[test] + fn measured_d7000_and_d5100_image_area() { + let (w, h) = (4992usize, 3280usize); + let decode_as = |model: &str, bits: u16| { + let nef = nef_model_in(ByteOrder::Big, model, 1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None); + crate::decode(&nef).unwrap() + }; + for (model, bits) in [("NIKON D7000", 14), ("NIKON D7000", 12), ("NIKON D5100", 14)] { + let raw = decode_as(model, bits); + assert_eq!(raw.crop, Rect::new(8, 8, 4928, 3264), "{model} {bits}-bit"); + assert_eq!((raw.metadata.width, raw.metadata.height), (Some(4928), Some(3264))); + let developed = raw.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (4928, 3264)); + } + // another model, or the D5100's unmeasured 12-bit mode, keeps the whole data + for (model, bits) in [("NIKON D7100", 14), ("NIKON D5100", 12), ("NIKON D7000 ", 16)] { + assert_eq!(decode_as(model, bits).crop, Rect::new(0, 0, w, h), "{model} {bits}-bit"); + } + } + + /// The D200, D3200 and Nikon 1 V1 keep masked or dark lines in their whole data (the D200 shares the D80's data + /// size but frames it elsewhere), measured on public sample files beyond the CC0 ones; the D3100, D5000, D40, D50, + /// D70 and D70s get best-guess frames (the D40 shares the D50 / D70 / D70s data size but not their frame). + #[test] + fn measured_older_dx_bodies_image_area() { + let decode_as = |model: &str, (w, h): (usize, usize)| { + let nef = nef_model_in(ByteOrder::Big, model, 1, 12, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None); + crate::decode(&nef).unwrap() + }; + for (model, size, crop) in [ + ("NIKON D200", (3904, 2616), Rect::new(16, 12, 3872, 2592)), + ("NIKON D80", (3904, 2616), Rect::new(14, 9, 3872, 2592)), + ("NIKON D3200", (6080, 4012), Rect::new(8, 6, 6016, 4000)), + ("NIKON 1 V1", (3904, 2606), Rect::new(12, 6, 3872, 2592)), + ("NIKON D3100", (4672, 3084), Rect::new(24, 6, 4608, 3072)), + ("NIKON D5000", (4352, 2868), Rect::new(11, 10, 4288, 2848)), + ("NIKON D40", (3040, 2014), Rect::new(16, 9, 3008, 2000)), + ("NIKON D50", (3040, 2014), Rect::new(15, 7, 3008, 2000)), + ("NIKON D70", (3040, 2014), Rect::new(15, 7, 3008, 2000)), + ("NIKON D70s", (3040, 2014), Rect::new(15, 7, 3008, 2000)), + ] { + assert_eq!(decode_as(model, size).crop, crop, "{model}"); + } + // the V1's area on the V2's data size: no entry, the whole data + assert_eq!(decode_as("NIKON 1 V1", (4620, 3084)).crop, Rect::new(0, 0, 4620, 3084)); + } + + /// Every measured entry is a crop inside its data, once per model, data size and depth, with a white level (if + /// any) inside its bit range. + #[test] + fn measured_bodies_are_consistent() { + let mut seen = std::collections::HashSet::new(); + for b in MEASURED_BODIES { + let (x, y, w, h) = b.crop; + assert!(w > 0 && h > 0 && x + w <= b.size.0 && y + h <= b.size.1, "{}: crop outside its data", b.model); + assert!(seen.insert((b.model, b.size, b.bits)), "{} {:?} {}-bit listed twice", b.model, b.size, b.bits); + assert!(matches!(b.bits, 12 | 14), "{}: {}-bit", b.model, b.bits); + assert!(b.white.is_none_or(|v| v > 0.0 && v < (1u32 << b.bits) as f32), "{}: white level", b.model); + assert_eq!(b.model.trim(), b.model, "{}: model names are matched trimmed", b.model); } } diff --git a/docs/gaps.md b/docs/gaps.md index e63fcbf6b..2e9d66a04 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Nikon edge bars on bodies without CropArea fixed, #976 / #914 / #1236; gap 10 notes the survey; catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A distortion and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users; Nikon crops of bodies without CropArea now surveyed and measured ([raw-parity](raw-parity.md#variant-matrix-depth-per-format), D3100 / D5000 / D40 / D50 / D70 / D70s on best-guess frames until more samples confirm them) | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -59,6 +59,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-14: Nikon edge bars on bodies without CropArea (#976, #914, #1236): 44 more model / size / depth groups measured (6 on best-guess frames). - 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345), JPEG XL export (#247). - 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), @@ -81,6 +82,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | | 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | +| 2026-10-14 | minor | Nikon edge bars on bodies without CropArea fixed for 44 more model / size / depth groups (#976, #914, #1236); gap 10 notes the survey and the best-guess bodies; gap not closed | | 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/parity-checklist.md b/docs/parity-checklist.md index a434d28cf..8e50083c0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue). Every Nikon body without CropArea on raw.pixls.us is surveyed with `cargo xtask raw-frame`: 44 more model / size / depth groups whose data keeps masked or dark lines (D7000 and D5100 issue #976, D800 issue #914, D600 / D610 issue #1236, Df, D3, D3S, D700, D300, D300S, D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D800E, D4 12-bit, D200, D3200, Nikon 1 V1) crop to their camera JPEG's frame measured on raw.pixls.us CC0 samples and other public sample files; the D3100, D5000, D40, D50, D70 and D70s crop to best-guess frames, short of the evidence rule, until more samples confirm them.; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 811353a70..ba4195923 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Nikon Z 6II / Z 8 / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Nikon image areas of bodies without CropArea surveyed and measured, issues #976 / #914 / #1236; NEF decode gaps listed; Nikon Z 6II / Z 8 / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -93,7 +93,7 @@ committed table is stale. |---|---:|---:|---:|---| | Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | | Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | +| Nikon | 16% | 91% | 12% | decoders cover NEF codings but HE and the compressed NEFs without a linearization table (D100, D1X, Coolpix P330 / P7700 / P7800, D4S and D810 small raw); few bodies verified; image area of every body without `CropArea` surveyed | | Fujifilm | 10% | 91% | 45% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 3% | 1% | compressed ORF | @@ -125,6 +125,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | +| NEF | compressed without a linearization table (D100, D1X, Coolpix P330 / P7700 / P7800 NRW, D4S and D810 small raw) | ⬜ | refused as unsupported; one raw.pixls.us sample each | +| NEF | image area of bodies without `CropArea` | 🟡 | 47 model / size / depth groups cropped to their measured camera frame (D800 #914, D7000 #976, Df, D3, D300, D600, D3200 …); the D3100, D5000, D40, D50, D70 and D70s on best-guess frames until more samples confirm them (evidence in each entry's comment, `MEASURED_BODIES` in `crates/raw/src/vendor/nef.rs`). Checked and needing no entry, their data holding only picture beyond the camera frame: D750, D5300, D5500, D3300, D2X, D2Hs, D3X, D5200, Coolpix P6000, Nikon 1 AW1 / J3 / V2 / V3 and the D800's DX / 1.2x / 5:4 modes. `cargo xtask raw-frame FILE…` measures a body | | ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) | | ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured | | ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` | @@ -278,6 +280,7 @@ Beyond decoding: | 2026-10-12 | minor | Olympus E-10: as-shot white balance (`0x1017`/`0x1018`) and black level (`0x1012` in `0x102c` ValidBits units) from the main maker note, issue #1273; ΔE76 against its thumbnail 18.5 → 11.5, camera-look fit now accepted; counts unchanged | | 2026-10-13 | minor | Nikon D90 image area (4288 × 2848 at 10, 10) and white level (3830) measured on 10 of 550 NEFs from one body: its 42 filler columns no longer develop as a pink band | | 2026-10-12 | minor | Hasselblad Lunar verified (a Sony NEX-7 ARW under Make HASSELBLAD, now decoded): 236 verified, 232 preview; an uncompressed Leaf MOS is recognised as a raw (preview only) instead of opening as a greyscale image of its mosaic (#1275) | +| 2026-10-14 | minor | Nikon: every body without `CropArea` on raw.pixls.us surveyed; 44 more model / size / depth groups cropped to their measured camera frame (#976, #914, #1236), 6 of them on best-guess frames; NEF row no longer claims every coding but HE decodes (compressed NEFs without a linearization table listed) | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-13 | minor | Lens profiles: Nikon in-file distortion (NikonNEFInfo `0xc7d5`) for the Z 6II, Z 8 and Z f, validated on 83 frames from three lenses and the CC0 Z f sample (`docs/nikon-lens-corrections.md`); lens score unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..285b00521 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## 2026-10-14 +### Nikon raws without coloured edge bars +- Raws from older Nikon bodies no longer show a thin coloured or dark bar along one edge (the right edge, or the top + of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914, + #1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, + Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show + the area the camera itself frames, a few pixels smaller than before. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c392d0890..b301a1180 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -37,6 +37,11 @@ commands: corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + raw-frame [--json] FILE… + measure where each raw's embedded camera JPEG sits in its sensor data, which edge bands hold no + picture and what the decoder's crop keeps beyond the camera frame (any make with a full-size + embedded JPEG); print image-area entries for Nikon bodies that record no crop (MEASURED_BODIES in + crates/raw/src/vendor/nef.rs) "; fn main() -> ExitCode { @@ -54,6 +59,7 @@ fn main() -> ExitCode { Some("ci") => cmd_ci(), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("raw-frame") => cmd_raw_frame(&rest), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) @@ -69,6 +75,20 @@ fn main() -> ExitCode { } } +/// `cargo xtask raw-frame FILE…`: runs the `raw_frame` example of `lightcraft-engine` on the files. +fn cmd_raw_frame(files: &[&str]) -> Result<(), String> { + if files.is_empty() { + return Err("raw-frame needs one or more raw files (several per camera, with detail across the frame)".into()); + } + eprintln!("$ cargo run --release -p lightcraft-engine --example raw_frame -- {}", files.join(" ")); + let status = cargo() + .args(["run", "--release", "-q", "-p", "lightcraft-engine", "--example", "raw_frame", "--"]) + .args(files) + .status() + .map_err(|e| format!("raw_frame: {e}"))?; + if status.success() { Ok(()) } else { Err("raw_frame failed".into()) } +} + /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { // `cargo run` sets it at run time; prefer that over the compile-time value, which goes stale From 56022ffc2a2e138c8916972d38c92b6bb2a2d03c Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 22:51:38 -0400 Subject: [PATCH 233/315] Batch 36: D600/D610 measured areas listed once (#1053 and #1770 both added them, with the same crops) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raw/src/vendor/nef.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index f4e568cb3..d76a1a9cf 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -178,10 +178,6 @@ const MEASURED_BODIES: &[MeasuredBody] = &[ MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: None }, MeasuredBody { model: "NIKON D5000", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) }, - MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, - MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, - MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, - MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, ]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { From 38adc178a0e1e9b9235b467f53a5603324e97557 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Wed, 14 Oct 2026 21:16:09 -0600 Subject: [PATCH 234/315] ci: add comfortable runtime limits to unbounded jobs (#1836) Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com> --- .github/workflows/packaging-lint.yml | 1 + .github/workflows/release.yml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 8953710ce..7ec855992 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,6 +21,7 @@ concurrency: jobs: lint: + timeout-minutes: 15 runs-on: ubuntu-latest env: ACTIONLINT_VERSION: 1.7.12 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7e3036d6..0dcc3849c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ env: jobs: version: + timeout-minutes: 10 name: version runs-on: ubuntu-latest # No `environment: release` here or on the other jobs that sign nothing: no release secrets. @@ -65,6 +66,7 @@ jobs: echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" macos: + timeout-minutes: 90 name: macOS universal needs: version runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) @@ -114,6 +116,7 @@ jobs: if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi windows: + timeout-minutes: 90 name: Windows ${{ matrix.arch }} needs: version runs-on: windows-latest @@ -173,6 +176,7 @@ jobs: if-no-files-found: error linux: + timeout-minutes: 90 name: Linux ${{ matrix.arch }} needs: version # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, @@ -237,6 +241,7 @@ jobs: if-no-files-found: error flatpak: + timeout-minutes: 90 name: Flatpak ${{ matrix.arch }} needs: [version, linux] # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust @@ -274,6 +279,7 @@ jobs: if-no-files-found: error linux-riscv64: + timeout-minutes: 90 name: Linux riscv64 needs: version # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release @@ -398,6 +404,7 @@ jobs: if-no-files-found: error web: + timeout-minutes: 90 name: Web (wasm) needs: version runs-on: ubuntu-latest @@ -441,6 +448,7 @@ jobs: if-no-files-found: error release: + timeout-minutes: 10 name: Draft GitHub Release needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest From 48d69bfae5c05d70e09b8cbb66aefd57756d4c31 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:21:00 +0200 Subject: [PATCH 235/315] Drop out-of-range GPS coordinates read from XMP Found while testing crates/meta/src/xmp.rs:364 (parse_gps_coord, used at xmp.rs:539-544) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/meta/src/xmp.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..f7f45dee7 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -538,6 +538,8 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> { } if let (Some(lat), Some(lon)) = (first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s))) + && (-90.0..=90.0).contains(&lat) + && (-180.0..=180.0).contains(&lon) { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); @@ -841,6 +843,24 @@ mod tests { assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); } + /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. + #[test] + fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:e="http://ns.adobe.com/exif/1.0/" e:GPSLatitude="{lat}" e:GPSLongitude="{lon}"/>"# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); + } + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas From 851aedc64d8c0fcfc9acc6f247379488c92565a4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 23:25:29 -0400 Subject: [PATCH 236/315] Batch 36: tests and tracker after the merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Visualize HDR (#1555) has no default key: ⇧H cycles the Tool Overlay (#1473, issue #224) - CR3 tests (#1461, #1462) accept adaptive QP 131–175, as #1694 decodes; cr3.md says the R6 Mark II files decode - English fallbacks in the Czech and Polish catalogs use → for the menu triangle; Polish in the keyword-label test - canvas handle tests show pins (Tool Overlay Always); a focused handle keeps its pins shown in Auto - the model-install test and demo deadlock guards wait on deadlines long enough for a busy machine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/demo.rs | 10 ++++----- crates/raw/tests/cr3_variants.rs | 13 ++++++----- crates/raw/tests/crx_properties.rs | 4 ++-- crates/ui-egui/locales/cs-formats.json | 4 ++-- crates/ui-egui/locales/pl-formats.json | 4 ++-- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 5 ++++- crates/ui-egui/src/panels/keyword_list.rs | 4 ++-- crates/ui-egui/src/tests_canvas_keys.rs | 3 +++ crates/ui-egui/src/tests_keymap.rs | 27 +++++------------------ crates/ui-egui/src/tests_model_setup.rs | 4 +++- docs/cr3.md | 10 ++++----- docs/gaps.md | 5 +++-- docs/parity-checklist.md | 18 +++++++-------- docs/raw-parity.md | 17 +++++++------- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 3 ++- docs/whats-new.md | 10 +-------- 18 files changed, 67 insertions(+), 81 deletions(-) diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6a89dda88..2cbc20cee 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -409,7 +409,7 @@ mod tests { #[test] fn a_scene_is_rendered_once_however_many_ask() { use std::sync::atomic::{AtomicUsize, Ordering}; - let (renders, peak) = within(60, || { + let (renders, peak) = within(120, || { let slot = Arc::new(SceneSlot::default()); let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); let render = { @@ -454,7 +454,7 @@ mod tests { /// and that pool's limits; other threads render in parallel on the scene pool. #[test] fn a_rayon_worker_renders_alone_on_its_own_thread() { - let ((how, caller, renderer), (other_how, other_name)) = within(30, || { + let ((how, caller, renderer), (other_how, other_name)) = within(120, || { let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); let slot = SceneSlot::default(); let mut seen = None; @@ -485,7 +485,7 @@ mod tests { /// minutes of CPU time on a small machine). #[test] fn callers_wait_for_the_generation_running() { - let (a, b, renders) = within(30, || { + let (a, b, renders) = within(120, || { let slot = Arc::new(SceneSlot::default()); let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (started_tx, started) = std::sync::mpsc::channel::<()>(); @@ -518,7 +518,7 @@ mod tests { /// generates the scene again. #[test] fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { - let (first, waiters, again) = within(30, || { + let (first, waiters, again) = within(120, || { let slot = Arc::new(SceneSlot::default()); let (started_tx, started) = std::sync::mpsc::channel::<()>(); let (go, go_rx) = std::sync::mpsc::channel::<()>(); @@ -564,7 +564,7 @@ mod tests { assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); assert!(matches!(st.outcome(m), Some(Ok(_)))); // and with threads: a retry racing the waiters of a failed generation never hands them its result - let wrong = within(60, || { + let wrong = within(120, || { let mut wrong = 0; for _ in 0..20 { let slot = Arc::new(SceneSlot::default()); diff --git a/crates/raw/tests/cr3_variants.rs b/crates/raw/tests/cr3_variants.rs index b338228d9..566d4169d 100644 --- a/crates/raw/tests/cr3_variants.rs +++ b/crates/raw/tests/cr3_variants.rs @@ -11,7 +11,7 @@ //! documented gate. //! //! The accepted ranges are the ones in `vendor/crx.rs`: uniform quantization values 4..=44 and -//! adaptive QP values 131..=168. Odd QP-map heights are not a rejected variant any more (the last +//! adaptive QP values 131..=175. Odd QP-map heights are not a rejected variant any more (the last //! QP row is used on its own); the unit tests in `vendor/crx.rs` cover them. The deeper //! `"Canon CRX adaptive low-frequency quantization"` dequantization gate is unreachable through //! this API: plane validation rejects any gain on the low-frequency bands first. It is defence in @@ -351,9 +351,9 @@ fn uniform_quantization_edges_of_the_accepted_range_decode() { // --- qp_map gate ------------------------------------------------------------ #[test] -fn adaptive_qp_outside_131_to_168_is_unsupported() { - // Literal 260 → 130 and 338 → 169 sit just outside the accepted range; literal 5 → −3 is far outside. - for (literal, value) in [(260u32, 130i32), (338, 169), (5, -3)] { +fn adaptive_qp_outside_131_to_175_is_unsupported() { + // Literal 260 → 130 and 352 → 176 sit just outside the accepted range; literal 5 → −3 is far outside. + for (literal, value) in [(260u32, 130i32), (352, 176), (5, -3)] { assert_rejected( &format!("QP {value}"), &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, qp_with_literal(literal))), @@ -364,8 +364,9 @@ fn adaptive_qp_outside_131_to_168_is_unsupported() { #[test] fn adaptive_qp_edges_of_the_accepted_range_decode() { - // Literal 262 → QP 131 and 336 → QP 168; zero coefficients decode to the mid level whatever the step. - for (literal, value) in [(262u32, 131i32), (336, 168)] { + // Literal 262 → QP 131 and 350 → QP 175 (the EOS R6 Mark II's highest); zero coefficients decode to the mid + // level whatever the step. + for (literal, value) in [(262u32, 131i32), (350, 175)] { let file = cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, qp_with_literal(literal))); let raw = decode(&file).unwrap(); assert_eq!(raw.data, RawData::U16(vec![8192; 256]), "QP {value}"); diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/crx_properties.rs index b2bab66fb..32c9f65d3 100644 --- a/crates/raw/tests/crx_properties.rs +++ b/crates/raw/tests/crx_properties.rs @@ -945,7 +945,7 @@ fn every_qp_value_in_the_supported_band_dequantizes_within_the_bit_depth() { let bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; let mut first_hash = 0u64; let mut last_hash = 0u64; - for value in 131i32..=168 { + for value in 131i32..=175 { let fixture = v200_constant_bands_file(16, 16, value, 400, Some((4, 8)), gains, bases); let image = decode(&fixture.file).unwrap(); let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; @@ -957,7 +957,7 @@ fn every_qp_value_in_the_supported_band_dequantizes_within_the_bit_depth() { last_hash = sensor_hash(&image); } assert_ne!(first_hash, last_hash, "the QP value must reach the dequantization step"); - for value in [130i32, 169] { + for value in [130i32, 176] { let fixture = v200_constant_bands_file(16, 16, value, 400, None, gains, bases); assert!(matches!(decode(&fixture.file), Err(RawError::Unsupported(_))), "QP {value}"); assert!(matches!(probe_info(&fixture.file), Err(RawError::Unsupported(_)))); diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index d3ce48fc6..68bcbab88 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -252,7 +252,7 @@ "Create the keyword “{name}”": "Create the keyword “{name}”", "new, inside {parent}": "new, inside {parent}", "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", " in {g} group{}": " in {g} group{}", " · kept, not applied: {}": " · kept, not applied: {}", " — kept but not applied: {}": " — kept but not applied: {}", @@ -262,7 +262,7 @@ "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", "Effect: {name}": "Effect: {name}", "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking ▸ Effect)", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", "{n} preset{}": "{n} preset{}", "{count} more warnings.": "{count} more warnings.", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 682d3c33f..a6286d2f0 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -257,7 +257,7 @@ "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", "Effect: {name}": "Effect: {name}", "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking ▸ Effect)", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", "{n} preset{}": "{n} preset{}", "{count} more warnings.": "{count} more warnings.", @@ -283,7 +283,7 @@ "Create the keyword “{name}”": "Create the keyword “{name}”", "new, inside {parent}": "new, inside {parent}", "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2cea393de..960d2ad95 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -95,7 +95,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), - ("view.visualizeHdr", "Visualize HDR", Some("Shift+H"), "View"), + ("view.visualizeHdr", "Visualize HDR", None, "View"), // in grids S expands/collapses stacks (the engine command it shadows) ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 8c4ee5d02..0f8803126 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2228,7 +2228,10 @@ fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { /// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is /// being dragged. fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { - resp.hover_pos().is_some_and(|q| map.rect.contains(q)) || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) + resp.hover_pos().is_some_and(|q| map.rect.contains(q)) + || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) + // a handle moved with the keyboard stays shown while it has the focus + || crate::canvas_keys::has_focus(&resp.ctx) } fn pin(p: &egui::Painter, c: Pos2, sel: bool) { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 83ce5d706..6fa21969a 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -621,13 +621,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant", "cs"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant", "cs", "pl"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 11, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 12, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/tests_canvas_keys.rs b/crates/ui-egui/src/tests_canvas_keys.rs index 111473643..c78d80820 100644 --- a/crates/ui-egui/src/tests_canvas_keys.rs +++ b/crates/ui-egui/src/tests_canvas_keys.rs @@ -14,6 +14,9 @@ const T: Duration = Duration::from_secs(20); fn detail(panel: &str) -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // pins on whatever the pointer does: these tests move handles with the keyboard, and a headless + // pointer is never over the photo, where the Tool Overlay's default Auto shows them + h.app.ui.tool_overlay = crate::state::ToolOverlay::Always; let r = h.request("ui.set", json!({"view": "detail"}), T); assert_eq!(r["ok"], true, "{r}"); let r = h.request("engine.execute", json!({"command": panel}), T); diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 45c3ba7de..92997d941 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -282,16 +282,16 @@ fn count_folder_picks(h: &mut Headless) -> std::sync::Arc<std::sync::atomic::Ato calls } -/// The declared keys of the two commands these tests bind. +/// The declared key of Open Library. Visualize HDR has none by default: ⇧H cycles the Tool Overlay +/// (issue #224); the user can give Visualize HDR a key in the keymap. #[test] fn open_library_and_visualize_hdr_keys_are_declared() { use crate::shortcuts::{Keymap, parse, shortcut_of}; let keymap = Keymap::new(); - for (id, want) in [("view.visualizeHdr", "Shift+H"), ("app.openLibrary", "Cmd+O")] { - assert_eq!(shortcut_of(&keymap, id), Some(want), "{id}"); - assert!(parse(want).is_some(), "{want} does not parse"); - } + assert_eq!(shortcut_of(&keymap, "app.openLibrary"), Some("Cmd+O")); + assert!(parse("Cmd+O").is_some()); + assert_eq!(shortcut_of(&keymap, "view.visualizeHdr"), None); } /// Opening a library uses the same folder picker as File > Open Library. Cancelling it @@ -308,19 +308,6 @@ fn cmd_o_opens_the_library_picker_once() { assert_eq!(h.app.session.catalog.len(), photos); } -/// Shift+H toggles View > Visualize HDR; Cmd+Shift+H (Histogram) is untouched. -#[test] -fn shift_h_toggles_the_hdr_view() { - let mut h = demo(); - assert!(!h.app.ui.hdr_visualize); - let histogram = h.app.ui.histogram; - press(&mut h, "H", &["shift"]); - assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); - assert_eq!(h.app.ui.histogram, histogram, "Shift+H leaves the Histogram unchanged"); - press(&mut h, "H", &["shift"]); - assert!(!h.app.ui.hdr_visualize, "Shift+H turns it back off"); -} - /// Home / End make the first / last photo of the view the active one, only in the library grids, /// and yield to a key the user gave to another command. #[test] @@ -412,10 +399,6 @@ fn navigation_open_library_and_hdr_keys_wait_for_a_text_field() { assert_eq!(h.app.session.selection.ids.len(), 2, "Shift+Right extends"); press(&mut h, "O", &["cmd"]); assert_eq!(picks(), 1, "Cmd+O asks for a folder"); - press(&mut h, "H", &["shift"]); - assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); - press(&mut h, "H", &["shift"]); - assert!(!h.app.ui.hdr_visualize); select(&mut h, middle); // with the search box focused, none does diff --git a/crates/ui-egui/src/tests_model_setup.rs b/crates/ui-egui/src/tests_model_setup.rs index 3068d8221..1366b745a 100644 --- a/crates/ui-egui/src/tests_model_setup.rs +++ b/crates/ui-egui/src/tests_model_setup.rs @@ -69,7 +69,9 @@ fn accepting_a_local_model_installs_in_background_and_resumes_the_pending_action assert_eq!(result["started"], "local-install"); assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); let ctx = egui::Context::default(); - for _ in 0..600 { + // a deadline, not a count of tries: the install copies on a background thread, slow on a busy machine + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { crate::panels::denoise::pump(&mut app, &ctx); if app.session.denoise_photo_state(raw) != lightcraft_engine::denoise::PhotoState::NoModel { break; diff --git a/docs/cr3.md b/docs/cr3.md index e99fbb604..79babaea4 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -48,7 +48,7 @@ only come from the gate named here. | Variant | Status | Gate | Exact rejection | |---|---|---|---| | Version `0x100`, four Bayer planes, encoding 0, wavelet levels 0 (lossless) or 3 (C-RAW, horizontal tiles) | Supported | `supports()` in `vendor/crx.rs` | none | -| Version `0x200`: single-tile 14-bit three-level C-RAW with adaptive quantization, QP map of any height (an odd height leaves the last QP row without a partner and it is used on its own), QP values 131–168 | Supported | `supports()`, `qp_map()` | none | +| Version `0x200`: single-tile 14-bit three-level C-RAW with adaptive quantization, QP map of any height (an odd height leaves the last QP row without a partner and it is used on its own), QP values 131–175 (169–175: EOS R6 Mark II) | Supported | `supports()`, `qp_map()` | none | | Version other than `0x100`/`0x200`, or wavelet levels other than 0 and 3 | Rejected | `supports()` | `Canon CRX version {version:#x}, encoding {encoding}, {planes} planes, {levels} wavelet levels` | | Bit depth outside 8–16 (the `CMP1` parser only accepts 1–16, so 1–7 is what can arrive) | Rejected | `supports()` | `Canon CRX {bits}-bit samples` | | C-RAW with vertical tiles (tile height below the image height, three levels) | Rejected | `supports()` | `Canon CRX C-RAW with vertical tiles` | @@ -58,7 +58,7 @@ only come from the gate named here. | Partial-subband coding | Rejected | `validate_planes()` | `Canon CRX partial subband coding` | | Adaptive quantization gain on a low-frequency band (bands 0–3) | Rejected | `validate_planes()` | `Canon CRX adaptive quantization subband parameters` | | Uniform quantization value outside 4–44 | Rejected | `quant_step()`, called from `validate_planes()` | `Canon CRX quantization value {value}` | -| Adaptive QP value outside 131–168 | Rejected | `qp_map()` | `Canon CRX adaptive QP value {value}` | +| Adaptive QP value outside 131–175 | Rejected | `qp_map()` | `Canon CRX adaptive QP value {value}` | | `ff11`/`ff12`/`ff13` markers in a `0x100` stream | Rejected | `layout()` | `Canon CRX ff11/ff12/ff13 marker family` | | Marker header of the wrong length (8 bytes expected for `ff01`, `ff02`, `ff03` and `ff12`, 16 for `ff11` and `ff13`) | Rejected | `layout()` | `Canon CRX marker {marker:#x} header length {size}` | | `0x200` tile header with a wrong magic, a nonzero reserved field, an empty QP map or QP padding above 7 | Rejected | `layout()` | `Canon CRX adaptive quantization tile header` | @@ -200,9 +200,9 @@ hashes pin LightCraft's own decode, and the check is black-box against the camer The EOS R6 Mark III (`ColorData` version 66) reads its as-shot white balance and black level as described at the top of this page; its white level comes from the data. -Not every C-RAW file of a verified body decodes: the decoder accepts adaptive QP values 131–168 only, and a -user reports 3 of 40 EOS R6 Mark II C-RAW files with QP 172 or 175 ("Canon CRX adaptive QP value 172"), which -open as their preview. EOS R7 C-RAW (the odd-height QP map above) has only been checked on six paired RAW / +Not every C-RAW file of a verified body decodes: the decoder accepts adaptive QP values 131–175 only. The 3 of +40 EOS R6 Mark II C-RAW files with QP 172 or 175 a user reported (they opened as their preview with "Canon CRX +adaptive QP value 172") now decode (#1694). EOS R7 C-RAW (the odd-height QP map above) has only been checked on six paired RAW / C-RAW captures; the pinned R7 file is lossless, so R7 C-RAW still has no black-box reference. ## White level diff --git a/docs/gaps.md b/docs/gaps.md index 68c2595e2..dcca2f0e0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 10: 515 of 1,446 models verified, ~84% of photographers on a verified body) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 259 of 1,446 Adobe models verified (18%), ~59% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 515 of 1,446 Adobe models verified (36%), ~84% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -113,3 +113,4 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-14 | minor | Gap 10 shrinks: 97 Canon CR2, Pentax and Ricoh models verified on one CC0 file each (334 models, ~50% on a verified body) | +| 2026-10-14 | minor | Gap 10: 515 of 1,446 models verified (36%), ~84% of photographers on a verified body (integration batch 36) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee05a4f55..a1d510677 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import: persistent result, offline Local promotion and library-switch dismissal; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry; LR-IMP-FORMATS: preview-only raws rechecked after an update, #662) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-VIEW-ZOOM: full-size slider-drag drafts on the GPU, timed with their frame, issue #330; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -38,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | +| K. Masking (MASK) | 15 | 5 | 5 | 0 | 7/8 (88%) | 7/7 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -51,13 +49,13 @@ cited here still exists. Formerly docs/parity.md. | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 23 | 7 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | +| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 36 | 22 | 27 | 8 | — | 24/26 (92%) | -| **Total** | 411 | 59 | 64 | 35 | 193/201 (96%) | 146/159 (92%) | +| Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | +| Lightroom Classic extras | 36 | 23 | 27 | 8 | — | 24/26 (92%) | +| **Total** | 413 | 61 | 63 | 35 | 193/201 (96%) | 147/160 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.5%** of 534 in-scope rows — P0 98.0% of 201 · P1 95.0% of 159 · P2 52.9% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.6%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 53.4% of 175. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -371,7 +369,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) | | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | -| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`), `crates/ui-egui/src/tests_keymap.rs` (`shift_h_toggles_the_hdr_view`) | grey below SDR white, four colour bands by stops above it; ⇧H toggles it (⌘⇧H stays the Histogram panel), and a text field with the focus keeps the key | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it; ⇧H toggles it (⌘⇧H stays the Histogram panel), and a text field with the focus keeps the key | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | | LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 3c514e060..fa249d5d8 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (22 more CR3 bodies verified on one CC0 file each; EOS R6 Mark III white balance; Nikon Z 6II / Z 8 / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -15,13 +15,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **259** (18%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **919** (64%) | measured from the format rules (631 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **232** (16%) | measured | -| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 95 / 627 / 24 / 8 of 754 | measured | -| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | +| Decoded from sensor data and **verified** on a real file | **515** (36%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **675** (47%) | measured from the format rules (631 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **218** (15%) | measured | +| **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 159 / 563 / 24 / 8 of 754 | measured | +| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~93%** | estimated: measured classes × written usage weights × per-format confidence | +| …of which on a body we have verified | **~84%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) | @@ -297,3 +297,4 @@ Beyond decoding: | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | | 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) | | 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged | +| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9f56bde58..90295104c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -47,7 +47,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | -| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 82% of Adobe's 1,446 models decode (36% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | | UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | @@ -312,3 +312,4 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | 2026-10-10 | minor | Added mainstream practitioner (~51%) and essentials user (~63%) numbers; full ready re-derived from written weights × discounts (55% → 46%); user-issue evidence; beta hours 340–600 | | 2026-10-10 | minor | Stage re-checked against the core-workflow alpha gate: passes, stays alpha | | 2026-10-10 | major | Created per the progress-docs standard: full re-measure (checklist recount, installed Lightroom 9.6 bundle listing, code counts, catalogs, CI), feature areas with weights, dimensions, AI, stage, calibration; merged ROADMAP's *Where we stand* and *Parity estimate* | +| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 17a8b6daa..e0bf171ba 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -70,7 +70,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. **Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). -**Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… and `Shift+H` toggles View → Visualize HDR (`Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. +**Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… ; View → Visualize HDR has no default key (`Shift+H` cycles the Tool Overlay, issue #224; `Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, @@ -130,6 +130,7 @@ a real screen reader on any platform yet. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it | | 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | | 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list | | 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status | diff --git a/docs/whats-new.md b/docs/whats-new.md index 89a3b9672..d1b0ced6f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,6 +1,6 @@ # What's new in LightCraft -## 2026-10-15 +## 2026-10-14 ### Lightroom radial masks on the right side - Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now @@ -8,8 +8,6 @@ Lightroom stays inverted, and older Radial Filters keep their Effect Outside / Invert Mask setting. Photos imported before this keep the masks they got: Photo ▸ Read Metadata from File reads a sidecar's again. -## 2026-10-15 - ### Delete in an album removes from the album only - In an album, the Delete key now takes the selected photos out of the album, as in Lightroom. It used to move them to Recently Deleted, so they also left their folder's view (issue #1805). They stay in the library and in their @@ -17,15 +15,11 @@ To delete photos from the library from an album, use Photo ▸ Delete Photo or right-click ▸ Delete Photo. In a smart album, Delete changes nothing: its rules decide what it shows. -## 2026-10-15 - ### Local folders select a photo - Showing a Local folder now makes its first photo the active one, as albums and the other sources do (issue #1447), or keeps the photo you had when the folder holds it. Before, the photo from the previous view stayed active: Photo Grid marked nothing, and Detail, the histogram and the photo info showed a photo that isn't in the folder. -## 2026-10-14 - ### HDR merges of moving lights, and many brackets at once - A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a @@ -281,8 +275,6 @@ "LightCraft stopped unexpectedly" and why, instead of "could not open its window", and the next start renders on the CPU and explains how to turn the GPU back on. -## 2026-10-13 - ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's From eb4cfe6c56a259f44f10b5b7f16e0f9db3a94f5b Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Wed, 14 Oct 2026 23:39:57 -0400 Subject: [PATCH 237/315] Batch 36: Canon RP C-RAW sample listed once (#1029 and #1453 both pinned it); the face-tile test waits up to a minute for its pictures on a busy machine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 5 +++-- crates/ui-egui/src/headless.rs | 2 +- xtask/src/main.rs | 5 ----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f21b043c8..a308a7f4f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~60% | 81% of 1,446 Adobe models decode, 18% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~60% | 82% of 1,446 Adobe models decode, 36% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -200,3 +200,4 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | 2026-10-02 | major | Parity estimate and effort table | | 2026-09-30 | major | Created | | 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (334 models, 23%); headline numbers unchanged | +| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36's CC0 per-model checks) | diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e0ba72d15..6178990b0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1703,7 +1703,7 @@ mod tests { // the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a // picture: the count is a little under the number of tiles) let started = Instant::now(); - while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(20) { + while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(60) { h.step(); std::thread::sleep(Duration::from_millis(20)); } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index dd49f1705..81feaff70 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -689,11 +689,6 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4543/nice/Canon%20-%20EOS%20R%20-%203:2.CR3", "1b3efe0664a28a0decb53e225cbfecdbc0dd27d85abdc4683fc2a4add5129b4d", ), - ( - "cr3-canon-rp-craw.cr3", - "https://raw.pixls.us/getfile.php/4672/nice/Canon%20-%20EOS%20RP%20-%203:2.CR3", - "bd3270610593d37ff8a399908bb9406a4e32f9151d55bf19ac1cf8f10d787b46", - ), ( "cr3-canon-g5xm2-craw.cr3", "https://raw.pixls.us/getfile.php/3506/nice/Canon%20-%20PowerShot%20G5%20X%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", From 1130bda456dd0999e0325e3d41fc3280e5b59e14 Mon Sep 17 00:00:00 2001 From: Ege Gursel <gurselege09@gmail.com> Date: Thu, 15 Oct 2026 00:23:41 -0400 Subject: [PATCH 238/315] fix(import): keep Lightroom luminance ranges open at white, report unmapped local sliders Two of the three problems reported in #1840: - A luminance range at full strength up to white (LumRange "a b 1 1", a typical highlights mask) ended at our scene white (0 EV) with no feather, so anything brighter (the sun, speculars, a sky after a positive exposure) fell out of the mask along a hard edge. That end now stays open. - Correction sliders we have no counterpart for (LocalGrain, ...) were dropped silently. They are now listed in `unmapped` as "Mask: LocalGrain"; LocalCurveRefineSaturation 100 counts as neutral. Adobe base looks, the third problem, are handled by #1469. Also adds my entry to contributors/people.toml. --- contributors/people.toml | 4 ++++ crates/engine/src/crs_masks.rs | 44 +++++++++++++++++++++++++++++++++- docs/xmp-interop.md | 3 ++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..2548de569 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -31,3 +31,7 @@ display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b..ea831e9c3 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -87,6 +87,21 @@ fn adjust(c: &XmpValue) -> Value { Value::Object(a) } +/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set: +/// reported, never dropped silently. +fn unmapped_local(c: &XmpValue) -> Vec<String> { + let XmpValue::Struct(m) = c else { return Vec::new() }; + m.keys() + .filter_map(|k| { + let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?; + // 100 = "curve saturation unchanged", written with every correction + let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 }; + let set = num(c, k).is_some_and(|v| v != neutral); + (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string()) + }) + .collect() +} + /// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log /// luminance on −8..+4 EV mapped to 0..1. fn lightness_to_range(l: f64) -> f64 { @@ -163,8 +178,12 @@ fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> { Some("2") => { let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); let [a, b, c, d] = q[..] else { return Err("luminance range".into()) }; + // full strength at white (1, the top of their scale) takes in everything brighter: ours + // goes on above scene white, so that end stays open instead of cutting off at 0 EV + let open_top = c >= 1.0; let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d)); - json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)}) + let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) }; + json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather}) } Some("3") => { let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); @@ -201,6 +220,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) { if parts.is_empty() { continue; } + skipped.extend(unmapped_local(c)); // brush dabs of one correction are one brush component let mut merged: Vec<Value> = Vec::new(); for p in parts { @@ -454,6 +474,28 @@ mod tests { assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); } + #[test] + fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"> + <crs:MaskGroupBasedCorrections><rdf:Seq> + <rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100"> + <crs:CorrectionMasks><rdf:Seq> + <rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li> + </rdf:Seq></crs:CorrectionMasks> + </rdf:Description></rdf:li> + </rdf:Seq></crs:MaskGroupBasedCorrections> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); + } + #[test] fn lrtemplate_corrections() { let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..ee78c67eb 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -181,11 +181,12 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct | `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV | | `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 | | `LocalToningHue` | `adjust.color_hue` | degrees | +| other `Local*` sliders (`LocalGrain`…) | — | no counterpart in our masks: reported as `Mask: LocalGrain` when set (`LocalCurveRefineSaturation` 100 is neutral) | | `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point | | `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape); `Flipped` → invert | | `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases | | `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed | -| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported | +| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; a range at full strength at white (third `LumRange` value 1) stays open above our scene white; colour ranges are reported | | `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | | Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount From 31f4ad43febfe7efacafe863713c2367cb63253b Mon Sep 17 00:00:00 2001 From: Ege Gursel <gurselege09@gmail.com> Date: Thu, 15 Oct 2026 00:29:43 -0400 Subject: [PATCH 239/315] contributors: re-add my people.toml entry lost in the merge --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..437a836cf 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true From 81a9569301426aafe844e17056a88ba4154df207 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 00:42:56 -0400 Subject: [PATCH 240/315] Batch 36: rustfmt after merging #1802 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/dialogs.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8e73e62f6..44509cd06 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -2251,7 +2251,9 @@ pub enum ConfirmStyle { pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { match dlg { - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => ConfirmStyle::Plain, + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { + ConfirmStyle::Plain + } _ if only_shows(dlg) => ConfirmStyle::Plain, _ => ConfirmStyle::Primary, } @@ -2261,7 +2263,12 @@ pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { fn only_shows(dlg: &Dialog) -> bool { matches!( dlg, - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew | Dialog::ManageEffects { .. } ) } From 60b938ab52abf962c0459affe780347802488edf Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 00:53:53 -0400 Subject: [PATCH 241/315] Batch 37: format catalogs list the strings this batch adds, in English until translated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/it-formats.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 17a48eeff..70a1bafbe 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -289,5 +289,7 @@ "{n} originals opened in the other editor": "{n} originals opened in the other editor", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", "Effect: {name}": "Effect: {name}", - " in {g} group{}": " in {g} group{}" + " in {g} group{}": " in {g} group{}", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" } From 3c489dc4087a6c953922585186de5d32a8424d75 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 00:56:44 -0400 Subject: [PATCH 242/315] Batch 37: the print commands (#1215) take the Result that Session::targets returns since #1773 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/print.rs | 6 +++--- crates/ui-egui/src/export_task.rs | 2 +- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/print_view.rs | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index b9033ad05..05a851893 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -43,7 +43,7 @@ fn state(s: &Session, photos: usize) -> Value { } fn reply(s: &Session) -> Result<Value> { - Ok(state(s, s.targets(&Value::Null).len())) + Ok(state(s, s.targets(&Value::Null)?.len())) } /// Applies `patch` over `dst`: objects merge key by key, anything else replaces. @@ -64,7 +64,7 @@ fn merge(dst: &mut Value, patch: &Value, depth: usize) { } fn get(s: &mut Session, p: &Value) -> Result<Value> { - Ok(state(s, s.targets(p).len())) + Ok(state(s, s.targets(p)?.len())) } fn set(s: &mut Session, p: &Value) -> Result<Value> { @@ -245,7 +245,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(ID, "id must be a photo id")); } s.check_write_target(path).map_err(|e| bad(ID, e))?; - let ids = s.targets(p); + let ids = s.targets(p)?; let settings = s.print.clone(); // a printer profile that can't be used fails the print before anything renders crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?; diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 8bcbf532d..d1a1acebc 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -143,7 +143,7 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str app.session.check_write_target(&path)?; let guard = app.session.original_guard(); let settings = app.session.print.clone(); - let ids = app.session.targets(params); + let ids = app.session.targets(params).map_err(|e| e.to_string())?; let prepared = compose::prepare(&mut app.session, &settings, &ids)?; let total = prepared.len(); let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e639955fe..3a1dac6c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,10 +78,10 @@ mod tests_dialog_primary; #[cfg(test)] mod tests_dialogs; #[cfg(test)] -mod tests_empty_library; -#[cfg(test)] mod tests_edit_layout; #[cfg(test)] +mod tests_empty_library; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index b7f6d44aa..71e836bc8 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -153,7 +153,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { let height = (screen.height() - 190.0).clamp(280.0, 780.0); let mut pu = std::mem::take(&mut app.ui.print); let st = app.session.print.clone(); - let ids = app.session.targets(&json!({})); + // (no `id`/`ids` given: the selection, which never fails to parse) + let ids = app.session.targets(&json!({})).unwrap_or_default(); // the page shown stays inside the print let pages = st.layout.page_count(ids.len()); pu.page = pu.page.min(pages.saturating_sub(1)); From edfd4bc80a55f40964795d5723ef8ed55c44de9d Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:07:22 -0400 Subject: [PATCH 243/315] Batch 37: conflict markers left in three tracker headers by the #1327 merge resolved Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 8 +------- docs/parity-checklist.md | 4 ---- docs/target-app-parity.md | 8 +------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d64d4cff5..d642c7af9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -<<<<<<< HEAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -||||||| 21125577 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled; camera-coverage sweep: 30 more models verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 ->>>>>>> rb/1327 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 79fdeaff5..0f9d2b943 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -795,12 +795,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -<<<<<<< HEAD | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | -||||||| 21125577 -======= | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | ->>>>>>> rb/1327 | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) | | 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it | | 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 11acb841e..07ae89fd7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,12 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -<<<<<<< HEAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) -||||||| 21125577 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (localization ~52% → ~55%: de, es, fr, ja, uk catalogs filled; 30 more camera models verified on 40 CC0 files) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) ->>>>>>> rb/1327 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas From e5cc449bd1486615443503a857b6c42a6918aae0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:10:07 -0400 Subject: [PATCH 244/315] Batch 37: the contact-sheet export and batch-merge tests wait longer on a busy machine (they pass alone, timed out under the full test run) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/export_task.rs | 2 +- crates/ui-egui/src/tests_activity.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index d1a1acebc..7ac6a838a 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -341,7 +341,7 @@ mod contact_sheet_tests { crate::pick::poll(&mut app, &ctx); assert!(app.pending_picks.is_empty()); assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); - let deadline = Instant::now() + Duration::from_secs(20); + let deadline = Instant::now() + Duration::from_secs(90); loop { // Poll the real worker without starting unrelated gallery renders each frame. poll(&mut app, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 493998fd3..126b3ef58 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -9,7 +9,8 @@ use crate::headless::Headless; use crate::panels::activity::{WIDTH, count_text}; use crate::{LightcraftApp, Services}; -const T: Duration = Duration::from_secs(20); +// generous: merges and renders run on worker threads that a busy machine (a full workspace test run) slows a lot +const T: Duration = Duration::from_secs(60); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); From 52a3b0ed84483f1458bf41f6cc7ca11dc9ba7eff Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:12:03 -0400 Subject: [PATCH 245/315] Batch 37: #1471's test mosaic sets #1176's lens_metadata Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/merge/src/frame.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 731f91a14..cb74e23ed 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -308,6 +308,7 @@ mod tests { wb_multipliers, linearized: false, opcodes: OpcodeLists::default(), + lens_metadata: Default::default(), metadata: Metadata::default(), } } From b00d7c02679c88f4410d35e5a20ef89f30584b0c Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:33:01 -0400 Subject: [PATCH 246/315] Batch 37: the contact-sheet export test's deadline is a hang guard (it queues behind other tests' demo scenes) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/export_task.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 7ac6a838a..1aec84820 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -341,7 +341,9 @@ mod contact_sheet_tests { crate::pick::poll(&mut app, &ctx); assert!(app.pending_picks.is_empty()); assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); - let deadline = Instant::now() + Duration::from_secs(90); + // a hang guard, not a speed check: the render queues behind other tests' demo scenes (generated one at a + // time process-wide), which on a loaded machine takes minutes + let deadline = Instant::now() + Duration::from_secs(300); loop { // Poll the real worker without starting unrelated gallery renders each frame. poll(&mut app, &ctx); From 2a4b53f86cb9cd23c03d6450ef9d39aa6f4b2e39 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:34:43 -0400 Subject: [PATCH 247/315] Batch 37: parity summary refreshed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/parity-checklist.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3bc5030ba..f04e80283 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 36 | 23 | 27 | 8 | — | 24/26 (92%) | -| **Total** | 413 | 61 | 63 | 35 | 193/201 (96%) | 147/160 (92%) | +| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | +| **Total** | 417 | 61 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.6%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 53.4% of 175. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 175. <!-- /parity:summary --> ## Top gaps and shortcut notes From a981538d056ef3b7fdec6a39668df37a9af60197 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:36:59 -0400 Subject: [PATCH 248/315] Batch 37: the #1506 What's New entry once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/whats-new.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 4006c0fc4..f85aa7c50 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -129,9 +129,6 @@ - The top bar has an Import button, beside Export, in every library. ### Reliability -- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a - script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, - or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. ### One kind of button - Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent), From 73ee54fe62e66aab2c22704f7c8d31181f86d8d8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:48:46 -0400 Subject: [PATCH 249/315] Batch 38: format catalogs list the strings this batch adds, in English until translated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/locales/it-formats.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 70a1bafbe..8edab510a 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -291,5 +291,9 @@ "Effect: {name}": "Effect: {name}", " in {g} group{}": " in {g} group{}", "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "Already in “{}”": "Already in “{}”", + "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", + "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." } From e44e7157fc683984c69192d9d65998cf5edf406c Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:58:56 -0400 Subject: [PATCH 250/315] Batch 38: rustfmt and parity summary after the merges Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/camera_preview.rs | 3 +-- docs/parity-checklist.md | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 50ba5641e..614c58c60 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -88,8 +88,7 @@ pub(crate) fn starting_colour( lens: Option<&LensGeometry>, embedded: Option<&lightcraft_develop::EmbeddedLens>, ) -> (Xy, CameraTransform, Option<CameraLook>) { - let (xy, t, look) = - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, embedded); return (xy, t, look); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5ac842798..2f4758687 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 417 | 61 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | +| **Total** | 417 | 62 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 175. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 538 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 176. <!-- /parity:summary --> ## Top gaps and shortcut notes From a94fc6bc84b3161afe195242b9e2fecd858e178c Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 01:59:50 -0400 Subject: [PATCH 251/315] Batch 38: What's New lists the Nikon profile and lens-corrected colour notes once Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/whats-new.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index cf0bb1c06..177d7b140 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -127,15 +127,6 @@ #1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show the area the camera itself frames, a few pixels smaller than before. -### Nikon Z 6II colour profile -- Raws from the Nikon Z 6II now start from a colour profile built from a few hundred Z 6II photos, the same way the - Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo - shows only a little of come out closer to the camera's JPEG. - -- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a - script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, - or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. - ### An empty library leads to Import - A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app, @@ -155,11 +146,6 @@ - In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive choice is never the eye-catching default. -### Closer camera colour for lens-corrected raws -- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the - Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that - didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt. - ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From f0b5f0339a73c33dd218b829a28fa8eff2a786f7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 02:38:20 -0400 Subject: [PATCH 252/315] Batch 39: #1474's hint test expects the value hint #1604 gave the scrubbable value Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/tests_slider_reset.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tests_slider_reset.rs b/crates/ui-egui/src/tests_slider_reset.rs index 6d0357fcb..ead2491b1 100644 --- a/crates/ui-egui/src/tests_slider_reset.rs +++ b/crates/ui-egui/src/tests_slider_reset.rs @@ -131,7 +131,7 @@ fn a_sliders_name_and_thumb_say_a_double_click_resets_it() { assert!(!shown(&h), "no hint away from the thumb"); // the value keeps its own hint ask(&mut h, "ui.hoverWidget", json!({"id": "sliderValue:light.exposure"})); - assert!(h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t == "Click to type a value"))); + assert!(h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"))); } /// Typing a value (KEY-SLIDER-TYPE, #1362) still works next to the reset. From a22f9a6d4651d498bddc5724e9393acf0332b77e Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 02:51:35 -0400 Subject: [PATCH 253/315] Batch 39: hang guards of the slideshow PDF export and slow-import tests allow a loaded machine (both pass in seconds alone) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/slideshow_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/slideshow_export.rs b/crates/ui-egui/src/slideshow_export.rs index c8c0794c7..62996e421 100644 --- a/crates/ui-egui/src/slideshow_export.rs +++ b/crates/ui-egui/src/slideshow_export.rs @@ -99,7 +99,7 @@ mod tests { fn run_dialog(h: &mut crate::headless::Headless) -> Value { let dialog = h.app.ui.dialog.take().unwrap(); crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); - let deadline = Instant::now() + Duration::from_secs(120); + let deadline = Instant::now() + Duration::from_secs(300); let mut seen_row = false; loop { h.step(); From fd9d045c75b8fe3f3484c76b73a68d43e1789486 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 02:57:33 -0400 Subject: [PATCH 254/315] CI: the packaging scripts check out the craft-fonts revision release.yml pins (3d9ab3a, with Noto Sans Thai) Windows ARM64 on main failed since batch 36: #1479's scripts pinned 8dcdacd, which predates the Thai face #1311 made required. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/releasing.md | 2 +- packaging/fonts.sh | 2 +- packaging/windows/fonts.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index 44a1afaaa..914a7ad84 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -66,7 +66,7 @@ the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a Local macOS, Linux and Windows packaging scripts automatically use the sibling `../craft-fonts` checkout. If it is absent, they clone it at revision -`8dcdacd5153e64560d109541a47d806f26f048c0` (keep the pins in `packaging/fonts.sh` and +`3d9ab3a132ed0eff457e424226a435a901da30d3` (keep the pins in `packaging/fonts.sh` and `packaging/windows/fonts.ps1` in sync with the release workflow). Existing checkouts are left untouched. Set `CRAFT_FONTS_DIR` to use another checkout; relative paths resolve from the workspace root. The script requires the Chinese and Japanese diff --git a/packaging/fonts.sh b/packaging/fonts.sh index 61dc913f4..70d9fe14e 100644 --- a/packaging/fonts.sh +++ b/packaging/fonts.sh @@ -10,7 +10,7 @@ prepare_fonts() { if [ -z "${CRAFT_FONTS_DIR:-}" ] && [ ! -e "$font_dir" ]; then # Same revision as release.yml. Never update or reset an existing checkout. - local revision=8dcdacd5153e64560d109541a47d806f26f048c0 + local revision=3d9ab3a132ed0eff457e424226a435a901da30d3 echo "==> downloading craft-fonts ($revision) to $font_dir" if ! git clone --no-checkout https://github.com/storytold/craft-fonts "$font_dir" || ! git -C "$font_dir" checkout --detach "$revision"; then diff --git a/packaging/windows/fonts.ps1 b/packaging/windows/fonts.ps1 index c927fbb7a..74f383fd8 100644 --- a/packaging/windows/fonts.ps1 +++ b/packaging/windows/fonts.ps1 @@ -6,7 +6,7 @@ function Initialize-CraftFonts([string] $WorkspaceRoot) { if (-not $env:CRAFT_FONTS_DIR -and -not (Test-Path -LiteralPath $fontDir)) { # Same revision as release.yml and packaging/fonts.sh. Existing checkouts stay untouched. - $revision = '8dcdacd5153e64560d109541a47d806f26f048c0' + $revision = '3d9ab3a132ed0eff457e424226a435a901da30d3' Write-Output "==> downloading craft-fonts ($revision) to $fontDir" & git clone --no-checkout https://github.com/storytold/craft-fonts $fontDir if ($LASTEXITCODE -ne 0) { throw 'Could not download craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' } From d6f63f244ff930e678c7273b67ed16a66ac48a55 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 03:00:59 -0400 Subject: [PATCH 255/315] Batch 39: parity summary refreshed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/parity-checklist.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 66f0fca76..043aebb50 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -51,11 +51,11 @@ cited here still exists. Formerly docs/parity.md. | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | +| Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 417 | 62 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | +| **Total** | 418 | 62 | 59 | 35 | 193/201 (96%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 538 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 539 in-scope rows — P0 98.0% of 201 · P1 95.0% of 161 · P2 55.7% of 176. <!-- /parity:summary --> ## Top gaps and shortcut notes From 00dced3dae9878634e5d128c46965e35f543663e Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 03:07:35 -0400 Subject: [PATCH 256/315] Batch 39: #1829's straight brush lines carry pen pressure as the author merged them with #1033 (the whole masks.rs from the PR head) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/masks.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 542ff5d11..0efb3b658 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -167,9 +167,7 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<Br let mut points = Vec::with_capacity(to.points.len() + 1); points.push(from); points.extend(to.points.iter().copied()); - // (the joining point takes the pen pressure of the stroke's first one) - let pressure = - if to.pressure.is_empty() { Vec::new() } else { std::iter::once(to.pressure_at(0)).chain(to.pressure.iter().copied()).collect() }; + let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() }; return vec![BrushStroke { points, pressure, ..to }]; } // about 5% of size, or 2 points of feather or flow, per piece @@ -190,8 +188,7 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<Br size: lerp(last.size, to.size, t1), feather: lerp(last.feather, to.feather, t1), flow: lerp(last.flow, to.flow, t1), - // (one pressure for the whole piece: the stroke's first point's) - pressure: if to.pressure.is_empty() { Vec::new() } else { vec![to.pressure_at(0)] }, + pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() }, ..to.clone() } }) From e6dceb9f899fdb6ccceed8b4ab49d1655055263f Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 03:43:21 -0400 Subject: [PATCH 257/315] =?UTF-8?q?Batch=2040:=20#1027's=20LR-VIEW-ZOOM=20?= =?UTF-8?q?row=20(=E2=9C=85,=20mask=20and=20proof=20zoom=20windows)=20kept?= =?UTF-8?q?;=20parity=20summary=20refreshed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/parity-checklist.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0db3c6262..26bb0dd4d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -28,7 +28,7 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 2 | 1 | 0 | 8/9 (89%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 16 | 1 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 418 | 62 | 59 | 35 | 193/201 (96%) | 148/161 (92%) | +| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 539 in-scope rows — P0 98.0% of 201 · P1 95.0% of 161 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | 🟡 | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Modes that draw another picture over the loupe (mask overlays, soft proofing) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, soft-proofing or showing an overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Still 🟡: mask overlays and soft proofing have no window (a full-size whole-frame render at 1:1) | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | From b17483a3ce7dddee2847e3c84e010078e5addab6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 03:45:00 -0400 Subject: [PATCH 258/315] Batch 40: tracker rows that earlier batches' doc merges dropped are back Batches 36's doc conflicts kept main's side of rows both sides had touched; for some, main had not changed the row, so the PR's update was lost. Restored: LR-VIEW-FILMSTRIP (#1482), KEYC-COMPARE (#1491 + #1555), LR-IMP-LOCAL (#1547 + #1820), LR-LIB-STACK (#1556), gaps 28 (#1555), 2 (#1029) and 23 (#1038), ROADMAP UI/UX (#1038). The raw-parity by-maker and by-use tables are re-measured with coverage.py (545 verified, ~94% / ~85%). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ROADMAP.md | 2 +- docs/gaps.md | 6 +++--- docs/parity-checklist.md | 8 ++++---- docs/raw-parity.md | 41 ++++++++++++++++++++-------------------- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 36f1ec980..f142cd226 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | | ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | | Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | diff --git a/docs/gaps.md b/docs/gaps.md index 3bd61c294..f11a74f7a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -16,7 +16,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| | 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | -| 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | +| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) | | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | @@ -36,14 +36,14 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-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) | +| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), 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) | | 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**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [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; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 26bb0dd4d..95dcd2aef 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | @@ -107,7 +107,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | @@ -130,7 +130,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | @@ -777,7 +777,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | -| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | +| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys | | KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), filter-bar keys | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index f02d91ca1..90ee6eba7 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -78,45 +78,45 @@ committed table is stale. | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 86 | 51 | 10 | 2 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported | -| Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | -| Samsung | 128 | 4 | 115 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | -| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | +| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported | +| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | +| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | +| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | -| Nikon | 99 | 73 | 23 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only | +| Nikon | 99 | 77 | 19 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only | | Fujifilm | 97 | 65 | 9 | 23 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | -| Google | 88 | 1 | 87 | 0 | 0 | — | -| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | +| Google | 88 | 5 | 83 | 0 | 0 | — | +| Olympus | 71 | 17 | 3 | 51 | 0 | **compressed ORF** | | Leica | 61 | 27 | 34 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — | -| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | +| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 4 | 17 | 0 | 0 | DNG | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **259 (18%)** | **919 (64%)** | **232 (16%)** | **36 (2%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) | +| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | | ## By use (estimated) | Bucket | Weight | Camera fully works | On a verified body | Why | |---|---:|---:|---:|---| -| Canon | 33% | 94% | 72% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 | -| Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 98% | 83% | decoders cover every NEF coding but HE; 73 bodies verified | +| Canon | 33% | 95% | 93% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 | +| Sony | 27% | 98% | 92% | bulk-checked | +| Nikon | 16% | 98% | 89% | decoders cover every NEF coding but HE; 77 bodies verified | | Fujifilm | 10% | 94% | 92% | | | Panasonic | 4% | 100% | 98% | bulk-checked | | OM / Olympus | 3% | 6% | 5% | compressed ORF | -| Phones | 3% | 93% | 1% | DNG | -| Pentax / Ricoh | 1.5% | 94% | 14% | | +| Phones | 3% | 93% | 4% | DNG | +| Pentax / Ricoh | 1.5% | 99% | 80% | | | Leica | 1% | 99% | 72% | the Q3 verified, 13 more bodies pinned on CC0 files | -| DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **92%** | **59%** | | +| DJI | 1% | 96% | 19% | | +| Medium format and others | 0.5% | 29% | 8% | IIQ, 3FR, X3F | +| **Weighted** | 100% | **94%** | **85%** | | -About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually -checked, and Canon, the largest bucket, is the weakest of the big four. +About 19 in 20 raw photographers' cameras decode, and about 5 in 6 use a body we have actually +checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus) is the largest gap left. ## Variant matrix (depth per format) @@ -310,3 +310,4 @@ Beyond decoding: | 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) | | 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged | | 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) | +| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again | From e8856c828cf18999737856919079addf92fa9ba8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Thu, 15 Oct 2026 03:45:11 -0400 Subject: [PATCH 259/315] Batch 40: raw-parity action 3 (verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies) is done (#1045, #1081) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/raw-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 90ee6eba7..d9cc06a93 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -265,7 +265,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). |---:|---|---:|---:|---:|---| | 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) | | 2 | CR3: remaining CRX variants (no public file uses them) | +0.3 | 10–25 | ~0.02 | files that open as a preview, from users | -| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples | +| 3 | ~~Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks)~~ done (#1045, #1081) | (+2.0, taken) | — | — | public samples | | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | | 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | From de038ca3d4cc694df97dc1f979186513dc49db96 Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:31:06 +0530 Subject: [PATCH 260/315] #1880: allow 102 MP AI denoise inputs --- crates/denoise-core/src/run.rs | 17 ++++++++++++++++- docs/denoise.md | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 987a859b9..4314ed8c3 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -14,7 +14,7 @@ use crate::manifest::Gain; use crate::tiles::{starts, weight}; /// Most pixels one picture may have (guards the allocations that follow from its size). -pub const MAX_PIXELS: usize = 100_000_000; +pub const MAX_PIXELS: usize = 110_000_000; /// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from. const MIN_SIGNAL: f32 = 0.003; @@ -642,4 +642,19 @@ mod tests { assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_)))); assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_)))); } + + #[test] + fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() { + let layout = Layout { dx: 0, dy: 0 }; + let model = Mock { tile: 32, scale: 1.0 }; + let p = params(32, 8, Gain::None); + assert!(matches!( + denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the mosaic does not have width × height samples" + )); + assert!(matches!( + denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the picture has no size, or is too large" + )); + } } diff --git a/docs/denoise.md b/docs/denoise.md index 926b1bee7..7c3f5138f 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -77,7 +77,7 @@ pool. A scalar implementation checks the optimized operators on synthetic networ Tract is not a workspace dependency or a product feature. CPU inference and the GPU model module are feature-gated; ordinary engine/web builds do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`; -lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most +lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 110 megapixels; at most four tile calls run together, chosen within the process-wide working-memory budget. One idle CPU workspace is retained, and cache costs reserve room for the blended picture. From 112e202fedbf578025b10d89a742365c35b90712 Mon Sep 17 00:00:00 2001 From: Adrian D'souza <102761952+bogusyogi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:41 +0530 Subject: [PATCH 261/315] JPEG XL: check the size from the header before reading frames, under an allocation budget (#1517) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/codecs/src/jxl.rs | 55 ++++++++++++++++++++++++++++++-- crates/codecs/tests/roundtrip.rs | 16 ++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index 26e144aed..3e6e155b5 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; -use jxl_oxide::{AuxBoxData, JxlImage}; +use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; const F: Format = Format::Jxl; @@ -16,10 +16,59 @@ fn err(e: impl std::fmt::Display) -> Error { Error::Malformed(F, e.to_string()) } +/// Bytes the decoder may hold per allowed pixel: four f32 channels, eight times over for frames, +/// extra channels and working buffers. The budget follows `max_pixels`, so oversized frames, extra +/// channels or modular trees inside an image of allowed size still fail (#1517). +const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; + +/// The allocation budget never drops below this, so small images keep the decoder's fixed +/// overhead (group buffers, entropy tables) whatever `max_pixels` is. +const MIN_TRACKED_BYTES: u64 = 64 << 20; + +/// Bytes handed to the header stage at a time (the size `JxlImageBuilder::read` reads in). +const HEADER_CHUNK: usize = 4096; + +/// Parses the header, checks the image size against `opts`, and only then reads the frames, with +/// an allocation budget, so a huge image is refused before any of its pixels are decoded +/// (`JxlImageBuilder::read` loads everything first). +fn open(bytes: &[u8], opts: &DecodeOptions) -> Result<JxlImage> { + let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES); + let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX)); + let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit(); + // Small slices, as `read` does: whatever the header stage has buffered when it initializes is + // parsed as frame data right away, so feeding the whole file here would skip the size check. + let mut rest = bytes; + let mut window = HEADER_CHUNK; + let mut img = loop { + let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?; + rest = rest.get(consumed..).unwrap_or_default(); + match uninit.try_init().map_err(err)? { + InitializeResult::Initialized(img) => break img, + InitializeResult::NeedMoreData(more) => uninit = more, + } + if consumed == 0 { + // the parser wants more than the window holds at once + if window >= rest.len() { + return Err(err("truncated JPEG XL header")); + } + window = window.saturating_mul(2); + } + }; + check_size(F, img.width() as u64, img.height() as u64, opts)?; + while !rest.is_empty() { + let consumed = img.feed_bytes(rest).map_err(err)?; + if consumed == 0 { + break; + } + rest = rest.get(consumed..).unwrap_or_default(); + } + img.finalize().map_err(err)?; + Ok(img) +} + pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> { - let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?; + let mut img = open(bytes, opts)?; let (w, h) = (img.width(), img.height()); - check_size(F, w as u64, h as u64, opts)?; let header = img.image_header(); let bits = header.metadata.bit_depth.bits_per_sample() as u8; let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }); diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index af689ae5d..2ab55da31 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -734,6 +734,22 @@ fn jxl_lossless_exact_with_metadata() { assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image)); } +/// #1517: the size limit is checked from the header, before any frame is read, and a +/// file cut anywhere is an error, never a panic. +#[test] +#[cfg(feature = "jxl")] +fn jxl_size_limit_and_truncation() { + let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() }); + assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}"); + let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (97, 61)); + for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] { + let _ = decode(&b[..cut], DecodeOptions::default()); + } + assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err()); +} + #[test] #[cfg(feature = "jxl")] fn jxl_lossy_quality_and_effort() { From 8a4d6f2ebf104f9b68ce9b987dc7ec90f47ce63a Mon Sep 17 00:00:00 2001 From: Adrian D'souza <102761952+bogusyogi@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:30:46 +0530 Subject: [PATCH 262/315] HEIF: hold uncropped SPS sizes and a grid's tiles together to max_pixels (#1518) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/heif/src/lib.rs | 22 ++++++++++++++ crates/heif/src/tests_synthetic.rs | 11 +++++++ crates/heif/src/vui.rs | 49 ++++++++++++++++++++++-------- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 8292024b1..21b9cf647 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) }) } +/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles +/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. +const GRID_BUDGET: u64 = 2; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec<Frame>, @@ -297,6 +301,24 @@ impl<'a> Coded<'a> { let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?; let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? }; let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format); + // #1518: the decoder allocates each picture at its SPS's uncropped size, which a + // conformance window (or a slice picking another SPS) can make far larger than the + // declared one, and a grid holds all its tiles at once. Both are refused before decoding. + let mut total = 0u64; + for t in &ids { + let tp = ctx.props(*t).map_err(err)?; + let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; + let sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?; + let mut largest = 0u64; + for (w, h) in sizes { + heic_rs::image::check_pixels(w, h, limit).map_err(err)?; + largest = largest.max(u64::from(w) * u64::from(h)); + } + total = total.saturating_add(largest); + } + if total > limit.saturating_mul(GRID_BUDGET) { + return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit"))); + } let decode_one = |t: &u32| -> Result<Frame, Error> { let tp = ctx.props(*t).map_err(err)?; let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index e229af926..3259dc96d 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() { assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); } +/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A +/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit. +#[test] +fn grid_tiles_are_held_to_the_pixel_budget_together() { + let bytes = build(&Spec::new(33, 33, &grey)); + let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() }); + assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); + let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (33, 33)); +} + #[test] fn truncated_and_corrupted_files_are_errors_not_panics() { let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) }); diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index d732f2863..226604303 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option<Signal> { parse_sps(&unescape(sps.get(2..)?)) } +/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among +/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the +/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an +/// SPS this reader can't follow. +pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option<Vec<(u32, u32)>> { + parameter_sets + .iter() + .filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT)) + .map(|sps| { + let rbsp = unescape(sps.get(2..)?); + let r = &mut Bits { data: &rbsp, pos: 0 }; + sps_size(r).map(|(w, h, _)| (w, h)) + }) + .collect() +} + +/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and +/// `sps_max_sub_layers_minus1`. +fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> { + r.skip(4)?; // sps_video_parameter_set_id + let max_sub_layers_minus1 = r.u(3)?; + if max_sub_layers_minus1 > 6 { + return None; + } + r.skip(1)?; // sps_temporal_id_nesting_flag + profile_tier_level(r, max_sub_layers_minus1)?; + r.ue()?; // sps_seq_parameter_set_id + if r.ue()? == 3 { + r.skip(1)?; // separate_colour_plane_flag + } + let width = r.ue()?; // pic_width_in_luma_samples + let height = r.ue()?; // pic_height_in_luma_samples + Some((width, height, max_sub_layers_minus1)) +} + /// Removes emulation prevention bytes (`00 00 03` → `00 00`). fn unescape(nal: &[u8]) -> Vec<u8> { let mut out = Vec::with_capacity(nal.len()); @@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u3 fn parse_sps(rbsp: &[u8]) -> Option<Signal> { let r = &mut Bits { data: rbsp, pos: 0 }; - r.skip(4)?; // sps_video_parameter_set_id - let max_sub_layers_minus1 = r.u(3)?; - if max_sub_layers_minus1 > 6 { - return None; - } - r.skip(1)?; // sps_temporal_id_nesting_flag - profile_tier_level(r, max_sub_layers_minus1)?; - r.ue()?; // sps_seq_parameter_set_id - if r.ue()? == 3 { - r.skip(1)?; // separate_colour_plane_flag - } - r.ue()?; // pic_width_in_luma_samples - r.ue()?; // pic_height_in_luma_samples + let (_, _, max_sub_layers_minus1) = sps_size(r)?; if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset From a43cb685935ad9fceffc05a6fe5ba986d6209297 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak <me@mattsivak.cz> Date: Thu, 15 Oct 2026 14:04:14 +0200 Subject: [PATCH 263/315] LR-MERGE-HEADLESS: merge dialogs remember the last settings per kind The HDR / Panorama / HDR Panorama dialogs now open with the options of the last merge of that kind instead of the defaults, and those options are kept in the library's prefs.json (Session::last_merge) so they survive a restart. "With Last Settings" and HDR for Each Bracket Set read the same store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/lib.rs | 5 +++++ crates/engine/src/library.rs | 4 ++++ crates/engine/src/tests_prefs.rs | 19 ++++++++++++++++ crates/ui-egui/src/merge.rs | 23 +++++++++++++------ crates/ui-egui/src/tests_activity.rs | 33 ++++++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- 6 files changed, 78 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a282ce3e..1b6db3b52 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -298,6 +298,10 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option<serde_json::Value>, + /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`, + /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses + /// them. Persisted in prefs.json. + pub last_merge: std::collections::BTreeMap<String, serde_json::Value>, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec<export::ExportPreset>, /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives @@ -459,6 +463,7 @@ impl Session { library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, + last_merge: Default::default(), export_presets: Vec::new(), print: Default::default(), print_templates: Vec::new(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 51655aea5..e91f0bfff 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -207,6 +207,8 @@ struct PrefsFile { /// Parameters of the last export (for Export with Previous). #[serde(alias = "last_export")] last_export: Option<serde_json::Value>, + /// Options of the last Photo Merge, per command. + last_merge: std::collections::BTreeMap<String, serde_json::Value>, /// The user's export presets. export_presets: Vec<crate::export::ExportPreset>, /// Print templates. @@ -492,6 +494,7 @@ impl Session { let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.last_merge = prefs.last_merge; self.export_presets = prefs.export_presets; self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; @@ -746,6 +749,7 @@ impl Session { let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone(), + last_merge: self.last_merge.clone(), export_presets: self.export_presets.clone(), print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index c69bb7da2..4c9d9582d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn last_merge_settings_persist_with_the_library() { + let lib = temp_dir("merge"); + let opts = json!({"align": false, "deghost": "high"}); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.last_merge.insert("merge.hdr".into(), opts.clone()); + s.save_prefs().unwrap(); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts)); + assert!(!s.last_merge.contains_key("merge.panorama")); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn default_copyright_and_creator_fill_gaps_on_import() { let dir = temp_dir("copyright"); diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index d4c9a0bc6..61485a271 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -108,8 +108,6 @@ pub struct MergeState { pub last_result: Option<Value>, /// Photos the dialog merges (the selection when it opened). pub ids: Vec<PhotoId>, - /// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings. - pub last: std::collections::HashMap<String, MergeDialog>, /// HDR for Each Bracket Set: the sets still to merge after the running one. pub batch: std::collections::VecDeque<Vec<PhotoId>>, } @@ -148,7 +146,15 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } -/// Open the merge dialog for the selection. +/// The options of the last merge of kind `command` started (kept in the library preferences), or +/// the defaults the first time. Fields missing from an older or hand-edited prefs.json take their +/// defaults; options that don't parse at all fall back to the defaults. +pub fn last_options(app: &LightcraftApp, command: &str) -> MergeDialog { + let saved = app.session.last_merge.get(command).and_then(|v| serde_json::from_value::<MergeDialog>(v.clone()).ok()); + MergeDialog { command: command.into(), ..saved.unwrap_or_default() } +} + +/// Open the merge dialog for the selection, with the options of the last merge of this kind. pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { let ids = app.session.selected(); if ids.len() < 2 { @@ -161,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } - app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) }); + app.ui.dialog = Some(crate::state::Dialog::Merge { opts: last_options(app, command) }); Ok(json!({"photos": app.merge.ids.len()})) } @@ -180,7 +186,10 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result<Value, guard.progress(0, 1000); task.guard = Some(guard); app.merge.final_task = Some(task); - app.merge.last.insert(opts.command.clone(), opts.clone()); + app.session.last_merge.insert(opts.command.clone(), opts.params()); + if let Err(e) = app.session.save_prefs() { + log::warn!("merge: {e}"); + } Ok(json!({"started": true, "photos": ids.len()})) } @@ -192,7 +201,7 @@ pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, Strin return Err("select at least 2 photos to merge".into()); } app.merge.ids = ids; - let opts = app.merge.last.get(command).cloned().unwrap_or_else(|| MergeDialog::for_command(command)); + let opts = last_options(app, command); start_final(app, &opts) } @@ -217,7 +226,7 @@ pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> { let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) }; app.merge.ids = ids; - let opts = app.merge.last.get("merge.hdr").cloned().unwrap_or_else(|| MergeDialog::for_command("merge.hdr")); + let opts = last_options(app, "merge.hdr"); start_final(app, &opts).map(|_| ()) } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 126b3ef58..bce9d2eb1 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -361,6 +361,39 @@ fn final_merge_shows_a_row_and_cancels() { let _ = std::fs::remove_dir_all(dir); } +/// The merge dialog opens with the options of the last merge of its kind (per kind), and "with Last +/// Settings" uses them; a kind never merged opens with the defaults. +#[test] +fn merge_dialog_remembers_the_last_settings_per_kind() { + let (mut h, dir) = bracket("merge-last"); + let opts = crate::merge::MergeDialog { + align: false, + deghost: "high".into(), + auto_settings: false, + ..crate::merge::MergeDialog::for_command("merge.hdr") + }; + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + h.app.ui.dialog = None; + crate::merge::start_final(&mut h.app, &opts).unwrap(); + let row = h.app.session.activity.list()[0].id; + h.app.session.activity.cancel(row).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &opts); + crate::merge::open(&mut h.app, "merge.panorama").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &crate::merge::MergeDialog::for_command("merge.panorama")); + h.app.ui.dialog = None; + // older or hand-edited preferences: missing fields take their defaults, junk is ignored + h.app.session.last_merge.insert("merge.panorama".into(), json!({"projection": "cylindrical", "command": "merge.hdr"})); + h.app.session.last_merge.insert("merge.hdrPanorama".into(), json!("junk")); + let pano = crate::merge::last_options(&h.app, "merge.panorama"); + assert_eq!((pano.command.as_str(), pano.projection.as_str(), pano.auto_crop), ("merge.panorama", "cylindrical", true)); + assert_eq!(crate::merge::last_options(&h.app, "merge.hdrPanorama"), crate::merge::MergeDialog::for_command("merge.hdrPanorama")); + let _ = std::fs::remove_dir_all(dir); +} + /// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the /// rest. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..85718ca79 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -355,7 +355,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | | LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | -| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | ## P. Enhance (ENH) From 79d726f8569381607ef9c98043f88ee0826ada1c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Thu, 15 Oct 2026 15:38:03 +0200 Subject: [PATCH 264/315] LRC-PRINT-PAGE: Page Options print page numbers, page info and crop marks (gap 15) The Print dialog's Page section gets Lightroom's Page Options for Single Image and Contact Sheet: the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, and crop marks at the corners of each photo. They are `print.set {page: {pageNumbers, pageInfo, cropMarks}}`, saved with templates, drawn in the preview and composed into the PDF; marks and text turn white on a dark page. --- crates/engine/src/cmd/print.rs | 2 +- crates/engine/src/print/compose.rs | 252 ++++++++++++++++++++++++++++- crates/engine/src/tests_print.rs | 20 +++ crates/ui-egui/src/print_view.rs | 54 ++++++- crates/ui-egui/src/tests_print.rs | 29 ++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- 7 files changed, 352 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 05a851893..26f5d9bc4 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", always, get), cmd!(query "print.set", "Set Print Settings", [], None, - "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", always, set), cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), cmd!(query "print.cell.add", "Add Print Cell", [], None, diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs index 91e5db69e..f4e50703b 100644 --- a/crates/engine/src/print/compose.rs +++ b/crates/engine/src/print/compose.rs @@ -1,5 +1,6 @@ //! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit), -//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s. +//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop +//! marks, page number, page info) and returns [`PageRaster`]s. //! //! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one //! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a @@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0; pub const MAX_STROKE_PT: f64 = 36.0; /// Renders kept for cells that print the same photo at the same size. const RENDER_CACHE: usize = 4; +/// A crop mark's length and its gap from the photo's corner, inches. +pub const CROP_MARK_IN: f64 = 0.25; +pub const CROP_MARK_GAP_IN: f64 = 0.0625; +/// A crop mark's weight, points. +pub const CROP_MARK_PT: f64 = 0.5; +/// Size of the page number and page info text, points. +pub const PAGE_TEXT_PT: f64 = 9.0; /// Lightroom's Image Settings panel. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -85,11 +93,18 @@ impl ImageSettings { pub struct PageStyle { /// Page background colour, sRGB. pub background: [u8; 3], + /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them): + /// the page number at the bottom right of each page. + pub page_numbers: bool, + /// The print settings (sharpening, profile, resolution) at the bottom left of each page. + pub page_info: bool, + /// Marks at the corners of each photo to cut along. + pub crop_marks: bool, } impl Default for PageStyle { fn default() -> Self { - Self { background: [255; 3] } + Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false } } } @@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) } +/// Whether a layout style prints the Page Options (page numbers, page info, crop marks): +/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead. +pub fn has_page_options(style: Style) -> bool { + !style.is_package() +} + +/// The colour of crop marks and page text on `background`: black, or white on a dark page. +pub fn mark_ink(background: [u8; 3]) -> [u8; 3] { + let [r, g, b] = background.map(f64::from); + if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] } +} + +/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer +/// where the resolution is; this print goes to a PDF). +pub fn page_info_text(settings: &PrintSettings) -> String { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let c = &settings.job.color; + let media = match c.media { + PrintMedia::Matte => "Matte", + PrintMedia::Glossy => "Glossy", + }; + let sharpen = match c.sharpen { + PrintSharpen::Off => "Off".to_string(), + PrintSharpen::Low => format!("Low ({media})"), + PrintSharpen::Standard => format!("Standard ({media})"), + PrintSharpen::High => format!("High ({media})"), + }; + let profile = match &c.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi()) +} + // ---- placement ------------------------------------------------------------------------------ /// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. @@ -355,8 +405,8 @@ impl CellStyle { } /// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when -/// asked), then the inner stroke on top of it. -pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> { +/// asked), then the inner stroke on top of it. Returns where the photo landed. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> { // The border may not eat the cell: at least one pixel of photo area stays. let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); let (ax, ay) = (cell.x + border, cell.y + border); @@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke); page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke); } - Ok(()) + Ok(PxRect { x: dx, y: dy, w, h }) +} + +/// What the Page Options draw on every page, in page pixels. +struct Marks { + /// Crop mark length, gap from the corner and weight. + crop: Option<(usize, usize, usize)>, + numbers: bool, + info: Option<String>, + ink: [u16; 3], + text_px: f32, + /// The printable area: the text sits in the margin below it, aligned with its sides. + area: Option<PxRect>, +} + +impl Marks { + fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self { + let on = has_page_options(settings.layout.style); + let page = &settings.page; + let px = |inches: f64| (inches * ppi).round() as usize; + Marks { + crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))), + numbers: on && page.page_numbers, + info: (on && page.page_info).then(|| page_info_text(settings)), + ink: rec2020_from_srgb8(mark_ink(page.background)), + text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32, + area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)), + } + } + + /// Draws the marks of page `number` (one-based) around `photos`, the photos on it. + fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) { + if let Some((len, gap, weight)) = self.crop { + for r in photos { + crop_marks(page, r, len, gap, weight, photos, self.ink); + } + } + let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h }); + let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32); + // centred in the bottom margin, and on the paper when the margin is thinner than the text + let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0)); + if self.numbers + && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS) + { + let x = ((area.x + area.w) as f32 - t.width).max(0.0); + draw_text(page, &t, x, top(t.height), self.ink); + } + if let Some(info) = &self.info + && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS) + { + draw_text(page, &t, area.x as f32, top(t.height), self.ink); + } + } +} + +/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts +/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo. +fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) { + let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64); + let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64); + let segments = [ + (x0 - g - l, y0, l, t), + (x1 + g, y0, l, t), + (x0 - g - l, y1 - t, l, t), + (x1 + g, y1 - t, l, t), + (x0, y0 - g - l, t, l), + (x0, y1 + g, t, l), + (x1 - t, y0 - g - l, t, l), + (x1 - t, y1 + g, t, l), + ]; + let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h); + for (sx, sy, sw, sh) in segments { + for y in sy.max(0)..(sy + sh).min(page.h as i64) { + for x in sx.max(0)..(sx + sw).min(page.w as i64) { + let (x, y) = (x as usize, y as usize); + if !on_photo(x, y) { + page.fill_rect(x, y, 1, 1, ink); + } + } + } + } +} + +/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`). +fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) { + let (w, h) = (page.w, page.h); + text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| { + let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return }; + if gx >= w || gy >= h { + return; + } + let i = (gy * w + gx) * 3; + if let Some(px) = page.rgb.get_mut(i..i + 3) { + let k = cov.clamp(0.0, 1.0); + for (p, c) in px.iter_mut().zip(ink) { + *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16; + } + } + }); } // ---- the job --------------------------------------------------------------------------------- @@ -402,6 +550,7 @@ pub struct Prepared { ppi: f32, background: [u16; 3], style: CellStyle, + marks: Marks, /// Print sharpening, applied to each photo once it's rendered at its print size. color: crate::print_color::PrintColorSettings, } @@ -430,6 +579,7 @@ impl Prepared { let mut out = Vec::with_capacity(self.pages.len()); for (index, &(w, h)) in self.pages.iter().enumerate() { let mut page = Image16::filled(w, h, self.background)?; + let mut placed = Vec::new(); while let Some(item) = items.next_if(|i| i.page == index) { if !progress(done, &item.name) { return Err("Print cancelled".into()); @@ -448,8 +598,9 @@ impl Prepared { } }; let photo = &cache.get(at).ok_or("render cache lost an entry")?.1; - draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?; + placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?); } + self.marks.draw(&mut page, &placed, index + 1); out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); } if !progress(done, "") { @@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI ppi: ppi as f32, background: rec2020_from_srgb8(settings.page.background), style, + marks: Marks::of(&settings, ppi, pw, ph), color: settings.job.color.clone(), }) } @@ -825,6 +977,94 @@ mod tests { assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); } + /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the + /// margins are bare background unless something draws there). + fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> { + let ids = first_ids(s, 2); + let mut set = settings(style); + if style.is_package() { + set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.new_page().unwrap(); + let id = set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.set_cell_page(id, 1).unwrap(); + } + set.image.zoom_to_fill = true; + set.page = page; + prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() + } + + fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + } + + /// The pixels of `x0..x1` x `y0..y1` that are not the white background. + fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() + } + + #[test] + fn crop_marks_sit_outside_each_photo_corner() { + let mut s = Session::with_demo(); + let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); + assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); + let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); + // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, + // along each edge of the photo. + for p in &pages { + for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { + assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); + } + for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { + assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); + } + } + // a dark page gets white marks + let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); + assert_eq!(rgb(&dark[0], 30, 50), WHITE); + } + + #[test] + fn page_numbers_and_info_print_in_the_bottom_margin() { + let mut s = Session::with_demo(); + let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); + // bottom right: the number, different on each page; nothing at the bottom left + let (right, left) = ((200, 400), (0, 200)); + for p in &numbered { + assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); + assert_eq!(inked(p, left.0, left.1, 550, 600), 0); + assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); + } + let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>(); + assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); + let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); + for p in &info { + assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); + } + // the packages have no Page Options in Lightroom: nothing is drawn + let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; + let package = page_options_pages(&mut s, Style::PicturePackage, all); + assert_eq!(package.len(), 2); + for p in &package { + assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); + } + } + + #[test] + fn page_info_names_the_sharpening_profile_and_resolution() { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let mut set = PrintSettings::default(); + let line = page_info_text(&set); + assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); + set.job.color.sharpen = PrintSharpen::Off; + set.job.color.media = PrintMedia::Matte; + set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; + set.job.ppi = 300.0; + assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); + set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; + assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); + } + #[test] fn caps_and_hostile_settings_are_refused() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs index 5b890a7f0..898faa889 100644 --- a/crates/engine/src/tests_print.rs +++ b/crates/engine/src/tests_print.rs @@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() { assert!(c.params.len() > 3, "{id}"); } } + +#[test] +fn page_options_are_print_settings_and_template_fields() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false})); + let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}})); + assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false))); + assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges"); + fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}})); + fails(&mut s, "print.set", json!({"page": {"pageNumber": true}})); + // a template keeps them + run(&mut s, "print.template.save", json!({"name": "Marked"})); + run(&mut s, "print.reset", json!({})); + assert!(!s.print.page.crop_marks); + run(&mut s, "print.template.apply", json!({"name": "Marked"})); + assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info); + let set = crate::find_command("print.set").unwrap(); + assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params); +} diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 71e836bc8..6e35f5b10 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -420,6 +420,19 @@ fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, patch(app, &ctx, json!({"page": {"background": c}})); } }); + if compose::has_page_options(l.style) { + ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label)); + let p = &st.page; + for (id, label, on, key) in [ + ("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"), + ("printPageInfo", "Page Info", p.page_info, "pageInfo"), + ("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"page": {(key): v}})); + } + } + } heading(ui, t, "Print Job"); row(ui, t, "Resolution", |ui| { @@ -607,11 +620,13 @@ fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, s Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), }; + let mut shown = Vec::new(); for (i, (r, photo)) in cells.iter().enumerate() { let rect = sheet.rect(r); register(&ctx, format!("print:gridcell:{i}"), rect); - paint_cell(app, &painter, t, st, rect, *photo, &sheet, r); + shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r)); } + page_options(&painter, st, &sheet, &shown, pu.page + 1); } // footer: page navigation and the selected cell @@ -782,7 +797,7 @@ fn paint_cell( photo: Option<PhotoId>, sheet: &Sheet, inches: &layout::Rect, -) { +) -> Option<Rect> { let im = &st.image; if st.guides.show_cells { painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); @@ -794,7 +809,7 @@ fn paint_cell( }; let avail = cell.shrink(border); if avail.width() < 1.0 || avail.height() < 1.0 { - return; + return None; } // the photo's shape: its cropped size, or 3:2 for a placeholder let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { @@ -804,7 +819,7 @@ fn paint_cell( let scale = 8.0; // sub-point resolution for the rounding in `place` let aw = (f64::from(avail.width()) * scale).round() as usize; let ah = (f64::from(avail.height()) * scale).round() as usize; - let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return }; + let p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?; let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); let visible = shown.intersect(avail); if border > 0.0 { @@ -856,6 +871,37 @@ fn paint_cell( painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); painter.galley(at, galley, Color32::WHITE); } + Some(visible) +} + +/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each +/// photo, the page number at the bottom right and the print settings at the bottom left. +fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) { + let p = &st.page; + let ink = compose::mark_ink(p.background); + let ink = Color32::from_rgb(ink[0], ink[1], ink[2]); + if p.crop_marks { + let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale); + let stroke = Stroke::new(1.0, ink); + for r in photos { + for (corner, dx, dy) in + [(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)] + { + painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke); + painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke); + } + } + } + let Ok(area) = st.layout.printable() else { return }; + let area = sheet.rect(&area); + let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0)); + let y = (area.bottom() + sheet.page.bottom()) / 2.0; + if p.page_numbers { + painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink); + } + if p.page_info { + painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink); + } } fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs index 29c214e47..28838f409 100644 --- a/crates/ui-egui/src/tests_print.rs +++ b/crates/ui-egui/src/tests_print.rs @@ -378,3 +378,32 @@ fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { } assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); } + +#[test] +fn page_options_checkboxes_set_the_page_and_hide_for_packages() { + let mut h = demo(Services::default()); + open_print(&mut h); + let scroll = |h: &mut Headless, dy: f32| { + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": dy}), T); + h.settle(T); + } + }; + // the Page panel is below Page Setup: scroll down to it + scroll(&mut h, -600.0); + for (id, on) in [("check:printPageNumbers", 0), ("check:printPageInfo", 1), ("check:printCropMarks", 2)] { + click(&mut h, id); + let p = &h.app.session.print.page; + assert_eq!([p.page_numbers, p.page_info, p.crop_marks].iter().filter(|v| **v).count(), on + 1, "{id}"); + } + let p = &h.app.session.print.page; + assert!(p.page_numbers && p.page_info && p.crop_marks); + scroll(&mut h, 600.0); + click(&mut h, "button:printStyle-1"); + assert!(has(&h, "check:printCropMarks"), "a contact sheet has Page Options"); + // Lightroom's packages have no Page Options + click(&mut h, "button:printStyle-2"); + assert!(!has(&h, "check:printPageNumbers") && !has(&h, "check:printCropMarks")); +} diff --git a/docs/gaps.md b/docs/gaps.md index f11a74f7a..fc0bbafeb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 10: 515 of 1,446 models verified, ~84% of photographers on a verified body) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -29,7 +29,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -77,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..81b848b69 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -762,7 +762,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | | LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | | LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | | LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | | LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | From 7ac6dd81fe2a75bc7c0b0c505c1dd489cd314b55 Mon Sep 17 00:00:00 2001 From: Lucas Lepski <lucas@lepski.de> Date: Thu, 15 Oct 2026 15:40:53 +0200 Subject: [PATCH 265/315] DOC-README: refresh current features and setup requirements Align feature descriptions, counts, exports and language options with the current code. Distinguish model-dependent features and implemented Print and slideshow workflows from their remaining gaps. Co-Authored-By: GPT-6.1 Sol <noreply@openai.com> --- README.md | 62 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 53ec908de..19eb7c0f2 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col ## Before & after -Hold <kbd>\\</kbd> to peek at the original, press <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a +Press <kbd>\\</kbd> to toggle the Before view, <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the [control channel](#built-for-agents). @@ -150,9 +150,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components -with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint, -Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise) -plus an overall Amount. +with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint, +Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise, +Moiré, Defringe), plus an overall Amount. + +Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately +installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use +classical heuristics. Existing model-generated masks render and export without the model installed. <p align="center"> <img src="docs/images/masking.jpg" alt="Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene" width="100%"> @@ -164,7 +168,7 @@ plus an overall Amount. ## Presets, profiles & the Color Mixer -Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp +Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose. @@ -209,6 +213,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. +The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe. +On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are +bundled. See [face models and setup](docs/faces.md). + <table> <tr> <td width="50%"><img src="docs/images/grid-demo.jpg" alt="Justified Photo Grid of 24 demo photos with ratings and flags, and a sidebar of nested albums under a Travel 2026 folder"><br><sub>Photo Grid with albums nested in a folder, ratings and flags. <i>Demo library.</i></sub></td> @@ -281,7 +289,13 @@ lightcraft --control 7980 ~/Pictures/trip print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf ``` - Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet. + The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation, + print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page + overlays (identity plate, watermark, page numbers) are not there yet. +- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order. + **Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports + support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain + missing. See [docs/slideshow.md](docs/slideshow.md). - **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a side door. - The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts. @@ -293,7 +307,7 @@ lightcraft --control 7980 ~/Pictures/trip - **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, - PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, + PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, as in PhotoCraft). - **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched. @@ -319,7 +333,7 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and [docs/target-app-parity.md](docs/target-app-parity.md)): -- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in +- **By feature count we're at ~82%** of Lightroom (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). - **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / @@ -327,9 +341,13 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det - **The biggest gaps:** - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - - **AI masks and denoise:** subject and sky selection are classical heuristics; + - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an + optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and + linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md); - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); - - **on-screen HDR display, video and the Classic Print / Book / Map modules.** + - **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and + colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback + and export work, with a dedicated module editor and music still missing. - **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). | Area | Status | @@ -337,16 +355,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ | | Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ | | Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ | -| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | +| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) | | Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing | +| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | | RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) | -| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ | -| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | -| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | -| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ | +| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ | +| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill | +| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | +| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ | | Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ | | Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | @@ -354,7 +372,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | -| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) | +| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) | +| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ | +| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays | +| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) | +| Video import/editing and Book module | ⬜ | | Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 | <sub>✅ works today · 🚧 in progress · ⬜ not started</sub> @@ -373,7 +395,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory cargo run --release -p lightcraft -- --control 7980 # with the automation channel cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 -cargo xtask ci # fmt, clippy, tests, layering, wasm checks +cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM ``` CI defaults to line-table debug information and one build job per 1.5 GB of RAM @@ -426,7 +448,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder @@ -536,7 +558,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft-<ver>-window | Format | x86_64 | aarch64 (ARM64) | Notes | |---|---|---|---| -| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | | Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` | | Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | | From d020c55062ee450274ef8db8a62dc062e8860cce Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford <nate@nateledford.com> Date: Thu, 15 Oct 2026 11:39:17 -0400 Subject: [PATCH 266/315] Contributors: credit nledford by real name Co-Authored-By: Claude <noreply@anthropic.com> --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..8db54f2c0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,6 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.nledford] +real_name = "Nathaniel Ledford" From 0f67225e83dc3e41de9420d188af99ed2ff0b48d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 14 Oct 2026 22:13:49 +0000 Subject: [PATCH 267/315] Hasselblad FFF: colour matrix from the embedded 3FR header An FFF (a 3FR converted on the desktop) has no colour matrix of its own: the camera's stays in the original 3FR header the FFF carries as a complete TIFF in private IFD0 tag 0xC51B. The new vendor::hasselblad reader decodes 3FR / FFF through dng::decode_as and, when the file has no matrix, takes the matrices from that header, so an FFF no longer develops with the "camera RGB = sRGB" fallback (H4D-40 as-shot white moves from xy 0.279, 0.356 to 0.325, 0.336). RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR (raw.pixls.us 931 and 709). Synthetic tests cover the embedded matrix, an own matrix winning, a missing or damaged header, and truncated files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b --- crates/raw/src/lib.rs | 4 +- crates/raw/src/vendor/hasselblad.rs | 176 ++++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 1 + docs/parity-checklist.md | 5 +- docs/raw-parity.md | 5 +- 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 crates/raw/src/vendor/hasselblad.rs diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 202983331..2800f0823 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -110,7 +110,7 @@ pub enum RawFormat { X3f, /// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): - /// Hasselblad 3FR and FFF. Read by the DNG reader. + /// Hasselblad 3FR and FFF. Read by `vendor::hasselblad` (the DNG reader plus what Hasselblad adds). CfaTiff, /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). KodakDcs, @@ -397,7 +397,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), - RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), + RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), RawFormat::Erf => vendor::erf::decode(bytes, mode), diff --git a/crates/raw/src/vendor/hasselblad.rs b/crates/raw/src/vendor/hasselblad.rs new file mode 100644 index 000000000..1a3b6c427 --- /dev/null +++ b/crates/raw/src/vendor/hasselblad.rs @@ -0,0 +1,176 @@ +//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]). +//! +//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and our own black-box +//! analysis of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every number +//! below was measured on it. +//! +//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution +//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`. +//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about. +//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor +//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter. +//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each +//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at +//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847 +//! with red, and the two green sites' diagonal differences are half those of the other pair. +//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in +//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag +//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a +//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). +//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer +//! file's, which is the one the converter last saved. +use crate::dng; +use crate::{ColorData, Mode, RawFormat, RawImage, Result}; +use lightcraft_tiff::{ParseOptions, Tiff}; + +/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. +pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; + +/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header. +const MAX_HEADER: usize = 1 << 20; + +/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]). +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff)?; + if !crate::color::has_matrix(&img.color) + && let Some(header) = original_header_color(bytes) + { + adopt_matrices(&mut img.color, &header); + } + Ok(img) +} + +/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix. +fn original_header_color(bytes: &[u8]) -> Option<ColorData> { + let outer = Tiff::parse(bytes).ok()?; + let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?; + if block.len() > MAX_HEADER { + return None; + } + let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() }; + let header = Tiff::parse_with(block, &opts).ok()?; + let ifd0 = header.ifds.first()?; + let raw = dng::raw_ifd(&header).unwrap_or(ifd0); + let color = dng::color_data(ifd0, raw, header.order); + crate::color::has_matrix(&color).then_some(color) +} + +/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. +fn adopt_matrices(color: &mut ColorData, from: &ColorData) { + color.illuminant = from.illuminant; + color.color_matrix = from.color_matrix; + color.forward_matrix = from.forward_matrix; + color.camera_calibration = from.camera_calibration; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawData, RawError, decode as decode_file, probe, probe_info}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + const MATRIX: [(i32, i32); 9] = + [(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)]; + + fn rgb_ifd() -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![0x80; 36]] }); + ifd + } + + /// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix. + fn original_header(matrix: bool) -> Vec<u8> { + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into())); + if matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec())); + } + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, + /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. + fn fff(header: Option<Vec<u8>>, own_matrix: bool) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] }); + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into())); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)])); + if own_matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + } + if let Some(h) = header { + ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); + } + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> { + c.color_matrix[0].map(|m| m.0[0]) + } + + /// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it + /// embeds, so the image is not developed with the "camera RGB ≈ sRGB" fallback. + #[test] + fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() { + let bytes = fff(Some(original_header(true)), false); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode_file(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032])); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance"); + assert!(!crate::color::camera_transform_of(&r.color, lightcraft_color::D65).matrix_is_fallback); + // the import probe says the same + assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032])); + } + + /// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the + /// file still decodes, with the fallback colour. + #[test] + fn own_matrix_wins_and_a_missing_one_is_not_an_error() { + let own = decode_file(&fff(Some(original_header(true)), true)).unwrap(); + assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0])); + for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] { + let r = decode_file(&fff(header, false)).unwrap(); + assert!(!crate::color::has_matrix(&r.color)); + } + } + + /// A truncated or damaged file never panics: an error, or a decode. + #[test] + fn damaged_files_do_not_panic() { + let bytes = fff(Some(original_header(true)), false); + for n in (0..bytes.len()).step_by(7) { + let _ = decode_file(&bytes[..n]); + let _ = probe_info(&bytes[..n]); + } + let mut header = original_header(true); + for i in (8..header.len()).step_by(3) { + header[i] ^= 0x5a; + match decode_file(&fff(Some(header.clone()), false)) { + Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {} + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 09c316d29..ef9616e09 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; pub mod erf; +pub(crate) mod hasselblad; pub mod kodak; pub mod mef; pub mod nef; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..06b818e4c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs; LR-IMP-FORMATS, Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -82,7 +82,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..42cca4610 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -159,7 +159,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | | DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | | IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From b5dc500c542339a850bea37ab4ea3a669829688a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 18:58:05 +0000 Subject: [PATCH 268/315] Hasselblad 3FR / FFF: uncompressed RGB embedded previews These files carry no JPEG: their preview is an uncompressed 8-bit RGB IFD (1217 x 913 in the H4D-40 FFF, 218 x 218 in the CFV 3FR). The largest such image is handed on by embedded_preview as a small baseline TIFF, which lightcraft_codecs::decode reads, so 3FR / FFF show a preview in the grid and loupe before they are developed. No Orientation tag is written: the raw file's applies, as for JPEG previews without one. The FFF's 16-bit copy (with a TransferFunction of unknown meaning) is not used. decode_raw_preview applied a container-declared colour space by forcing the JPEG decoder on any owned preview; it now checks for JPEG bytes, so a wrapped TIFF (or a JPEG XL preview) goes to its own decoder. Checked on the CC0 H4D-40 FFF and CFV 3FR through embedded_preview_srgb: right colours, the 3FR upright through its Rotate90. Tests cover the wrap, the choice among RGB images, a short strip and truncated files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b --- crates/engine/src/files.rs | 5 +- crates/raw/src/preview.rs | 6 +- crates/raw/src/vendor/hasselblad.rs | 123 +++++++++++++++++++++++++++- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 5 +- 5 files changed, 134 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..31bb930fb 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview( covering: Option<usize>, ) -> Option<lightcraft_codecs::Decoded> { let jpeg = stand_in_image(bytes, covering)?; - // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader - let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_)); + // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview + // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders + let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { Some(space) => { let space = match space { diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 1de7ab9e6..71dfad10d 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -148,8 +148,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) } } -/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with -/// `lightcraft_codecs::decode`. +/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which +/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF — all decode with `lightcraft_codecs::decode`. pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> { preview_covering(bytes, None) } @@ -220,6 +220,8 @@ fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> { .map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() }); // a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note) best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))) + // Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF + .or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::vendor::hasselblad::rgb_preview(&tiff, bytes)).flatten()) } /// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, diff --git a/crates/raw/src/vendor/hasselblad.rs b/crates/raw/src/vendor/hasselblad.rs index 1a3b6c427..5f3342f9f 100644 --- a/crates/raw/src/vendor/hasselblad.rs +++ b/crates/raw/src/vendor/hasselblad.rs @@ -19,9 +19,14 @@ //! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). //! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer //! file's, which is the one the converter last saved. +//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the +//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB +//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used. +//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF. use crate::dng; use crate::{ColorData, Mode, RawFormat, RawImage, Result}; -use lightcraft_tiff::{ParseOptions, Tiff}; +use lightcraft_tiff::image::{Layout, chunk_bytes}; +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t}; /// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; @@ -55,6 +60,58 @@ fn original_header_color(bytes: &[u8]) -> Option<ColorData> { crate::color::has_matrix(&color).then_some(color) } +/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen. +const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3; + +/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding +/// just those pixels, which `lightcraft_codecs::decode` reads. No `Orientation` tag is written: the viewer +/// applies the raw file's, which is the one that stands over this image in IFD0. +pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option<Vec<u8>> { + let info = tiff + .all_ifds() + .into_iter() + .filter_map(|i| i.image().ok()) + .filter(|i| { + i.photometric == t::photometric::RGB + && i.compression == t::compression::NONE + && i.samples_per_pixel == 3 + && i.bits_per_sample.iter().all(|&b| b == 8) + && i.planar == 1 + && i.sample_format == 1 + && matches!(i.layout, Layout::Strips { .. }) + && i.width > 0 + && i.height > 0 + }) + .max_by_key(|i| u64::from(i.width) * u64::from(i.height))?; + let (w, h) = (info.width as usize, info.height as usize); + let size = w.checked_mul(h)?.checked_mul(3)?; + if size > MAX_PREVIEW_BYTES { + return None; + } + let mut pixels = Vec::with_capacity(size); + for chunk in info.chunks(bytes.len() as u64) { + pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?); + if pixels.len() >= size { + break; + } + } + // a short last strip is a truncated file: no preview rather than a picture with a black band + if pixels.len() < size { + return None; + } + pixels.truncate(size); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok() +} + /// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. fn adopt_matrices(color: &mut ColorData, from: &ColorData) { color.illuminant = from.illuminant; @@ -81,7 +138,7 @@ mod tests { ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); ifd.set(t::COMPRESSION, Value::Short(vec![1])); - ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![0x80; 36]] }); + ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![(0..36).collect()] }); ifd } @@ -100,6 +157,11 @@ mod tests { /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. fn fff(header: Option<Vec<u8>>, own_matrix: bool) -> Vec<u8> { + fff_with(header, own_matrix, vec![]) + } + + /// [`fff`] with more `SubIFDs` children of IFD0 (other preview images). + fn fff_with(header: Option<Vec<u8>>, own_matrix: bool, extra: Vec<IfdBuilder>) -> Vec<u8> { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); @@ -122,6 +184,9 @@ mod tests { ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); } ifd0.add_sub_ifd(raw); + for e in extra { + ifd0.add_sub_ifd(e); + } TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } @@ -164,6 +229,7 @@ mod tests { for n in (0..bytes.len()).step_by(7) { let _ = decode_file(&bytes[..n]); let _ = probe_info(&bytes[..n]); + let _ = crate::embedded_preview(&bytes[..n]); } let mut header = original_header(true); for i in (8..header.len()).step_by(3) { @@ -173,4 +239,57 @@ mod tests { } } } + + /// `(width, height, pixels, has an Orientation tag)` of a wrapped preview. + fn wrapped(tiff: &[u8]) -> (u32, u32, Vec<u8>, bool) { + let t = Tiff::parse(tiff).unwrap(); + let ifd = &t.ifds[0]; + let info = ifd.image().unwrap(); + let chunks = info.chunks(tiff.len() as u64); + let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect(); + (info.width, info.height, pixels, ifd.contains(t::ORIENTATION)) + } + + /// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an + /// orientation of its own (the raw file's applies). + #[test] + fn rgb_ifd0_is_the_embedded_preview() { + let bytes = fff(Some(original_header(true)), false); + let preview = crate::embedded_preview(&bytes).expect("a preview"); + assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false)); + assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview)); + } + + /// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not + /// used, and nor is a planar or compressed one. + #[test] + fn only_8_bit_chunky_uncompressed_rgb_is_taken() { + let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar])); + let n = (w * h * 3 * u32::from(bits / 8)) as usize; + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] }); + ifd + }; + let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3"); + let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false)); + } + + /// A preview strip cut short by a truncated file gives no preview, not a partial picture. + #[test] + fn short_preview_strip_gives_none() { + let mut ifd0 = rgb_ifd(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] }); + let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None); + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 06b818e4c..cda8fe3c9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs; LR-IMP-FORMATS, Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs; LR-IMP-FORMATS, Hasselblad FFF colour matrix from the embedded 3FR header and 3FR / FFF RGB previews) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -82,7 +82,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes 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 and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | | 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 42cca4610..465588858 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Hasselblad FFF colour matrix from the embedded 3FR header; 3FR / FFF RGB previews) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -159,7 +159,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | | DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | | IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | | 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | From dde403946b0d55bc99a7f731e7f2fa69fea59511 Mon Sep 17 00:00:00 2001 From: naryzach <ryan5071@gmail.com> Date: Thu, 15 Oct 2026 12:17:25 -0700 Subject: [PATCH 269/315] raw: alias Canon EOS R6 Mark II to EOS R6m2 Canon EOS R6 Mark II writes its Exif model as 'Canon EOS R6 Mark II', which failed the spectral camera table lookup because the table registers the model as 'EOS R6m2'. Add the alias mapping and regression unit tests in lightcraft_raw and lightcraft_catalog. --- crates/catalog/src/model.rs | 1 + crates/raw/src/spectral.rs | 1 + crates/raw/src/spectral_table.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 0c7ca5112..6ac3544ee 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -643,6 +643,7 @@ mod edited_tests { ("NIKON CORPORATION NIKON D850", "NEF", false), ("Canon Canon EOS 5D Mark III", "CR2", false), ("Canon Canon EOS R6m2", "CR3", false), + ("Canon Canon EOS R6 Mark II", "CR3", false), ("Canon Canon EOS R7", "CR3", true), ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index cfa99ff5d..673c80138 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -142,6 +142,7 @@ mod tests { ("Canon", "Canon EOS REBEL T3i", "EOS 600D"), ("Canon", "Canon EOS Kiss X5", "EOS 600D"), ("Canon", "Canon EOS R5 Mark II", "EOS R5m2"), + ("Canon", "Canon EOS R6 Mark II", "EOS R6m2"), ("NIKON CORPORATION", "NIKON D800", "D800E"), ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), diff --git a/crates/raw/src/spectral_table.rs b/crates/raw/src/spectral_table.rs index f116e2335..e8703e471 100644 --- a/crates/raw/src/spectral_table.rs +++ b/crates/raw/src/spectral_table.rs @@ -335,6 +335,7 @@ pub(super) const ALIASES: &[(&str, &str, &str)] = &[ ("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name ("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name ("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name + ("Canon", "EOS R6 Mark II", "EOS R6m2"), // same model, alternative name ("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor ("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor ("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor From 6938be39e4e69657e231e2c4083b83ba66f5cd40 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 15 Oct 2026 19:17:57 +0000 Subject: [PATCH 270/315] Engine tests: drop the drop() of a closure (clippy drop_non_drop) Clippy 1.97 rejects `drop(write)` in the same-folder export test: the closure has no Drop, so its borrow of `written` already ends at its last use and the call did nothing. Removed, with a note on why the following read of `written` compiles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J2NDVXA8jQQWeJb4YVTb6b --- crates/engine/src/tests_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 2f1a05116..23969f505 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1038,7 +1038,7 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { assert_eq!(f["photo"], id.0, "{f}"); assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); } - drop(write); + // (`write` borrows `written` until its last use above; a closure has no `Drop` to wait for) assert!(written.is_empty(), "{written:?}"); // a photo on disk whose folder is gone (moved, an unplugged drive) From fa715c8c94696c4f15d4522d5d725e3c78fd94b8 Mon Sep 17 00:00:00 2001 From: skybluu <12523559+skybluu@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:36:38 -0500 Subject: [PATCH 271/315] Localize Info Overlay mode notices (#911) --- README.md | 2 +- README.zh-hans.md | 2 +- crates/ui-egui/src/headless.rs | 34 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 53ec908de..4dba64bec 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ Without it LightCraft builds and runs the same, but Chinese and Japanese text ha repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). **Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → -Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). +Language**; the choice applies immediately and persists. Info Overlay mode-change toasts use the selected interface language. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index f2d155214..fc9373445 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -394,7 +394,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft 本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。 **界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 → -语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。 +语言**;选择立即生效并会保留。信息叠加模式切换提示使用所选界面语言。见 [docs/localization.md](docs/localization.md)。 **日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log` (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6178990b0..e0888c9bb 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4292,6 +4292,40 @@ mod tests { h.settle(SETTLE); } + /// #911 (toast labels only): cycling the overlay announces each mode in the UI language. + #[test] + fn info_overlay_toasts_are_painted_in_the_users_language() { + use crate::i18n::Locale; + + struct RestoreLanguage(Locale); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_language(self.0); + } + } + let _restore = RestoreLanguage(crate::i18n::language()); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); + for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { + assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); + assert_eq!(h.app.ui.language, locale); + assert_eq!(crate::i18n::language(), locale); + for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { + let expected = locale.tr(key); + if locale != Locale::En { + assert_ne!(expected, key, "this locale must translate the tested message"); + } + assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); + assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); + assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); + h.step(); + let painted = h.painted_text(); + assert!(painted.iter().any(|text| text == expected), "{painted:?}"); + } + } + } + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..cd6829db4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -705,7 +705,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re crate::state::InfoOverlay::Basic => "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, label); + app.toast(&ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } "view.chooseLayoutImage" => { From c5deea05e47ed479684aeac4e3886da8e1a80dfc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Fri, 16 Oct 2026 00:23:44 +0200 Subject: [PATCH 272/315] Merged develop settings are kept in range, as XMP import keeps them (fixes #1946) --- crates/develop/src/lib.rs | 51 ++++++++++++++++++++++++++++++++++- crates/develop/src/presets.rs | 10 ++----- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index f376b3c67..4631e5e49 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -41,7 +41,7 @@ impl DevelopSettings { /// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that /// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a /// partial from before the scale existed, or one that never said, must not become Kelvin by landing on - /// Kelvin settings. + /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]). pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> { let mut v = self.to_json(); presets::deep_merge(&mut v, partial); @@ -52,9 +52,34 @@ impl DevelopSettings { { out.wb.scale = WbScale::Legacy; } + out.keep_in_range(self); Ok(out) } + /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`, + /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a + /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop + /// may reach past 0..1 and is left to the crop tools, which know the photo's size. + fn keep_in_range(&mut self, before: &DevelopSettings) { + for c in controls::CONTROLS { + if let Some(v) = controls::get(self, c.id) { + controls::set(self, c.id, v); + } + } + let c = &mut self.curve; + for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() { + *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); + } + let g = &mut self.crop.geometry; + if g.angle == 0.0 { + g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT); + } + if g.rect.is_empty() { + let old = before.crop.geometry.rect; + g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old }; + } + } + /// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys. pub fn hash64(&self) -> u64 { let s = serde_json::to_string(self).unwrap_or_default(); @@ -255,6 +280,30 @@ mod tests { assert_eq!(m.light.exposure, 0.0); } + #[test] + fn merged_settings_are_kept_in_range() { + // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said + let mut base = DevelopSettings::default(); + base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9); + let m = base + .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}})) + .unwrap(); + assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0)); + // point curves stay in 0..1, as an imported XMP curve does + let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap(); + assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]); + // a crop with no area is left out, as XMP import leaves out one whose edges are inverted + for rect in + [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})] + { + let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap(); + assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}"); + } + // unrotated, the crop stays inside the photo + let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap(); + assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75)); + } + #[test] fn unedited_and_reset() { let mut s = DevelopSettings::default(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 85f27d321..b3edc801c 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { } /// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped -/// through the control specs. +/// through the control specs, as every merge does ([`DevelopSettings::merged`]). pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) }; // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) - let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); - for c in crate::controls::CONTROLS { - if let Some(v) = crate::controls::get(&out, c.id) { - crate::controls::set(&mut out, c.id, v); - } - } - out + s.merged(&patch).unwrap_or_else(|_| s.clone()) } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] From c8f741fd0be11ca75b3f39c31ee2256dd09b96ec Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Fri, 16 Oct 2026 02:20:02 +0200 Subject: [PATCH 273/315] Contributors: add my name for the About window --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bec67597c..267b90281 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -45,3 +45,8 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false From 0802892132ce7106037d71c65b0b6885b6ac6aa9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Sun, 11 Oct 2026 11:56:04 -0400 Subject: [PATCH 274/315] ci: refactor workflows to use storytold/craft-common-actions --- .github/workflows/freebsd.yml | 56 +-- .github/workflows/packaging-lint.yml | 80 +---- .github/workflows/release.yml | 504 ++------------------------- .github/workflows/windows-arm64.yml | 103 ++---- 4 files changed, 71 insertions(+), 672 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b10e65a75..bdfdc21c1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,16 +1,23 @@ -# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner -# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. +# FreeBSD build and package check in a QEMU VM. name: FreeBSD on: push: branches: [main] - pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - .github/workflows/freebsd.yml + - ".github/workflows/freebsd.yml" + - "packaging/freebsd/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + pull_request: + branches: [main] + paths: + - ".github/workflows/freebsd.yml" + - "packaging/freebsd/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" workflow_dispatch: concurrency: @@ -22,34 +29,7 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font build input, so the Japanese font tests run (they skip without it). - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - path: craft-fonts - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 7ec855992..f54b0538b 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -1,7 +1,5 @@ -# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging -# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a -# Windows job that builds and installs throwaway per-user MSI probes for the install-folder -# searches (a few minutes). +# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. name: Packaging lint on: @@ -21,75 +19,5 @@ concurrency: jobs: lint: - timeout-minutes: 15 - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then - echo "::error::Icon Id must end in .ico or .exe"; exit 1 - fi - # The interactive installer asks where to install and ends on a completion page (issues #18, #399). - for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \ - 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do - grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; } - done - # The completion page the Publish events point at must actually exist: a Publish alone - # does not pull the dialog fragment into the MSI, and without the DialogRef the - # installer finishes silently (issues #790, #892). - grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \ - || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; } - desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop - sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF - - name: FreeBSD tarball layout (stub binaries) - run: bash packaging/freebsd/package.sh --dry-run - - windows-install-folder: - runs-on: windows-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Install-folder fallback and precedence (real MSI AppSearch) - shell: pwsh - run: | - ./packaging/windows/test-install-folder.ps1 -Arch x64 - ./packaging/windows/test-install-folder.ps1 -Arch x86 + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 392ee4eb7..e24229356 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,5 @@ -# Release pipeline: every push to `release` builds platform artifacts (signed where credentials -# are configured) and creates or updates the draft GitHub Release "LightCraft v<version>". -# See docs/releasing.md. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional: -# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign -# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on -# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never -# drafts a release. +# Release pipeline: every push to `release` builds signed installers for every platform and +# creates or updates the draft GitHub Release "LightCraft v<version>". See docs/releasing.md. name: Release on: @@ -21,479 +13,33 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - LIGHTCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - timeout-minutes: 10 - name: version - runs-on: ubuntu-latest - # No `environment: release` here or on the other jobs that sign nothing: no release secrets. - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - timeout-minutes: 90 - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - timeout-minutes: 90 - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - # package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache. - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - timeout-minutes: 90 - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications' - ldd target/release/lightcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - timeout-minutes: 90 - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - linux-riscv64: - timeout-minutes: 90 - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - name: Check the tarball - run: | - tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \ - | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. - runs-on: ubuntu-latest - timeout-minutes: 180 - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - bash packaging/freebsd/package.sh - tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \ - | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - timeout-minutes: 90 - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - WASM_BINDGEN_VERSION: 0.2.129 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install wasm-bindgen CLI - run: | - # Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it). - if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then - cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked - fi - wasm-bindgen --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - timeout-minutes: 10 - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="LightCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: lightcraft + app-title: LightCraft + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 1cccd858b..0033346dc 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -1,22 +1,27 @@ -# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 -# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed -# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left -# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) +# Windows ARM64 build and smoke test. +# +# Release packages on arm64-windows are produced on x64 runners via cross-compilation +# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run +# on GitHub Actions' native windows-11-arm runner. name: Windows ARM64 on: push: branches: [main] - pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - - .github/workflows/windows-arm64.yml - - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + - ".github/workflows/windows-arm64.yml" + - "packaging/windows/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + pull_request: + branches: [main] + paths: + - ".github/workflows/windows-arm64.yml" + - "packaging/windows/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" workflow_dispatch: concurrency: @@ -26,70 +31,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: lightcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: lightcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'LightCraft' - foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'lightcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok lightcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: lightcraft + app-dir: LightCraft From 3885b61e6b35c1dbcc8d08f411d9e1273ead4ed1 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:25:18 +0200 Subject: [PATCH 275/315] ci: retain LightCraft installer and package checks --- .github/workflows/freebsd.yml | 1 + .github/workflows/packaging-lint.yml | 16 ++++++++++++++++ packaging/check-app-specific.sh | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packaging/check-app-specific.sh diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index bdfdc21c1..5fc2092a9 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,5 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: + require-craft-fonts: true craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b..71d3b3063 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,19 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + with: + extra-checks-script: packaging/check-app-specific.sh + + windows-install-folder: + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Install-folder fallback and precedence (real MSI AppSearch) + shell: pwsh + run: | + ./packaging/windows/test-install-folder.ps1 -Arch x64 + ./packaging/windows/test-install-folder.ps1 -Arch x86 diff --git a/packaging/check-app-specific.sh b/packaging/check-app-specific.sh new file mode 100644 index 000000000..c14ca5725 --- /dev/null +++ b/packaging/check-app-specific.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# App-specific installer and package assertions retained alongside shared packaging lint. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \ + 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do + grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; } +done +grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \ + || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; } + +python3 - <<'PYEOF' +import yaml +manifests = [] +for name in ("ai.storyteller.lightcraft.yml", "ai.storyteller.lightcraft.bundle.yml"): + with open("packaging/linux/flatpak/" + name) as source: + manifest = yaml.safe_load(source) + assert manifest["id"] == "ai.storyteller.lightcraft" and manifest["command"] == "lightcraft", name + manifests.append(manifest) +for key in ("runtime", "runtime-version", "sdk", "finish-args"): + assert manifests[0][key] == manifests[1][key], f"Flatpak manifests disagree on {key}" +print("LightCraft package identity ok") +PYEOF From b67bb01952e7425d5becc4439c6f3bc5edc9dc52 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Fri, 16 Oct 2026 07:04:45 +0200 Subject: [PATCH 276/315] TIFF: read strips and tiles larger than 128 MiB (fixes #1983) --- crates/codecs/src/tiff_codec.rs | 3 +++ crates/codecs/tests/roundtrip.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index b077c214a..6f1e25b13 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -95,6 +95,9 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> { fn limits(opts: &DecodeOptions) -> Limits { let mut limits = Limits::default(); limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; + // One strip or tile is read whole: the default 128 MiB refuses a single-strip uncompressed 16-bit + // export of ~22 MP (#1983). The same cap as the decoded image (32 bytes per allowed pixel) bounds it. + limits.intermediate_buffer_size = limits.decoding_buffer_size; limits.ifd_value_size = 32 << 20; limits } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index af689ae5d..489255c09 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -263,6 +263,28 @@ fn tiff_cmyk_and_orientation() { assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}"); } +/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877, +/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so +/// the thumbnail (and the full decode) failed with "decoder limits exceeded". +#[test] +fn tiff_single_strip_larger_than_128_mib() { + use tiff::encoder::{TiffEncoder, colortype}; + let (w, h) = (3923u32, 5877u32); + let mut cur = std::io::Cursor::new(Vec::new()); + { + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::<colortype::RGB16>(w, h).unwrap(); + im.rows_per_strip(h).unwrap(); + im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap(); + } + let bytes = cur.into_inner(); + assert!(bytes.len() > 128 << 20); + let t = decode_thumbnail(&bytes, 256).unwrap(); + assert_eq!((t.source_width, t.source_height), (w, h)); + let px = t.image.get(10, 10); + assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}"); +} + /// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and /// already packed into `strip`, one padded row after another. fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> { From 312dd04830debcd2129d60c7e6efc1aefb975ec6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:21:24 +0200 Subject: [PATCH 277/315] Reject out-of-range start in photo.rename and photo.renamePreview Found while testing crates/engine/src/rename.rs:919 (start parsed at crates/engine/src/cmd/manage.rs:12) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/manage.rs | 9 +++++++-- crates/engine/src/rename.rs | 3 ++- crates/engine/src/tests_organize.rs | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 66ddfe9fb..339b20b90 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -9,8 +9,13 @@ use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> { let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string(); - let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize; - Ok((s.targets(p)?, template, start)) + let start = p.get("start").and_then(Value::as_u64).unwrap_or(1); + let ids = s.targets(p)?; + // every number of the sequence `start..start + n` must fit + let last = start.checked_add((ids.len() as u64).saturating_sub(1)); + let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok()); + let start = start.ok_or_else(|| bad(c, "start out of range"))?; + Ok((ids, template, start)) } /// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name). diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 16003cd61..f0d725793 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -916,7 +916,8 @@ fn plan_rename_core( ) -> Vec<RenamePlan> { let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch let mut out = Vec::new(); - for (seq, p) in (start..).zip(photos) { + for (i, p) in photos.iter().enumerate() { + let seq = start.saturating_add(i); let want = expand(template, p, seq); let (stem, ext) = split_ext(&want); let (stem, ext) = (stem.to_string(), ext.to_string()); diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 8990acb4c..344838432 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -336,3 +336,23 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() { drop(reopened); let _ = std::fs::remove_dir_all(dir); } + +/// A huge `start` is a validation error, not an arithmetic overflow in the numbering. +#[test] +fn rename_huge_start_is_a_bad_param() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + for cmd in ["photo.renamePreview", "photo.rename"] { + let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err(); + assert!(e.to_string().contains("start out of range"), "{cmd}: {e}"); + } + let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + assert_eq!(names_before, names_after, "a rejected rename changes nothing"); + // the last start whose whole sequence fits is accepted, one more is not + let fits = usize::MAX as u64 - 2; + let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids})); + assert!(r.is_ok(), "{r:?}"); + let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids})); + assert!(r.is_err(), "{r:?}"); +} From 0a9675c6d9e214ba56fdee219f6c4ec018f0e90d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:54:06 +0000 Subject: [PATCH 278/315] Ignore zero or negative ISO when importing XMP Found while testing crates/meta/src/xmp.rs:509-512 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/meta/src/xmp.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index c27fe4ae2..856f8849b 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -506,10 +506,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> { label: first("xmp:Label"), ..Default::default() }; - m.iso = num("exif:ISOSpeedRatings").or_else(|| num("exifEX:PhotographicSensitivity")).filter(|v| *v > 0.0).map(|v| v as u32); - if let Some(v) = props.get("exif:ISOSpeedRatings").and_then(|v| v.first()).and_then(|s| parse_number(s)) { - m.iso = Some(v as u32); - } + m.iso = [num("exif:ISOSpeedRatings"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| v as u32); if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) { let v: Vec<f64> = spec.iter().filter_map(|s| parse_number(s)).collect(); if v.len() == 4 { @@ -802,6 +799,24 @@ mod tests { assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); } + /// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and + /// must not hide a valid `exifEX:PhotographicSensitivity`. + #[test] + fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:exifEX="http://cipa.jp/exif/1.0/" {attrs}/> + </rdf:RDF></x:xmpmeta>"# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); + } + #[test] fn malformed_inputs() { assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err()); From 3accb561967611af8fc551b7839208e3a0650a1d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:32:19 +0000 Subject: [PATCH 279/315] Start crop drags in the zone the cursor showed at the press Fixes #1930 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/panels/detail.rs | 4 ++- crates/ui-egui/src/tests_crop_rotate.rs | 39 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 44427565e..94462e6dc 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1580,8 +1580,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = app.run("tool.done", json!({})); return; } + // egui reports a drag only once the pointer has moved a few px: the zone is where the button + // went down, where the cursor showed it, not where the pointer is by then if resp.drag_started() - && let Some(q) = resp.interact_pointer_pos() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) { diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index f19171ffc..848b1b035 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -415,3 +415,42 @@ fn rotating_the_crop_dims_nothing_inside_the_box() { let lum = |c: egui::Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); assert!(lum(pixel(&rotated, corner_px)) < lum(pixel(&plain, corner_px)), "the photo's corner, outside the box, is dimmed"); } + +fn crop_rect(h: &Headless) -> lightcraft_geom::Rect { + let id = h.app.session.active().expect("a photo"); + h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.rect +} + +/// Press where the pointer shows the resize cursor for the left edge handle, `dx` px from the +/// edge, and drag on by `step` px per event: the drag must resize, as the cursor said (issue #1930). +fn drag_left_handle(dx: f32, step: f32) -> Headless { + let mut h = crop_tool(); + let img = h.app.image_rect.expect("the photo on screen"); + hover(&mut h, egui::pos2(img.left() + dx, img.center().y)); + assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal, "the pointer shows a resize"); + let x = |px: f32| px / img.width(); + let events: Vec<_> = std::iter::once(json!({"kind": "down", "x": x(dx), "y": 0.5})) + .chain((1..=4).map(|i| json!({"kind": "drag", "x": x(dx + step * i as f32), "y": 0.5}))) + .collect(); + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// Just outside the box, inside the handle's reach: the drag resizes, it does not rotate. +#[test] +fn a_drag_from_just_outside_a_handle_resizes_not_rotates() { + let h = drag_left_handle(-8.0, -3.0); + assert_eq!(angle(&h), 0.0, "the resize cursor showed, so nothing rotates"); + assert!(!has(&h, "cropAngleReadout"), "no rotation under way"); +} + +/// Just inside the box, inside the handle's reach: the drag moves the left edge, not the box. +#[test] +fn a_drag_from_just_inside_a_handle_resizes_not_moves() { + let h = drag_left_handle(8.0, 3.0); + let r = crop_rect(&h); + assert!(r.x0 > 0.0, "the left edge moved in: {r:?}"); + assert!((r.x1 - 1.0).abs() < 1e-6, "the right edge stays: {r:?}"); +} From 346681561ade3306a7501e60cab0ed7c2646947f Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 16 Oct 2026 17:44:52 +0000 Subject: [PATCH 280/315] Lightroom import: follow catalog paths only on this computer or the catalog's share --- Cargo.lock | 5 + Cargo.toml | 1 + crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/doc_path.rs | 63 +++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/lightroom_archive.rs | 2 + crates/engine/src/lightroom_catalog.rs | 146 +++++++++++++++++++++---- crates/engine/src/lightroom_job.rs | 4 +- crates/ui-egui/src/lightroom_import.rs | 2 +- docs/lightroom-catalog-import.md | 9 ++ 11 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 crates/engine/src/doc_path.rs diff --git a/Cargo.lock b/Cargo.lock index 4b6d54289..c70a2adba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1072,6 +1072,10 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-ui" version = "0.1.0" @@ -3217,6 +3221,7 @@ name = "lightcraft-engine" version = "0.8.0" dependencies = [ "ab_glyph", + "craft-docpath", "harfrust", "jxl-oxide", "libc", diff --git a/Cargo.toml b/Cargo.toml index ac8cbeca1..8f08431b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ed20cd605..091efe066 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job workspace = true [dependencies] +# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share. +craft-docpath = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7b0e1e562..860cf089f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1488,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> { always, |_, p| { let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; - Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings})) + Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings})) }), cmd!( "library.importLightroom", diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 000000000..c2b0e9f69 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,63 @@ +//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the +//! originals a Lightroom catalog lists. +//! +//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the +//! document's folder, and only local files, or files on the network share the document itself is +//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing +//! file. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is on another computer (other than the document's share), names a device, is a + /// URL, or is not a usable path. + Refused(Refused), + /// The path is not an absolute path on this computer, such as a Windows drive path read on + /// macOS or Linux. + NotHere, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NotHere => f.write_str("the path is not a path on this computer"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`, +/// or why it is not followed. A relative `document` is taken from the working directory, where it +/// was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> { + let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); + let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // document does not choose. + if path.is_relative() { + return Err(NotFollowed::NotHere); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() { + let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat"); + assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg"))); + assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere)); + assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device))); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a282ce3e..1e9c18d99 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod demo; pub mod denoise; pub mod devices; pub mod display; +pub mod doc_path; mod errors; pub mod export; pub mod face_download; diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 0dceb421d..5133fc80c 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -538,6 +538,7 @@ mod tests { keywords: vec!["Trips|Goa".into()], history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])], snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])], + original: crate::lightroom_catalog::Original::Local, }; CatalogImport { source: "source.lrcat".into(), @@ -621,6 +622,7 @@ mod tests { keywords: Vec::new(), history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])], snapshots: Vec::new(), + original: crate::lightroom_catalog::Original::Local, }) .collect(); assert_eq!(store_best_effort(&path, &input).unwrap(), None); diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 777206bcb..d60d60582 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -2,7 +2,7 @@ //! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place. //! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -146,6 +146,31 @@ pub struct CatalogPhoto { pub keywords: Vec<String>, pub history: Vec<Row>, pub snapshots: Vec<Row>, + /// Whether `path` may be looked at (derived when the catalog is read; not archived). + #[serde(skip)] + pub original: Original, +} + +/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog +/// as the document ([`crate::doc_path::resolve`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Original { + /// `path` is a local file path, or one on the network share the catalog is on. + Local, + /// `path` is not a path on this computer (a catalog made on another system): the photo is + /// kept as an offline record for later relinking, and the path is not looked at. + #[default] + NotHere, + /// `path` is refused (why): it is on another computer or a device, or its folder or file name + /// is an absolute path. The photo is reported missing and not added to the library. + Refused(String), +} + +impl CatalogPhoto { + /// Whether the original is a file here; only a [`Original::Local`] path is looked at. + pub fn original_is_file(&self) -> bool { + self.original == Original::Local && Path::new(&self.path).is_file() + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -343,25 +368,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> { /// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from /// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the -/// catalog's volume, resolved against the catalog's folder, if that is a folder. +/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are +/// looked at only when [`crate::doc_path::resolve`] follows them. fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> { - if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() { + if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) { return None; } - let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative); - let mut out = PathBuf::new(); - for part in dir.components() { - match part { - Component::ParentDir => { - if !out.pop() { - return None; - } - } - Component::CurDir => {} - part => out.push(part), - } + crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir()) +} + +/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in +/// place of the root folder. +fn is_absolute_part(part: &str) -> bool { + let bytes = part.as_bytes(); + part.starts_with(['/', '\\']) + || (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':')) + || Path::new(part).is_absolute() +} + +/// The path of an original under `root` and whether it may be looked at, resolved with the +/// catalog at `catalog` as the document. +fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) { + let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string(); + if is_absolute_part(folder) || is_absolute_part(name) { + return (stored, Original::Refused("its folder or file name is an absolute path".into())); + } + match crate::doc_path::resolve(&stored, Some(catalog)) { + Ok(found) => (found.to_string_lossy().to_string(), Original::Local), + Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere), + Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())), } - out.is_dir().then_some(out) } /// Inspect `.lrcat` plus committed WAL pages without modifying either source file. @@ -457,6 +493,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, out }; let mut root_paths = HashMap::new(); + let mut refused_roots = HashSet::new(); let mut root_ids: Vec<_> = roots.keys().copied().collect(); root_ids.sort_unstable(); for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) { @@ -469,6 +506,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, } None => absolute.to_string(), }; + if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) { + warnings + .push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name"))); + refused_roots.insert(id); + } root_paths.insert(id, resolved); } let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); @@ -487,13 +529,19 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; - let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str); + let root_id = number(root, "id_local"); + let root_path = root_paths.get(&root_id).map_or("", String::as_str); let folder_path = text(folder, "pathFromRoot"); let name = text(file, "idx_filename"); if root_path.is_empty() || name.is_empty() { return Err(format!("image {id}: incomplete original path")); } - let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string(); + let (photo_path, original) = original_path(root_path, folder_path, name, path); + if let Original::Refused(why) = &original + && !refused_roots.contains(&root_id) + { + out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added")); + } let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) { Ok(s) => s, Err(e) => { @@ -504,13 +552,14 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, out.photos.push(CatalogPhoto { source_id: id, uuid: text(&image, "id_global").into(), - path, + path: photo_path, image, settings: develops.get(&id).cloned().unwrap_or_default(), xmp: packet, keywords: keywords.remove(&id).unwrap_or_default(), history: history.remove(&id).unwrap_or_default(), snapshots: snapshots.remove(&id).unwrap_or_default(), + original, }); } ensure_read_active(cancel)?; @@ -665,11 +714,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> let paths: Vec<_> = data .photos .iter() - .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file()) + .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file()) .map(|p| p.path.clone()) .collect(); let missing_sources: HashSet<String> = - data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect(); + data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect(); let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?; let applied = apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?; @@ -713,12 +762,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let mut photos: Vec<_> = data.photos.iter().collect(); photos.sort_by_key(|p| number(&p.image, "masterImage") != 0); let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect(); + let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect(); let mut planned: HashMap<PhotoId, Photo> = HashMap::new(); for src in photos { let master = number(&src.image, "masterImage"); if master == 0 && missing_sources.contains(&key(&src.path)) { missing.push(src.path.clone()); } + // A refused path must not become a library record the app would look at later; its + // virtual copies go with it. + if refused.contains(&src.source_id) || refused.contains(&master) { + continue; + } let indexed = index .photos .get(&identity(&data, src)) @@ -940,6 +995,7 @@ mod tests { keywords: vec!["Travel|Goa".into()], history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], snapshots: Vec::new(), + original: Original::Local, }; let mut copy = photo.clone(); copy.source_id = 2; @@ -1508,11 +1564,57 @@ mod tests { // a relative path that leads nowhere keeps the recorded one std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); let catalog = read(&path).unwrap(); - assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn originals_on_other_computers_are_never_looked_at_or_added() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + let source = dir.join("source.lrcat"); + let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; + if cfg!(unix) { + // `//<local folder>/` is a local folder to Linux, but a network path to the resolver. + roots.push(format!("/{}/", dir.display())); + } + for root in &roots { + std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); + assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a refused original must not be probed".into()) + })); + let report = import(&mut s, &source, false).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); + assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); + assert!(s.catalog.is_empty(), "{root}"); + } + // An absolute folder path would replace the root. + let mut tables = photo_tables((dir.to_str().unwrap(), "")); + tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); + // A root beside the catalog is followed. + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert_eq!(catalog.photos[0].original, Original::Local); + assert!(catalog.photos[0].original_is_file()); + // A root that moved is found through its path relative to the catalog. + std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn oversized_optional_table_warns_and_is_skipped() { let mut main = vec![0u8; 2 * 512]; diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 8de1cc806..52a51b30f 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V if !seen.insert(key.clone()) { continue; } - if Path::new(&photo.path).is_file() { + if photo.original_is_file() { paths.push(photo.path.clone()); } else { missing.insert(key); @@ -393,6 +393,7 @@ mod tests { keywords: Vec::new(), history: Vec::new(), snapshots: Vec::new(), + original: lightroom_catalog::Original::Local, }], collections: Vec::new(), members: Vec::new(), @@ -531,6 +532,7 @@ mod tests { serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))), )])], snapshots: Vec::new(), + original: lightroom_catalog::Original::Local, } }) .collect(); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index d7ea85122..241a23d6b 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -233,7 +233,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> json!({ "photos": data.photos.len(), "collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(), - "missing": data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::<Vec<_>>(), + "missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::<Vec<_>>(), "warnings": data.warnings, }) } diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 5ad4383fd..b6c0014f0 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -97,3 +97,12 @@ the path relative to the catalog that Lightroom records for root folders on the (`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying a project folder with its catalog inside it therefore keeps its originals linked. Other moved originals stay missing; use LightCraft's missing-photo relinking tools to update their paths. + +Original and root folder paths are resolved with the catalog as the document and are looked at +only when they are on this computer or on the network share the catalog itself is on +(`crates/engine/src/doc_path.rs`). An original on another computer (`\\server\share\…`, +`//server/…`, `file://server/…`), a device path, or one whose folder or file name in the catalog is +an absolute path is not looked at: the import lists it as missing with a warning and leaves the +photo and its virtual copies out of the library. To import originals on a share, open the catalog +from that share. A path that is not a path on this computer (a Windows drive path read on macOS or +Linux) is not looked at either and stays catalogued as missing. From 7b934ca2b2e6f818557291e670548b020311cdea Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Fri, 16 Oct 2026 17:52:01 +0000 Subject: [PATCH 281/315] Sidecars: write XMP only beside an existing local original --- .../src/cmd/library/tests_relative_ratings.rs | 1 + crates/engine/src/sidecar.rs | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs index 1a1b8b23c..8af63cd9b 100644 --- a/crates/engine/src/cmd/library/tests_relative_ratings.rs +++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs @@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() { std::fs::create_dir_all(&dir).unwrap(); // Metadata-only fixtures: no import, image decoding or model/service dependency. for id in 1..=4 { + std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap(); s.catalog .apply(Op::SetFile { id: PhotoId(id), diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index ea06d69a7..cdee1e894 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -4,6 +4,8 @@ //! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright, //! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop //! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`). +//! A sidecar is written only beside an original that is an existing file at an absolute path; +//! otherwise the save fails. //! //! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties //! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and @@ -632,6 +634,10 @@ impl Session { return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; + // A relative path would be taken from the working directory. + if Path::new(orig).is_relative() || !Path::new(orig).is_file() { + return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name))); + } let naming = owners.naming(p, self.xmp.naming); let preferred = sidecar_path(orig, naming); let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; @@ -786,6 +792,30 @@ impl Session { #[cfg(test)] mod tests { + #[test] + fn sidecars_are_written_only_beside_an_existing_original() { + let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap(); + let originals = [ + (r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false), + (dir.join("IMG_2.jpg").to_string_lossy().to_string(), false), + (dir.join("IMG_1.jpg").to_string_lossy().to_string(), true), + ]; + for (original, written) in originals { + let _ = std::fs::remove_file(dir.join("IMG_1.xmp")); + let mut s = Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}"); + assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}"); + assert!(!dir.join("IMG_2.xmp").exists(), "{original}"); + assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + /// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`) /// gives the photo its keywords as paths. #[test] From 5be931e48a72e7a1ee0d01aa6116003d80ccbf97 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:23:38 +0200 Subject: [PATCH 282/315] Show the applied profile when hovering it in the profile browser (#2057) --- crates/ui-egui/src/headless.rs | 17 +++++++++++++++++ crates/ui-egui/src/panels/profiles.rs | 3 +++ 2 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 6178990b0..74a907127 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1772,6 +1772,23 @@ mod tests { h.settle(SETTLE); } + /// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. + #[test] + fn hovering_the_applied_profile_drops_the_last_hover() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); + h.step(); + assert!(h.app.hover_preview.is_some(), "another profile previews"); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.step(); + assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); + } + /// Presets column: resting on a preset previews it in the loupe without a history entry; /// thumbnails are variant renders; Create Preset includes only the checked groups. #[test] diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index dac064d19..8a4b08345 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -188,6 +188,9 @@ fn cell_ui( app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() }); ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string()))); + } else if resp.hovered() && applied { + // resting on the applied profile shows it (no preview), not the last other profile hovered + ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); } let resp = resp.on_hover_text(format!( "{} ({})", From 80e439f6dc6d47c8eb9b646d93c0bc11f5ae5a12 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:45:25 +0000 Subject: [PATCH 283/315] Reject signed hex digits in #rrggbb colour parsing Found while testing crates/slideshow/src/settings.rs:296-300 (parse_color); same pattern in crates/engine/src/slideshow.rs:117-122 (fadeColor) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/color/src/lib.rs | 13 +++++++++++-- crates/engine/src/slideshow.rs | 4 +++- crates/slideshow/src/settings.rs | 5 ++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 855c3d049..89fe93c9f 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8); impl Rgb8 { pub fn from_hex(s: &str) -> Option<Rgb8> { - let s = s.trim_start_matches('#'); - if s.len() != 6 { + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let v = u32::from_str_radix(s, 16).ok()?; @@ -196,6 +196,15 @@ impl Rgb8 { mod tests { use super::*; + #[test] + fn rgb8_from_hex_rejects_signs_and_extra_hashes() { + assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15))); + assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0))); + for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] { + assert_eq!(Rgb8::from_hex(bad), None, "{bad}"); + } + } + #[test] fn srgb_matrix_matches_standard() { let m = SRGB.to_xyz(); diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs index 8f615bf0c..dbee505af 100644 --- a/crates/engine/src/slideshow.rs +++ b/crates/engine/src/slideshow.rs @@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> { Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None), Value::String(s) => { let h = s.strip_prefix('#').unwrap_or(s); - if h.len() != 6 || !h.is_ascii() { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(bad()); } let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad); @@ -747,6 +747,8 @@ mod tests { json!({"repeat": 1}), json!({"fadeColor": "#12345"}), json!({"fadeColor": "#ééé"}), + json!({"fadeColor": "#+f+f+f"}), + json!({"fadeColor": "-12345"}), json!({"fadeColor": [300, 0, 0]}), json!({"fadeColor": [1, 2]}), json!({"speed": 2}), diff --git a/crates/slideshow/src/settings.rs b/crates/slideshow/src/settings.rs index 6fb004bf6..fd3b327ef 100644 --- a/crates/slideshow/src/settings.rs +++ b/crates/slideshow/src/settings.rs @@ -293,7 +293,7 @@ pub fn parse_color(v: &Value) -> Option<[u8; 3]> { match v { Value::String(s) => { let h = s.trim().strip_prefix('#').unwrap_or(s.trim()); - if h.len() != 6 || !h.is_ascii() { + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); @@ -356,6 +356,9 @@ mod tests { json!({"zoomToFill": 1}), json!({"strokeColor": "#12345"}), json!({"strokeColor": "#ééééé"}), + json!({"strokeColor": "#+f+f+f"}), + json!({"backdropColor": "+12345"}), + json!({"washColor": "#-00000"}), json!({"washColor": [256, 0, 0]}), json!({"washColor": [-1, 0, 0]}), json!({"backdropColor": [1, 2]}), From edc369565eef92161b56b8f1e39ef53629c05489 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:17:46 +0200 Subject: [PATCH 284/315] Reject trailing text after a bracketed IPv6 host in Url::parse Found while testing crates/fetch/src/http.rs:87-102 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/fetch/src/http.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index c2b5bee1e..f9a7ba6a8 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -88,6 +88,9 @@ impl Url { let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?; let host = v6.get(..end).unwrap_or_default(); let after = v6.get(end + 1..).unwrap_or_default(); + if !after.is_empty() && !after.starts_with(':') { + return Err(bad("unexpected text after IPv6 address")); + } (host.to_string(), after.strip_prefix(':')) } else { match authority.rsplit_once(':') { @@ -537,6 +540,17 @@ mod tests { } } + #[test] + fn rejects_text_after_a_bracketed_ipv6_host() { + for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] { + assert!(matches!(Url::parse(bad), Err(HttpError::BadUrl(_))), "{bad}"); + } + let u = Url::parse("http://[::1]:8080/x").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 8080)); + let u = Url::parse("http://[::1]/x").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 80)); + } + #[test] fn resolves_redirects() { let u = Url::parse("https://a.example/x/y/file?q").unwrap(); From 62eb6627a62891677c5336f83d2f19c476f7adcf Mon Sep 17 00:00:00 2001 From: aiongg <aiongtaigi@gmail.com> Date: Sat, 17 Oct 2026 02:21:02 +0000 Subject: [PATCH 285/315] Build: use craft-docpath from craft-libs --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c70a2adba..6ba84c4a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,6 +1075,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" [[package]] name = "craft-ui" diff --git a/Cargo.toml b/Cargo.toml index 8f08431b2..3c00cb4f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } +craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 8e268431e071dca6c9ff698e5f6e6be11e41c4b1 Mon Sep 17 00:00:00 2001 From: wly <tyyyy66@icloud.com> Date: Sat, 17 Oct 2026 15:08:07 +0800 Subject: [PATCH 286/315] l10n: unify zh-hant library term and contact sheet wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use 照片庫 for "library" everywhere (it was split between 照片庫 and 圖庫), and 縮圖目錄 for the contact sheet progress label to match the File menu item. Only translations change; English keys and format placeholders are untouched. --- crates/ui-egui/locales/zh-hant-formats.json | 4 +-- crates/ui-egui/locales/zh-hant.json | 34 ++++++++++----------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 7128165f5..62bc3e681 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -249,7 +249,7 @@ "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", "{what} merge cancelled": "已取消{what}合併", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)", "New Folder in “{name}”": "在「{name}」中新增資料夾", "Moved {} photo(s)": "已移動 {} 張照片", "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", @@ -292,7 +292,7 @@ "{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}", "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", - "Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", + "Renames {n} face{} across the library. The files are not changed.": "在整個照片庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})" diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 19c7bc338..a1723fc22 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -44,7 +44,7 @@ "Add to My Photos": "加入我的照片", "Can't be moved here": "無法移到這裡", "This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾", - "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在照片庫中:請先「加入我的照片」,再移動", "Add to Selected Photos": "加入選取的照片", "Add to Target Album": "加入目標相簿", "Add to this mask": "加入此遮色片", @@ -280,7 +280,7 @@ "Move Photos": "移動照片", "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。", "Don't ask again": "不再詢問", - "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個照片庫:未移動任何內容", "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟", "Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認", "Constrain Crop": "限制裁切範圍", @@ -1938,7 +1938,7 @@ "Paper": "紙白", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", - "Exporting contact sheet": "正在匯出聯絡表", + "Exporting contact sheet": "正在匯出縮圖目錄", "Reading folder": "正在讀取檔案夾", "Scanning folder": "正在掃描檔案夾", "Building previews": "正在建立預覽", @@ -1953,7 +1953,7 @@ "Looking for changes": "正在尋找變更", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。", - "Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片", + "Albums hold library photos: Add to My Photos first": "相簿只能收錄照片庫中的照片:請先加入我的照片", "Add to My Photos First": "先加入我的照片", "Fill Location from GPS": "根據 GPS 填寫位置", "Download Place Names (GeoNames)": "下載地名資料 (GeoNames)", @@ -1978,14 +1978,14 @@ "No imported folders yet": "還沒有匯入的資料夾", "Show Sidebar Section": "顯示側邊欄區段", "Move Sidebar Section": "移動側邊欄區段", - "New Library…": "新增圖庫…", - "Open Recent Library": "開啟最近的圖庫", - "Recent Libraries": "最近的圖庫", - "Clear Recent Libraries": "清除最近的圖庫", - "No Recent Libraries": "沒有最近的圖庫", - "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置", - "New Library": "新增圖庫", - "Library name": "圖庫名稱", + "New Library…": "新增照片庫…", + "Open Recent Library": "開啟最近的照片庫", + "Recent Libraries": "最近的照片庫", + "Clear Recent Libraries": "清除最近的照片庫", + "No Recent Libraries": "沒有最近的照片庫", + "New Library: Choose Where to Put It": "新增照片庫:選擇存放位置", + "New Library": "新增照片庫", + "Library name": "照片庫名稱", "New Folder on Disk": "在磁碟上新增資料夾", "New Folder on Disk…": "在磁碟上新增資料夾…", "Move to Folder": "移至資料夾", @@ -2004,10 +2004,10 @@ "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", "Lightroom Catalog Import": "Lightroom 目錄匯入", - "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的圖庫並再次匯入目錄以儲存相片。", - "Show in Library": "在圖庫中顯示", - "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的圖庫,不會建立獨立的目錄資料夾。", - "Destination library": "目標圖庫", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的照片庫並再次匯入目錄以儲存相片。", + "Show in Library": "在照片庫中顯示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的照片庫,不會建立獨立的目錄資料夾。", + "Destination library": "目標照片庫", "Playback": "播放", "Layout": "版面", "Link all margins": "連結所有邊界", @@ -2028,7 +2028,7 @@ "Background colour": "背景顏色", "Colour wash": "顏色漸層", "Background image": "背景影像", - "(not in the library)": "(不在圖庫中)", + "(not in the library)": "(不在照片庫中)", "Use selected photo": "使用所選照片", "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", "Reset layout": "重設版面", From e0c02d5e893ba915e2ed7a5fcbefe0f79dea902b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:12:52 +0200 Subject: [PATCH 287/315] Strip XML 1.0-forbidden characters from XMP sidecar output Fixes #2131 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/meta/src/xmp.rs | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index c27fe4ae2..9a54301ee 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -185,11 +185,22 @@ fn entity(name: &str) -> Option<char> { Some(h) => u32::from_str_radix(h, 16).ok()?, None => n.parse().ok()?, }; - char::from_u32(v)? + char::from_u32(v).filter(|&c| is_xml_char(c))? } }) } +/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`). +fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}') +} + +/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the +/// whole document for. +fn xml_safe(s: &str) -> String { + s.chars().filter(|&c| is_xml_char(c)).collect() +} + /// Unescape attribute values (predefined + numeric references; unknown references kept verbatim). fn unescape_attr(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -623,12 +634,12 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { } x.push_str(">\n"); for (k, v) in &simple { - x.push_str(&format!(" <{k}>{}</{k}>\n", escape(v.as_str()))); + x.push_str(&format!(" <{k}>{}</{k}>\n", escape(xml_safe(v)))); } for (k, v) in lc { if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { // element content: quotes needn't be escaped, which keeps the JSON readable - x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v))); + x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(xml_safe(v)))); } } let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| { @@ -638,9 +649,9 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n")); for it in items { if lang { - x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(it.as_str()))); + x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it)))); } else { - x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(it.as_str()))); + x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it)))); } } x.push_str(&format!(" </rdf:{kind}>\n </{k}>\n")); @@ -1039,4 +1050,24 @@ mod tests { a.overlay_user_fields(&b); assert_eq!(a.regions, vec![with_region]); } + + #[test] + fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_xmp_lc(&m, &[("settings", bad)]); + let illegal: Vec<char> = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); + } + + #[test] + fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); + } } From 035d572ac81ae8bfa87817bb189fb014e7a6018c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:57:37 +0200 Subject: [PATCH 288/315] Refuse catalog snapshots with an out-of-range seq Fixes #2130 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/catalog/src/journal.rs | 15 +++++++++++---- crates/catalog/src/tests_journal.rs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 289ee4fb8..edcfae22a 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> { const START: &[u8] = b"{\"seq\":"; (1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| { let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?; - (rec.0 <= last + 1).then_some((i, rec)) + (rec.0 <= last.saturating_add(1)).then_some((i, rec)) }) } @@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() }) } +/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow. +const MAX_SEQ: u64 = u64::MAX / 2; + /// Just the identification of `catalog.snap` (read before the catalog itself). #[derive(serde::Deserialize)] struct SnapHeader { @@ -331,6 +334,10 @@ impl Journal { return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version))); } let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?; + // a seq this high can't come from real use; the next record's seq would wrap + if s.seq > MAX_SEQ { + return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq))); + } let mut catalog = s.catalog; report.repaired = catalog.repair(); for r in &report.repaired { @@ -392,7 +399,7 @@ impl Journal { let _ = op; report.stale += 1; } - Some((seq, op)) if seq == j.seq + 1 => { + Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => { if catalog.apply(op).is_err() { report.failed += 1; } else { @@ -476,7 +483,7 @@ impl Journal { let mut buf = String::new(); let mut seq = self.seq; for op in ops { - seq += 1; + seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?; buf.push_str(&encode_record(seq, op)); buf.push('\n'); } @@ -527,7 +534,7 @@ impl Journal { log::warn!("catalog: background snapshot failed ({e}); writing one now"); } let t0 = web_time::Instant::now(); - let seq = self.seq + unlogged; + let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?; let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) { Ok(t) => t, Err(e) => { diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 8570bd077..26b4e902a 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() { let d = &loaded.photo(id).unwrap().develop; assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8)); } + +/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would +/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug. +#[test] +fn snapshot_with_seq_near_u64_max_is_corrupt() { + for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] { + let m = MemStore::new(); + m.set( + SNAPSHOT, + format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(), + ); + let e = Journal::open(Box::new(m)).err(); + assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}"); + } +} From fafa70724b3137c33776da6f4baf48150a549a97 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:33:33 +0200 Subject: [PATCH 289/315] Keep face index records aligned after a torn or failed append Fixes #2129 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/engine/src/faces_index.rs | 77 +++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index bf10e3502..17bffbc19 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -167,6 +167,9 @@ impl Index { self.entries.insert((photo, key), (emb.into(), view)); } } + // a half-written last record (a crash while appending): written fresh at the next save, so later records are + // not appended after it and shifted + self.rewrite = !rest.is_empty(); } fn header(&self) -> Vec<u8> { @@ -201,12 +204,11 @@ impl Index { return Ok(()); } if self.file_matches && !self.rewrite { - let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let mut buf = Vec::new(); for (k, e) in &self.unsaved { buf.extend(self.record(k, e)); } - f.write_all(&buf)?; + append(&path, &buf)?; } else { let mut buf = self.header(); for (k, e) in &self.entries { @@ -229,6 +231,22 @@ impl Drop for Index { } } +/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so +/// the part that got in does not shift what is appended later. +fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new().append(true).open(path)?; + let old_len = f.metadata()?.len(); + let written = f.write_all(buf); + if written.is_err() { + drop(f); + // an append-only handle may not truncate (Windows): cut through a writable one + if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) { + let _ = w.set_len(old_len); + } + } + written +} + const SCAN_MAGIC: &[u8; 4] = b"LCFS"; const SCAN_VERSION: u8 = 1; /// The detector a search was made with: a record made by another one is ignored (a better detector may find more). @@ -325,8 +343,11 @@ impl Scanned { let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; - // a half-written last id (a crash while appending) is ignored - self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c))); + // a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save + // so later ids are not appended after it and shifted + let (ids, torn) = rest.as_chunks::<8>(); + self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c))); + self.rewrite = !torn.is_empty(); } /// Write what was added since the last save: appended when the file is this detector's, else the whole record @@ -341,9 +362,8 @@ impl Scanned { return Ok(()); } if self.file_matches && !self.rewrite { - let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); - f.write_all(&buf)?; + append(&path, &buf)?; } else { let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); @@ -487,6 +507,51 @@ mod tests { assert!(!t.contains(1) && t.contains(2) && t.contains(3)); let _ = std::fs::remove_dir_all(&dir); } + + /// A torn last record (a crash or a full disk while appending) does not shift the records appended after it + /// (issue #2129). + #[test] + fn records_appended_after_a_torn_tail_stay_aligned() { + let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin")); + let junk = |p: &Path| { + let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap(); + f.write_all(&[0x3f; 5]).unwrap(); + }; + let mut a = Index::default(); + a.reset("m@abc", 3, Some(emb.clone())); + a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]); + a.save().unwrap(); + drop(a); + junk(&emb); + let mut b = Index::default(); + b.reset("m@abc", 3, Some(emb.clone())); + b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]); + b.save().unwrap(); + drop(b); + let mut c = Index::default(); + c.reset("m@abc", 3, Some(emb)); + assert_eq!(c.len(), 2); + assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5])); + assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125])); + let mut s = Scanned::default(); + s.reset(Some(scan.clone())); + s.insert(42); + s.save().unwrap(); + drop(s); + junk(&scan); + let mut t = Scanned::default(); + t.reset(Some(scan.clone())); + t.insert(43); + t.save().unwrap(); + drop(t); + let mut u = Scanned::default(); + u.reset(Some(scan)); + assert!(u.contains(42) && u.contains(43) && u.len() == 2); + let _ = std::fs::remove_dir_all(&dir); + } } /// What the recognition commands keep between calls: the loaded model and the embeddings made with it. From f6d465c72c7ddc2c6a4f624607c705e8776d5483 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:50:59 +0200 Subject: [PATCH 290/315] Make TIFF chunk-length inference and header-plan merging fast Fixes #2128 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/raw/src/sparse.rs | 59 ++++++++++++++++++++++++++++++++++++++-- crates/tiff/src/image.rs | 54 +++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index 53efa0567..d67a92b40 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -75,6 +75,21 @@ impl Ranges { self.0 = out; } + /// Add every `[start, end)` of `spans` in one sort and merge (empty ones add nothing). + pub fn extend(&mut self, spans: impl IntoIterator<Item = (u64, u64)>) { + let mut all: Vec<(u64, u64)> = std::mem::take(&mut self.0); + all.extend(spans.into_iter().filter(|&(a, b)| a < b)); + all.sort_unstable(); + let mut out: Vec<(u64, u64)> = Vec::with_capacity(all.len()); + for (a, b) in all { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + self.0 = out; + } + /// Whether all of `[start, end)` is covered (an empty range always is). pub fn contains(&self, start: u64, end: u64) -> bool { start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) @@ -232,6 +247,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { } let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; let mut holes = Ranges::new(); + let mut spans = Vec::new(); for ifd in tiff.all_ifds() { let chunks: Vec<(u64, u64)> = match ifd.image() { // a preview's strips are read when the preview is wanted: only the raw image is skipped @@ -249,11 +265,12 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { if total < BULK { continue; } - for (i, &(offset, size)) in chunks.iter().enumerate() { + spans.extend(chunks.iter().enumerate().map(|(i, &(offset, size))| { let start = if i == 0 { offset.saturating_add(HEAD) } else { offset }; - holes.insert(start, offset.saturating_add(size).min(len)); - } + (start, offset.saturating_add(size).min(len)) + })); } + holes.extend(spans); // A Panasonic RW2 keeps its raw data from `RawDataOffset` to the end, outside any TIFF image. if holes.is_empty() && bytes.starts_with(b"IIU\0") @@ -567,4 +584,40 @@ mod tests { assert!(crate::probe_info(&file).is_ok()); assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles")); } + + /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is + /// built in one sort and merge, not one pass over all strips (or all holes) per strip. + #[test] + fn many_strips_without_byte_counts_plan_fast() { + let n = 1_000_000u32; + let (offsets_at, counts_at) = (8 + 2 + 5 * 12 + 4, 8 + 2 + 5 * 12 + 4 + 4 * n); + let data = counts_at + 4 * n; + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&5u16.to_le_bytes()); + let entry = |tiff: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| { + tiff.extend_from_slice(&tag.to_le_bytes()); + tiff.extend_from_slice(&typ.to_le_bytes()); + tiff.extend_from_slice(&count.to_le_bytes()); + tiff.extend_from_slice(&value.to_le_bytes()); + }; + entry(&mut tiff, 256, 4, 1, 1); + entry(&mut tiff, 257, 4, 1, n); + entry(&mut tiff, 273, 4, n, offsets_at); + entry(&mut tiff, 278, 4, 1, 1); + entry(&mut tiff, 279, 4, n, counts_at); + tiff.extend_from_slice(&0u32.to_le_bytes()); + for i in (0..n).rev() { + tiff.extend_from_slice(&(data + i).to_le_bytes()); + } + tiff.resize(data as usize + n as usize, 0); + let len = tiff.len() as u64; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut loaded = Ranges::new(); + loaded.insert(0, len); + let _ = tx.send(header_plan(&tiff, &loaded)); + }); + let plan = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s"); + assert!(!matches!(plan, HeaderPlan::Need(_)), "{plan:?}"); + } } diff --git a/crates/tiff/src/image.rs b/crates/tiff/src/image.rs index 2188ab502..ca3ff8b69 100644 --- a/crates/tiff/src/image.rs +++ b/crates/tiff/src/image.rs @@ -127,6 +127,13 @@ impl ImageInfo { let (cw, ch) = self.chunk_size(); let n = self.offsets.len().min(per_plane.saturating_mul(planes)); let mut out = Vec::with_capacity(n); + // the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk) + let mut sorted: Vec<u64> = Vec::new(); + if (0..n).any(|i| self.byte_counts.get(i).is_none_or(|&l| l == 0)) { + sorted = self.offsets.clone(); + sorted.sort_unstable(); + sorted.dedup(); + } for i in 0..n { let plane = i / per_plane.max(1); let j = i % per_plane.max(1); @@ -142,7 +149,7 @@ impl ImageInfo { Some(&l) if l > 0 => l, _ => { // infer: up to the next larger offset, else end of data - let next = self.offsets.iter().copied().filter(|&o| o > offset).min().unwrap_or(data_len); + let next = sorted.get(sorted.partition_point(|&o| o <= offset)).copied().unwrap_or(data_len); next.saturating_sub(offset) } }; @@ -162,3 +169,48 @@ pub fn chunk_bytes<'a>(data: &'a [u8], c: &Chunk) -> Option<&'a [u8]> { let end = (c.offset.saturating_add(c.len)).min(data.len() as u64) as usize; Some(&data[start..end]) } + +#[cfg(test)] +mod tests { + use super::*; + + fn strips(offsets: Vec<u64>, byte_counts: Vec<u64>) -> ImageInfo { + ImageInfo { + width: 1, + height: offsets.len() as u32, + bits_per_sample: vec![8], + samples_per_pixel: 1, + compression: 1, + photometric: 1, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Strips { rows_per_strip: 1 }, + offsets, + byte_counts, + } + } + + #[test] + fn missing_byte_counts_run_to_the_next_larger_offset() { + let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]); + let lens: Vec<u64> = info.chunks(100).iter().map(|c| c.len).collect(); + assert_eq!(lens, vec![20, 20, 5, 20, 30]); + } + + /// A million strips without byte counts (offsets in descending order) are measured in one sort, not one + /// scan of all offsets per strip. + #[test] + fn many_strips_without_byte_counts_are_fast() { + let n = 1_000_000u64; + let info = strips((0..n).rev().map(|i| 1000 + i * 2).collect(), Vec::new()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let chunks = info.chunks(1000 + n * 2); + let _ = tx.send(chunks.iter().map(|c| c.len).collect::<Vec<u64>>()); + }); + let lens = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("chunks() took longer than 20 s"); + assert!(lens.iter().all(|&l| l == 2)); + } +} From 16beb4db40eaac41e063583b18d24413a3d26b6f Mon Sep 17 00:00:00 2001 From: adrdsouza <102761952+adrdsouza@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:38:18 +0530 Subject: [PATCH 291/315] Bound JPEG XL input feeds through the final frame (#1517) Retain the pre-frame size check and allocation budget while avoiding untracked input-sized buffers. Add the reported heap regression and container/progressive compatibility checks. Co-authored-by: Codex <noreply@openai.com> --- crates/codecs/Cargo.toml | 3 + crates/codecs/src/jxl.rs | 13 ++-- crates/codecs/tests/jxl_streaming.rs | 98 ++++++++++++++++++++++++++++ crates/codecs/tests/jxl_tail_heap.rs | 43 ++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 crates/codecs/tests/jxl_streaming.rs create mode 100644 crates/codecs/tests/jxl_tail_heap.rs diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 1ae016bc8..589d285fa 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -55,3 +55,6 @@ jxl-oxide = { workspace = true } proptest = { workspace = true } # Lossless synthetic HEIC files for the `heif` feature's tests. lightcraft-heif = { workspace = true, features = ["testdata"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +dhat = { workspace = true } diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index 3e6e155b5..d629a1e10 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -25,8 +25,8 @@ const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; /// overhead (group buffers, entropy tables) whatever `max_pixels` is. const MIN_TRACKED_BYTES: u64 = 64 << 20; -/// Bytes handed to the header stage at a time (the size `JxlImageBuilder::read` reads in). -const HEADER_CHUNK: usize = 4096; +/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in). +const INPUT_CHUNK: usize = 4096; /// Parses the header, checks the image size against `opts`, and only then reads the frames, with /// an allocation budget, so a huge image is refused before any of its pixels are decoded @@ -38,7 +38,7 @@ fn open(bytes: &[u8], opts: &DecodeOptions) -> Result<JxlImage> { // Small slices, as `read` does: whatever the header stage has buffered when it initializes is // parsed as frame data right away, so feeding the whole file here would skip the size check. let mut rest = bytes; - let mut window = HEADER_CHUNK; + let mut window = INPUT_CHUNK; let mut img = loop { let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?; rest = rest.get(consumed..).unwrap_or_default(); @@ -55,8 +55,11 @@ fn open(bytes: &[u8], opts: &DecodeOptions) -> Result<JxlImage> { } }; check_size(F, img.width() as u64, img.height() as u64, opts)?; - while !rest.is_empty() { - let consumed = img.feed_bytes(rest).map_err(err)?; + // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first + // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail + // after the last frame; feeding the remaining file at once bypasses the allocation budget. + while !img.is_loading_done() && !rest.is_empty() { + let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?; if consumed == 0 { break; } diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs new file mode 100644 index 000000000..298034656 --- /dev/null +++ b/crates/codecs/tests/jxl_streaming.rs @@ -0,0 +1,98 @@ +#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +fn codestream(bytes: &[u8]) -> Vec<u8> { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12; + while at + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let end = if len == 0 { bytes.len() } else { at + len }; + match &bytes[at + 4..at + 8] { + b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]), + b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]), + _ => {} + } + at = end; + } + assert!(raw.starts_with(b"\xff\x0a")); + raw +} + +fn box_bytes(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); +} + +#[test] +fn metadata_in_final_feed_matches_builder() { + let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]); + let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let raw = codestream(&encoded); + let exif = minimal_exif(1); + let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field + exif_box.extend_from_slice(&exif); + // Split the codestream across two feeds, with Exif and XMP following its last frame. + let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec(); + box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl "); + let split = raw.len() / 2; + let mut part = 0u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[..split]); + box_bytes(&mut bytes, b"jxlp", &part); + let padding = vec![0; 4096 - bytes.len() - 8]; + box_bytes(&mut bytes, b"free", &padding); + let mut part = 0x8000_0001u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[split..]); + box_bytes(&mut bytes, b"jxlp", &part); + box_bytes(&mut bytes, b"Exif", &exif_box); + box_bytes(&mut bytes, b"xml ", b"<xmp/>"); + assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk"); + let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap(); + let old_exif = match old.aux_boxes().first_exif().unwrap() { + jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(), + _ => panic!("builder must preserve Exif in its final feed"), + }; + let old_xml = match old.aux_boxes().first_xml() { + jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(), + _ => panic!("builder must preserve XMP in its final feed"), + }; + let decoded = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice())); + assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap())); + assert_eq!(decoded.to_srgb8(), pixels); +} + +#[test] +fn progressive_truncation_matches_builder() { + let pixels = Rgba8::from_fn(96, 64, |x, y| { + let mut seed = (y * 96 + x + 1) as u32; + let mut rgb = [0; 4]; + for channel in &mut rgb[..3] { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + *channel = seed as u8; + } + rgb[3] = 255; + rgb + }); + for lossless in [false, true] { + let encoded = + encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default()) + .unwrap(); + let raw = codestream(&encoded); + assert!(raw.len() > 4096, "fixture must exercise post-header feeds"); + for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] { + let bytes = &raw[..cut]; + // `finalize` permits partial images: rendering determines whether frame 0 is usable. + let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok()); + assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len()); + } + } +} diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs new file mode 100644 index 000000000..068ebc952 --- /dev/null +++ b/crates/codecs/tests/jxl_tail_heap.rs @@ -0,0 +1,43 @@ +#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] + +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn codestream(bytes: &[u8]) -> Vec<u8> { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12; + while at + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let end = if len == 0 { bytes.len() } else { at + len }; + match &bytes[at + 4..at + 8] { + b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]), + b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]), + _ => {} + } + at = end; + } + assert!(raw.starts_with(b"\xff\x0a")); + raw +} + +#[test] +fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() { + let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]); + let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let mut raw = codestream(&encoded); + let opts = DecodeOptions { max_pixels: 64, ..Default::default() }; + decode(&raw, opts.clone()).unwrap(); + raw.resize(raw.len() + (1 << 20), 0); + let _profiler = dhat::Profiler::builder().testing().build(); + let decoded = decode(&raw, opts).unwrap(); + assert_eq!((decoded.width, decoded.height), (8, 8)); + let peak = dhat::HeapStats::get().max_bytes; + eprintln!("peak heap bytes: {peak}"); + assert!(peak < (128 << 10), "tiny image used {peak} heap bytes"); +} From 9c4fce176f8ef541d9dcc9970e9a854266b91aec Mon Sep 17 00:00:00 2001 From: adrdsouza <102761952+adrdsouza@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:50:59 +0530 Subject: [PATCH 292/315] Record CI-generated codecs test dependency lockfile Co-authored-by: Codex <noreply@openai.com> --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 4b6d54289..3ec9cd317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3135,6 +3135,7 @@ version = "0.8.0" dependencies = [ "avif-serialize", "bytemuck", + "dhat", "image", "image-webp", "jixel", From a8bc88209227a8dad6aa5c6d1239b335b2a3f7ee Mon Sep 17 00:00:00 2001 From: adrdsouza <102761952+adrdsouza@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:13:49 +0530 Subject: [PATCH 293/315] Correct JPEG XL test target guard & tracked-budget claims (#1517) Pitch-Impact: none Co-authored-by: Codex <noreply@openai.com> --- crates/codecs/src/jxl.rs | 14 ++++++-------- crates/codecs/tests/roundtrip.rs | 5 ++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index d629a1e10..adf59c0aa 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -16,9 +16,8 @@ fn err(e: impl std::fmt::Display) -> Error { Error::Malformed(F, e.to_string()) } -/// Bytes the decoder may hold per allowed pixel: four f32 channels, eight times over for frames, -/// extra channels and working buffers. The budget follows `max_pixels`, so oversized frames, extra -/// channels or modular trees inside an image of allowed size still fail (#1517). +/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees. +/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517). const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; /// The allocation budget never drops below this, so small images keep the decoder's fixed @@ -28,15 +27,14 @@ const MIN_TRACKED_BYTES: u64 = 64 << 20; /// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in). const INPUT_CHUNK: usize = 4096; -/// Parses the header, checks the image size against `opts`, and only then reads the frames, with -/// an allocation budget, so a huge image is refused before any of its pixels are decoded -/// (`JxlImageBuilder::read` loads everything first). +/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames +/// within a tracked allocation budget. fn open(bytes: &[u8], opts: &DecodeOptions) -> Result<JxlImage> { let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES); let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX)); let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit(); - // Small slices, as `read` does: whatever the header stage has buffered when it initializes is - // parsed as frame data right away, so feeding the whole file here would skip the size check. + // Initialization can load already-buffered frame data, so small feeds limit work before + // dimension checking. let mut rest = bytes; let mut window = INPUT_CHUNK; let mut img = loop { diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 2ab55da31..3391e3a91 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -734,10 +734,9 @@ fn jxl_lossless_exact_with_metadata() { assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image)); } -/// #1517: the size limit is checked from the header, before any frame is read, and a -/// file cut anywhere is an error, never a panic. +/// #1517: the pixel limit rejects an oversized image; selected truncations never panic. #[test] -#[cfg(feature = "jxl")] +#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))] fn jxl_size_limit_and_truncation() { let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() }); From d54673bb9525391ee6d23fa4f87f063df9ee9833 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 17 Oct 2026 10:18:47 -0600 Subject: [PATCH 294/315] #2135: only file types LightCraft reads reach the system opener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photo ▸ Edit In ▸ Open Original in Other Editor handed a photo's stored path straight to `open` / `xdg-open` / `explorer.exe`, with `Path::is_file()` as the only check, and Lightroom catalog import kept original paths exactly as the catalog had them. A crafted `.lrcat` could therefore put `payload.exe`, `start.lnk`, `run.bat`, `page.hta`, `Evil.app` or `go.command` into the library and have it run on one user click. - `lightcraft_engine::open_path`: one gate for every path that leaves LightCraft for the OS. The extension must be one LightCraft reads (`import::EXTENSIONS`, which already covers the Edit In copy formats `tif`/`tiff`/`psd`), it must be a regular file (so a `.app` bundle and any other folder are out), what a link resolves to must be a read type too, and what is handed over is the absolute path. A refusal is the sentence shown to the user. - Open Original and Edit In go through it; a refused photo is listed in `skipped` with the reason, and the menu item is greyed out for a record that can never be opened (extension only, so no disk or share is touched while a menu is drawn). - `lightroom_catalog::refuse_non_media` drops such images where the catalog is read and again where its photos become records, with a warning each, so a refused entry never becomes an openable library record. A virtual copy of a refused original goes with it. Tests: a catalog naming a program (and a virtual copy of it) imports neither while the catalog's real photos do; a library record naming a program is never handed to the stub launcher, which records no invocation, and a program selected beside a photo leaves only the photo, by absolute path. Both fail before this change. Co-Authored-By: Claude <noreply@anthropic.com> --- crates/engine/src/lib.rs | 1 + crates/engine/src/lightroom_catalog.rs | 90 ++++++++++++++++++- crates/engine/src/open_path.rs | 117 +++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 34 ++++--- crates/ui-egui/src/tests_masking.rs | 64 ++++++++++++++ docs/parity-checklist.md | 2 +- 6 files changed, 294 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/open_path.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a282ce3e..3b31d6d13 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub mod media; pub mod memory; pub mod merge; mod model_download; +pub mod open_path; pub mod originals; mod pdf; pub mod preset_import; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 777206bcb..3c4b538fa 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -513,10 +513,54 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, snapshots: snapshots.remove(&id).unwrap_or_default(), }); } + refuse_non_media(&mut out); ensure_read_active(cancel)?; Ok(out) } +/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the +/// import's warnings. +/// +/// The catalog decides these paths, and a record it leaves behind is one click from the system +/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`, +/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run +/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not +/// openable. A virtual copy of a refused original goes with it — it has no original of its own. +/// +/// Called where the catalog is read and again where its photos become records, so neither route +/// can keep one; the second pass finds nothing left to say. +pub(crate) fn refuse_non_media(data: &mut CatalogImport) { + // masters first: a virtual copy may come before its master in the table + let refused: HashSet<i64> = data + .photos + .iter() + .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path))) + .map(|p| p.source_id) + .collect(); + if refused.is_empty() { + return; + } + let mut warnings = Vec::new(); + data.photos.retain(|p| { + let master = number(&p.image, "masterImage"); + // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not + // take every master of the catalog with it + if master != 0 { + if !refused.contains(&master) { + return true; + } + warnings.push(format!("image {}: not imported; its original was not imported", p.source_id)); + return false; + } + if !refused.contains(&p.source_id) { + return true; + } + warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path)); + false + }); + data.warnings.extend(warnings); +} + fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } @@ -692,8 +736,10 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } -pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> { +pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> { let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; + // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135) + refuse_non_media(&mut data); validate(&data).map_err(error)?; let mut warnings = data.warnings.clone(); let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect(); @@ -962,6 +1008,48 @@ mod tests { warnings: Vec::new(), } } + /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so + /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual + /// copies go with it; the catalog's real photos still import. + #[test] + fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { + let mut data = sample(); + let mut exe = data.photos[1].clone(); + exe.source_id = 3; + exe.uuid = "payload".into(); + exe.path = "/Users/sky/Downloads/payload.exe".into(); + exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); + let mut exe_copy = exe.clone(); + exe_copy.source_id = 4; + exe_copy.uuid = "payload-copy".into(); + exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); + exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); + // the copy comes before its master, as a catalog's table order may have it + data.photos.insert(0, exe_copy); + data.photos.push(exe); + for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { + let mut one = data.clone(); + one.photos.retain(|p| p.source_id < 3); + let mut planted = one.photos[1].clone(); + planted.source_id = 5; + planted.uuid = "planted".into(); + planted.path = bad.into(); + planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); + one.photos.push(planted); + let mut s = crate::Session::new(); + let r = apply(&mut s, one, false).unwrap(); + assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); + } + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); + assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); + } + #[test] fn offline_local_master_and_copy_join_library_with_one_undo_step() { let mut s = crate::Session::new(); diff --git a/crates/engine/src/open_path.rs b/crates/engine/src/open_path.rs new file mode 100644 index 000000000..d80039729 --- /dev/null +++ b/crates/engine/src/open_path.rs @@ -0,0 +1,117 @@ +//! Whether a path the library holds may be handed to the operating system. +//! +//! A photo's `Source::File` path is not always something a user typed: it can come from an +//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder. +//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever +//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta` +//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135). +//! +//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular +//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which +//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must +//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the +//! absolute path to hand over; what comes back on refusal is the sentence to show the user. +//! +//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may +//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a +//! refused original never becomes a library record in the first place. + +use std::path::Path; + +/// The absolute path to hand to an opener or external editor, or the reason it is refused. +/// +/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses +/// the path's own file name. +pub fn openable(path: &str, label: &str) -> Result<String, String> { + let p = Path::new(path); + let name = match label.trim() { + "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()), + l => l.to_string(), + }; + if !is_media(p) { + return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application")); + } + match std::fs::metadata(p) { + Ok(m) if m.is_file() => {} + Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")), + Err(e) => return Err(format!("the original is missing: {path} ({e})")), + } + // a link may name a photo and point at a program; what runs is what it points at + if let Ok(target) = p.canonicalize() + && !is_media(&target) + { + return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display())); + } + let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?; + Ok(absolute.to_string_lossy().into_owned()) +} + +/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included). +pub fn is_media(path: &Path) -> bool { + crate::import::is_supported(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, b"x").unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn only_file_types_lightcraft_reads_are_handed_over() { + let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] { + let path = write(&dir, good); + assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads"); + } + for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] { + let path = write(&dir, bad); + let e = openable(&path, "").unwrap_err(); + assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}"); + } + // a bundle (macOS `.app`) and any other folder are not files + let bundle = dir.join("Evil.app"); + std::fs::create_dir_all(&bundle).unwrap(); + let e = openable(&bundle.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}"); + let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err(); + assert!(e.contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A relative path is made absolute: the opener runs with its own working directory. + #[test] + fn the_path_handed_over_is_absolute() { + let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = write(&dir, "a.jpg"); + let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg"); + let out = openable(&dotted.to_string_lossy(), "").unwrap(); + assert!(Path::new(&out).is_absolute(), "{out}"); + assert!(out.ends_with("a.jpg"), "{out}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A link named like a photo that points at a program is refused: what the opener runs is + /// the target, not the name. + #[cfg(unix)] + #[test] + fn a_link_to_a_program_is_refused() { + let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = write(&dir, "payload.command"); + let link = dir.join("holiday.jpg"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let e = openable(&link.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("points at") && e.contains("payload.command"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..d575d7490 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2144,12 +2144,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), "photo.openOriginalInExternal" => { + // only a file type LightCraft reads can be handed over (#2135); the extension alone + // decides, so no disk or share is touched while a menu is drawn app.services.open_with.is_some() - && app - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + && app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source { + lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)), + _ => false, + }) } "app.showInFinder" => { app.services.reveal.is_some() @@ -2324,8 +2325,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: if let Some(id) = id { app.ui.external_edits.push(*id); } - if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) { - failed = Some(e); + // the copy is LightCraft's own render, but it goes through the same gate as any other + // path handed to the system opener (#2135) + match lightcraft_engine::open_path::openable(path, "") { + Err(e) => failed = Some(e), + Ok(absolute) => { + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { + failed = Some(e); + } + } } } match (failed, copies.as_slice()) { @@ -2377,11 +2385,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { let mut files = Vec::new(); let mut skipped = Vec::new(); for id in ids { - match app.session.catalog.photo(id).map(|ph| &ph.source) { - Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), - Some(lightcraft_engine::catalog::Source::File { path }) => { - skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) - } + // the path may come from an imported catalog: only a photo file LightCraft reads is + // handed to the opener, never a program (#2135) + match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { + Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { + Ok(absolute) => files.push((id, absolute)), + Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), + }, Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index bbea5356e..4e397208e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1177,6 +1177,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() { h.settle(SETTLE); } +/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the +/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is +/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling. +#[test] +fn open_original_refuses_anything_that_is_not_a_photo_file() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = Default::default(); + let batches: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>> = Default::default(); + let (o, bt) = (opened.clone(), batches.clone()); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + open_all_with: Some(Box::new(move |paths: &[String], _: &str| { + bt.lock().unwrap().push(paths.to_vec()); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let mut record = |name: &str| { + let path = dir.join(name); + std::fs::write(&path, b"MZ").unwrap(); + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + // what a crafted catalog can name as a photo's original + let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"]; + let bad: Vec<_> = programs.iter().map(|n| record(n)).collect(); + let good = record("DSC_0001.NEF"); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + + for (id, name) in bad.iter().zip(programs) { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], false, "{name} was opened: {r}"); + assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}"); + // the menu item is greyed out for such a record, so it is never offered either + exec(&mut h, "library.select", json!({"ids": [id.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu"); + } + assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program"); + + // a program selected together with a photo: only the photo is handed over, by absolute path + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(r["skipped"].to_string().contains("payload.exe"), "{r}"); + let handed = batches.lock().unwrap().clone(); + assert_eq!(handed.len(), 1); + assert_eq!(handed[0].len(), 1, "{handed:?}"); + assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}"); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..de7ba697f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) From e6587c3f82a24963203eb453f75b43ed6d4f5114 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 17 Oct 2026 10:33:47 -0600 Subject: [PATCH 295/315] chore: clear the rustc 1.96 clippy lints that fail `cargo xtask ci` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unrelated to the #2135 security fix in the commit before this one: both lines predate this branch and are rustc/clippy 1.96 lint churn. `cargo clippy --workspace --all-targets` only warns about them, but `cargo xtask ci` runs it with `-D warnings` (xtask/src/main.rs), so the gate fails before any test runs. - `clippy::nonminimal_bool` in `slide_overlay::shadow` (reported twice, two overlapping suggestions on the one expression): `!(A && B) || !C || !D` is `!(A && B && C && D)` by De Morgan, which is what the line now says. The operands are pure float comparisons and `is_finite` guards with no side effects, so this is an exact rewrite — a NaN or infinite `mx0`/`my0` still makes `shadow` return without drawing, as before. (Clippy's second suggestion drops the `my0.is_finite()` term and is *not* equivalent; it is not used.) - `clippy::drop_non_drop` on `drop(write)` in `same_folder_as_original_refuses_photos_without_a_folder`. The line is not dead: `write` is a closure holding a mutable borrow of `written`, and the `drop` ended that borrow so the next assertion could read it. The closure now lives in a block of its own instead, which ends the borrow at the brace rather than relying on `drop` of a non-`Drop` type. Every assertion of the test is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --- crates/engine/src/slide_overlay.rs | 2 +- crates/engine/src/tests_export.rs | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs index 409739335..a27f75d49 100644 --- a/crates/engine/src/slide_overlay.rs +++ b/crates/engine/src/slide_overlay.rs @@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: let my0 = (y - pad).floor().max(-pad); let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); - if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() { + if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) { return; } let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 2f1a05116..0ac8df804 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -1024,21 +1024,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { let mut s = Session::with_demo(); let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); let mut written = Vec::new(); - let mut write = |p: &str, _: &[u8]| -> Result<(), String> { - written.push(p.to_string()); - Ok(()) + // the writer lives in a block of its own, so its borrow of `written` has ended by the time + // the assertion below reads it + let files = { + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { + written.push(p.to_string()); + Ok(()) + }; + let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); + assert!(err.contains("no folder on disk"), "{err}"); + // without stopping: one error per photo, nothing written + let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); + crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap() }; - let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); - assert!(err.contains("no folder on disk"), "{err}"); - // without stopping: one error per photo, nothing written - let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); - let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap(); assert_eq!(files.len(), 2); for (f, id) in files.iter().zip(&ids) { assert_eq!(f["photo"], id.0, "{f}"); assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); } - drop(write); assert!(written.is_empty(), "{written:?}"); // a photo on disk whose folder is gone (moved, an unplugged drive) From 2b173f0bee347c3a44cafef54362cd35d3558496 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick <hi@skylar.cc> Date: Sat, 17 Oct 2026 12:03:10 -0600 Subject: [PATCH 296/315] Build: take craft-docpath from the merged craft-libs rev craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07, which predates resolve_absolute and the Windows colon refusal. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ba84c4a3..447a20ec7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,7 +1075,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" [[package]] name = "craft-ui" diff --git a/Cargo.toml b/Cargo.toml index 3c00cb4f7..39fc8aaaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } From 55898271782c8aa3a8c1f25e331503755983331c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:37:42 +0200 Subject: [PATCH 297/315] Fix offline reverse geocoding for cities at longitude exactly 180 (#2061) Found while testing crates/geocode/src/lib.rs:105-108 (cell) vs lib.rs:189-192 (reverse) Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> --- crates/geocode/src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs index 89fe86822..de0bea5c9 100644 --- a/crates/geocode/src/lib.rs +++ b/crates/geocode/src/lib.rs @@ -104,7 +104,8 @@ pub struct GeoNames { fn cell(p: LatLon) -> (i32, i32) { // valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range - (p.lat.floor() as i32, p.lon.floor() as i32) + // longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes + (p.lat.floor() as i32, (p.lon.floor() as i32 + 180).rem_euclid(360) - 180) } fn short(s: &str) -> String { @@ -253,6 +254,14 @@ mod tests { assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); } + #[test] + fn city_on_the_antimeridian_is_found() { + let g = GeoNames::parse("1\tTaveuni\tTaveuni\t\t0.0\t180.0\tP\tPPL\tFJ\t\t01\n", None, None); + for lon in [179.9, -179.9, 180.0, -180.0] { + assert_eq!(g.reverse(LatLon::new(0.0, lon)).map(|p| p.city).as_deref(), Some("Taveuni"), "from {lon}"); + } + } + #[test] fn high_latitude_cities_two_cells_away() { let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None); From 0d2c77c258bc4a74289c0004f4a847ff9ff5edf5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:38:33 +0200 Subject: [PATCH 298/315] Fix Assisted Culling bursts that cross midnight at month end (#2095) Found while testing crates/engine/src/cmd/cull.rs:17-20 (secs, used by the burst grouping at cull.rs:45) Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> --- crates/engine/src/cmd/cull.rs | 2 +- crates/engine/src/tests_import.rs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 676519d29..030cb6e93 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -16,7 +16,7 @@ const SAME: f32 = 0.93; fn secs(iso: &str) -> Option<i64> { let d = lightcraft_meta::DateTime::parse_iso(iso)?; - Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64) + Some(d.unix_seconds()) } fn analyze(s: &mut Session, p: &Value) -> Result<Value> { diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..13f02ce77 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() { let _ = std::fs::remove_dir_all(&dir); } +/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of +/// every month, including those with fewer than 31 days. +#[test] +fn assisted_culling_groups_bursts_across_month_end() { + let dir = temp_dir("cull-month-end"); + for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] { + let img = lightcraft_scenes::demo_library()[0].render(w, h); + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png")); + for (before, after) in [ + ("2026-01-31T23:59:59", "2026-02-01T00:00:00"), + ("2026-02-28T23:59:59", "2026-03-01T00:00:00"), + ("2026-04-30T23:59:59", "2026-05-01T00:00:00"), + ("2026-06-30T23:59:59", "2026-07-01T00:00:00"), + ("2026-09-30T23:59:59", "2026-10-01T00:00:00"), + ("2026-11-30T23:59:59", "2026-12-01T00:00:00"), + ("2026-12-31T23:59:59", "2027-01-01T00:00:00"), + ] { + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap(); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap(); + let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap(); + assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { From f48b2ad895c8028210fa031f3c56fff1dd79052d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:39:08 +0200 Subject: [PATCH 299/315] Search words with an unknown field prefix as plain text (#2099) Found while testing crates/catalog/src/query.rs:146-177 (token_matches) Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> --- crates/catalog/src/query.rs | 11 +++++++++-- crates/catalog/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index d1548f8bd..311f6dcde 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -173,11 +173,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)), "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), - _ => false, + // Not a known field: the colon is part of a plain word ("10:30", "Re:"). + _ => text_matches(p, &t), }; } + text_matches(p, &t) +} + +/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location, +/// format or a keyword of `p`. +fn text_matches(p: &Photo, t: &str) -> bool { let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format]; - hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t)) + hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t)) } /// Whether `p` has a named face region called `name` (case-insensitive). diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 8f1aaf151..255417098 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -123,6 +123,27 @@ fn filter_search_sort() { assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } +/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain +/// text, not a fielded token that matches nothing. +#[test] +fn free_text_with_a_colon_searches_the_text_fields() { + let mut c = Catalog::new(); + let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00"); + let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.caption = "Meeting at 10:30 with Re: notes".into(); + m.iso = Some(1600); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("meeting"), vec![a]); + assert_eq!(q("10:30"), vec![a], "a free-text token with a colon"); + assert_eq!(q("re:"), vec![a]); + assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word"); + assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded"); + assert_eq!(q("iso:>2000"), Vec::<PhotoId>::new()); + assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing"); +} + /// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an /// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be /// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores From cebb2be4d411c860349249ee7de44490031e944e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:39:42 +0200 Subject: [PATCH 300/315] Keep the zone when shifting a capture time without seconds (#2146) Found while testing crates/catalog/src/dates.rs:52 (shift_iso, tail = iso.trim().get(19..)); used by crates/engine/src/cmd/manage.rs:222 Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> --- crates/catalog/src/dates.rs | 13 ++++++++++++- crates/catalog/src/stacks.rs | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index d180d013d..4d16c2f4d 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option<String> { if !(first..=last).contains(&t) { return None; } - let tail = iso.trim().get(19..).unwrap_or(""); + let iso = iso.trim(); + // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes + let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or(""); Some(format!("{}{tail}", civil(t))) } @@ -222,6 +224,15 @@ mod tests { assert_eq!(normalize_iso("2026-02-30"), None); } + #[test] + fn shift_keeps_the_zone_of_a_time_without_seconds() { + assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00")); + assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z")); + assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00")); + assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00")); + assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00")); + } + #[test] fn runs_by_day_month_year_keep_stacks_together() { let mut c = Catalog::new(); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index a713b7736..eb7e89ef4 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -294,7 +294,8 @@ pub fn iso_seconds(s: &str) -> Option<i64> { if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return None; } - let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) }; + let (hh, mm, ss) = + if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) }; // days from civil (proleptic Gregorian) let y2 = if m <= 2 { y - 1 } else { y }; let era = y2.div_euclid(400); From 43a47e5a3b65fa4d5af03df60292308434404574 Mon Sep 17 00:00:00 2001 From: Alex Chambers <alexchambers91@gmail.com> Date: Sun, 18 Oct 2026 02:40:14 +0200 Subject: [PATCH 301/315] Issue #1952: clip non-finite float image samples safely (#2071) --- crates/codecs/README.md | 4 + crates/codecs/src/convert.rs | 19 +++-- crates/codecs/tests/float_tiff.rs | 128 ++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 crates/codecs/tests/float_tiff.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 6ea5866b7..5902243fc 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc` emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding. +Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity +become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths +preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1. + ## Dependencies and licences All pure Rust, verified with `cargo info` / `cargo tree`: diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 02652db5a..836aed548 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -44,11 +44,23 @@ impl Buf { match self { Buf::U8(v) => v[i] as f32 / 255.0, Buf::U16(v) => v[i] as f32 / 65535.0, - Buf::F32(v) => v[i], + Buf::F32(v) => normalize_float(v[i]), } } } +/// Clip non-finite device samples before colour conversion, preserving finite extended range. +#[inline] +fn normalize_float(value: f32) -> f32 { + if value == f32::INFINITY { + 1.0 + } else if value.is_finite() { + value + } else { + 0.0 + } +} + /// Interleaved device samples as produced by a format decoder. pub(crate) struct Raw { pub width: usize, @@ -218,10 +230,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f { par_rows(&mut img.data, w, |y, row| { for (x, o) in row.iter_mut().enumerate() { let i = (y * w + x) * ch; - let s = |c: usize| { - let val = v[i + c]; - if val.is_finite() { val } else { 0.0 } - }; + let s = |c: usize| normalize_float(v[i + c]); *o = if gray { let l = if lin { s(0) } else { trc[1].to_linear(s(0)) }; [l, l, l] diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs new file mode 100644 index 000000000..fe1cd2411 --- /dev/null +++ b/crates/codecs/tests/float_tiff.rs @@ -0,0 +1,128 @@ +//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6). + +use lightcraft_codecs::{icc, *}; + +fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec<u8> { + encode_tiff( + &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)), + TiffCompression::None, + &EncodeMeta { icc: profile, ..Default::default() }, + ) + .unwrap() +} + +#[test] +fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() { + let samples = + [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]]; + let bytes = tiff(samples.as_flattened(), 3, None); + let decoded = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(decoded.float); + assert_eq!(decoded.bit_depth, 32); + assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]); + let display = decoded.to_srgb8(); + assert_eq!(display.data[0], [255; 4]); + for pixel in &display.data[1..4] { + assert_eq!(*pixel, [0, 0, 0, 255]); + } + assert_eq!(display.data[6], [255, 188, 0, 255]); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); + + // A box-filtered pair must average clipped white with its ordinary neighbor. + let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None); + let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap(); + assert_eq!(small.image.data, [[0.625; 3]]); +} + +#[test] +fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() { + let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25]; + let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25]; + let profiles = [ + None, + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)), + Some(icc::write_named(NamedSpace::Srgb)), + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))), + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))), + Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()), + Some(b"invalid ICC profile".to_vec()), + ]; + for channels in [1, 3] { + let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect(); + let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect(); + for profile in &profiles { + let profile = profile.as_deref(); + let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap(); + let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.grayscale, channels == 1); + assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space); + assert_eq!(decoded.to_srgb8(), reference.to_srgb8()); + assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve"); + assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve"); + } + } +} + +#[test] +fn float_tiff_nonfinite_alpha_is_finite_and_bounded() { + let samples = [ + [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY], + [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN], + [0.25, 0.5, 2.0, f32::NEG_INFINITY], + [2.0, 3.0, 4.0, 0.5], + ]; + let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]); + assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); + + // Associated alpha takes the same path before unpremultiplication. + let mut cursor = std::io::Cursor::new(Vec::new()); + { + use tiff::encoder::{TiffEncoder, colortype}; + let mut encoder = TiffEncoder::new(&mut cursor).unwrap(); + let mut image = encoder.new_image::<colortype::RGBA32Float>(2, 1).unwrap(); + image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap(); + image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap(); + } + let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]); + assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]); +} + +fn lut_profile() -> Vec<u8> { + use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd}; + let mut profile = ColorProfile::new_srgb(); + profile.cicp = None; + profile.red_colorant = Xyzd::default(); + profile.green_colorant = Xyzd::default(); + profile.blue_colorant = Xyzd::default(); + profile.red_trc = None; + profile.green_trc = None; + profile.blue_trc = None; + // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves. + profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3], + matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) }, + b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3], + bias: Vector3d::default(), + })); + profile.encode().unwrap() +} + +#[test] +fn float_tiff_nonfinite_cms_samples_match_clipped_samples() { + let profile = lut_profile(); + let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75]; + let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75]; + let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap(); + let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.space.origin, SpaceOrigin::IccCms); + assert_eq!(decoded.image, reference.image); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); +} From 20ccedf56e64c9302d67d68e185daba54acf7167 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sat, 17 Oct 2026 21:42:57 -0400 Subject: [PATCH 302/315] Release 0.9.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 66 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 447a20ec7..a33b59a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3078,7 +3078,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -3103,7 +3103,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3120,7 +3120,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.8.0" +version = "0.9.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3166,7 +3166,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.8.0" +version = "0.9.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3192,7 +3192,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3207,7 +3207,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3219,7 +3219,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "ab_glyph", "craft-docpath", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.8.0" +version = "0.9.0" dependencies = [ "log", "rustls", @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.8.0" +version = "0.9.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3296,7 +3296,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.8.0" +version = "0.9.0" dependencies = [ "proptest", "serde", @@ -3304,7 +3304,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.8.0" +version = "0.9.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3324,7 +3324,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.8.0" +version = "0.9.0" dependencies = [ "heic-rs", "rayon", @@ -3332,14 +3332,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.8.0" +version = "0.9.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3348,7 +3348,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3359,7 +3359,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "lightcraft-color", @@ -3405,7 +3405,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.8.0" +version = "0.9.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3417,7 +3417,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.8.0" +version = "0.9.0" dependencies = [ "libc", "lightcraft-color", @@ -3429,7 +3429,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.8.0" +version = "0.9.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3453,7 +3453,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.8.0" +version = "0.9.0" dependencies = [ "image", "lightcraft-color", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.8.0" +version = "0.9.0" dependencies = [ "candle-core", "candle-nn", @@ -3478,7 +3478,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.8.0" +version = "0.9.0" dependencies = [ "lightcraft-raster", "serde", @@ -3487,15 +3487,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.8.0" +version = "0.9.0" [[package]] name = "lightcraft-testkit" -version = "0.8.0" +version = "0.9.0" [[package]] name = "lightcraft-tiff" -version = "0.8.0" +version = "0.9.0" dependencies = [ "proptest", "serde", @@ -3504,7 +3504,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-ui", "egui", @@ -3533,7 +3533,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "eframe", @@ -7003,7 +7003,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.8.0" +version = "0.9.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 39fc8aaaf..309ec5412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 4f0b110bcbd2e350c6d97b47f1d824c2222295c4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sat, 17 Oct 2026 23:16:37 -0400 Subject: [PATCH 303/315] Release 0.9.1: the release web build installs the wasm-bindgen CLI Since the web zip moved from trunk to cargo xtask web (aeaf4154), packaging/web/package.sh needs the wasm-bindgen CLI at Cargo.lock's version, and the shared release workflow installs only trunk, so 0.9.0's Web (wasm) job failed. In CI, install it first (as FilmCraft and EffectCraft do). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 66 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- packaging/web/package.sh | 9 ++++++ 3 files changed, 43 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a33b59a81..30ce9e1f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3078,7 +3078,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.9.0" +version = "0.9.1" dependencies = [ "eframe", "egui", @@ -3103,7 +3103,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.9.0" +version = "0.9.1" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3120,7 +3120,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.9.0" +version = "0.9.1" dependencies = [ "dhat", "lightcraft-codecs", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.9.0" +version = "0.9.1" dependencies = [ "avif-serialize", "bytemuck", @@ -3166,7 +3166,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-geom", "proptest", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.9.0" +version = "0.9.1" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3192,7 +3192,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3207,7 +3207,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3219,7 +3219,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.9.0" +version = "0.9.1" dependencies = [ "ab_glyph", "craft-docpath", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3277,7 +3277,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.9.0" +version = "0.9.1" dependencies = [ "log", "rustls", @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.9.0" +version = "0.9.1" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3296,7 +3296,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.9.0" +version = "0.9.1" dependencies = [ "proptest", "serde", @@ -3304,7 +3304,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.9.0" +version = "0.9.1" dependencies = [ "bytemuck", "lightcraft-color", @@ -3324,7 +3324,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.9.0" +version = "0.9.1" dependencies = [ "heic-rs", "rayon", @@ -3332,14 +3332,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.9.0" +version = "0.9.1" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3348,7 +3348,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3359,7 +3359,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.9.0" +version = "0.9.1" dependencies = [ "image", "lightcraft-color", @@ -3405,7 +3405,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.9.0" +version = "0.9.1" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3417,7 +3417,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.9.0" +version = "0.9.1" dependencies = [ "libc", "lightcraft-color", @@ -3429,7 +3429,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.9.0" +version = "0.9.1" dependencies = [ "dhat", "jpeg-encoder", @@ -3453,7 +3453,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.9.0" +version = "0.9.1" dependencies = [ "image", "lightcraft-color", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.9.0" +version = "0.9.1" dependencies = [ "candle-core", "candle-nn", @@ -3478,7 +3478,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.9.0" +version = "0.9.1" dependencies = [ "lightcraft-raster", "serde", @@ -3487,15 +3487,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.9.0" +version = "0.9.1" [[package]] name = "lightcraft-testkit" -version = "0.9.0" +version = "0.9.1" [[package]] name = "lightcraft-tiff" -version = "0.9.0" +version = "0.9.1" dependencies = [ "proptest", "serde", @@ -3504,7 +3504,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.9.0" +version = "0.9.1" dependencies = [ "craft-ui", "egui", @@ -3533,7 +3533,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.9.0" +version = "0.9.1" dependencies = [ "crc32fast", "eframe", @@ -7003,7 +7003,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.9.0" +version = "0.9.1" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 309ec5412..cf21739d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.9.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 64b6d2149..16691a6ba 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,6 +12,15 @@ set -euo pipefail HERE="$ROOT/packaging/web" if [ "${1:-}" != "--skip-build" ]; then + # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the + # version in Cargo.lock. Locally, `cargo xtask web` says which version to install. + if [ -n "${CI:-}" ]; then + WASM_BINDGEN_VERSION="$(awk '$0 == "name = \"wasm-bindgen\"" { getline; gsub(/version = |"/, ""); print; exit }' "$ROOT/Cargo.lock")" + [ -n "$WASM_BINDGEN_VERSION" ] || { echo "error: wasm-bindgen not found in Cargo.lock" >&2; exit 1; } + if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF " $WASM_BINDGEN_VERSION"; }; then + cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + fi + fi (cd "$ROOT" && cargo xtask web) fi From 863adcac6ff4079672557c6d1990a30c8836ab27 Mon Sep 17 00:00:00 2001 From: "Yorgos S." <smyrgeorge@gmail.com> Date: Sun, 18 Oct 2026 11:46:07 +0200 Subject: [PATCH 304/315] X-Trans CMOS 4 bodies take the X-T3's spectral matrices An X-T30 RAF shot in Acros embeds a black-and-white camera JPEG, so the per-file look fits only the tone curve, and the colour came from the neutral "camera RGB ~ sRGB" fallback: a grey sky and muted colours. The raw decode (lossless compressed) was correct. Add a hand-kept SAME_SENSOR alias list next to the generated rawtoaces-data table: the 26.1 MP X-Trans CMOS 4 bodies (X-T30, X-T30 II, X-Pro3, X100V, X-S10, X-E4) use the X-T3's measured matrices, and their white balance reads in Kelvin through them. --- crates/raw/src/spectral.rs | 23 ++++++++++++++++++++--- docs/camera-preview-colour.md | 6 +++++- docs/gaps.md | 3 ++- docs/raw-parity.md | 7 ++++--- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index cfa99ff5d..6224d374f 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -4,7 +4,8 @@ //! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG //! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral //! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the -//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list. No number +//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other +//! bodies built on a covered camera's sensor from [`SAME_SENSOR`]. No number //! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice //! (`docs/camera-preview-colour.md`). use crate::ColorData; @@ -40,6 +41,19 @@ impl Camera { } } +/// `(make, model as sold, table model)`: bodies sharing a table camera's sensor that rawtoaces-data's alias list +/// doesn't name, kept here because the table is generated. Fujifilm's 26.1 MP X-Trans CMOS 4 bodies use the X-T3's +/// sensor (X-T30 issue: a black-and-white Acros camera JPEG gives no colour to fit, and without matrices the photo +/// opened in the muted neutral fallback). +const SAME_SENSOR: &[(&str, &str, &str)] = &[ + ("Fujifilm", "X-T30", "X-T3"), + ("Fujifilm", "X-T30 II", "X-T3"), + ("Fujifilm", "X-Pro3", "X-T3"), + ("Fujifilm", "X100V", "X-T3"), + ("Fujifilm", "X-S10", "X-T3"), + ("Fujifilm", "X-E4", "X-T3"), +]; + /// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL` /// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D"). fn key(brand: &str, model: &str) -> String { @@ -70,7 +84,7 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { let wanted = key(&row_brand, model); make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted }; - match table::ALIASES.iter().find(|(m, alias, _)| same(m, alias)) { + match table::ALIASES.iter().chain(SAME_SENSOR).find(|(m, alias, _)| same(m, alias)) { Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target), None => table::CAMERAS.iter().find(|c| same(c.make, c.model)), } @@ -146,6 +160,9 @@ mod tests { ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), ("FUJIFILM", "GFX100S", "GFX 100"), + ("FUJIFILM", "X-T30", "X-T3"), + ("FUJIFILM", "X-T30 II", "X-T3"), + ("FUJIFILM", "X100V", "X-T3"), ] { assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); } @@ -171,7 +188,7 @@ mod tests { assert_eq!(find_camera(&joined), Some(c), "{joined}"); assert_eq!(find_camera(&joined), find(Some(c.make), c.model), "{joined}"); } - for (make, alias, _) in table::ALIASES { + for (make, alias, _) in table::ALIASES.iter().chain(SAME_SENSOR) { let joined = format!("{make} {alias}"); assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}"); assert!(find_camera(&joined).is_some(), "{joined}"); diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9a259e9ed..463ccaed7 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -173,7 +173,11 @@ For 52 camera models LightCraft has colour matrices fitted to the camera's measu (`crates/raw/src/spectral.rs`, generated into `spectral_table.rs` by `tools/spectral_camera_table.py` from [rawtoaces-data](https://github.com/AcademySoftwareFoundation/rawtoaces-data) v1.1.0, Apache-2.0): 22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names rawtoaces-data lists for them (EOS Rebel T3i and Kiss X5 for -the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100). Per model the generator fits, +the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100), and the bodies `SAME_SENSOR` +in `spectral.rs` adds by hand because the table is generated: Fujifilm's 26.1 MP X-Trans CMOS 4 bodies (X-T30, X-T30 II, +X-Pro3, X100V, X-S10, X-E4) take the X-T3's matrices. Without them an X-T30 raw whose camera JPEG was shot in Acros +(black and white) got its tone curve on the muted neutral fallback, with a grey sky where the scene had a deep blue +one. Per model the generator fits, under CIE Standard Illuminant A and under D65, a white-preserving 3×3 from white-balanced camera RGB to XYZ by the least mean CIE76 error over rawtoaces-data's 190 training reflectances, and writes DNG `ColorMatrix1/2` and `ForwardMatrix1/2`. The existing DNG colour model then interpolates them for the file's as-shot white, which the diff --git a/docs/gaps.md b/docs/gaps.md index f11a74f7a..bedd9818e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -15,7 +15,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| -| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices (plus same-sensor bodies, e.g. the X-T30 through the X-T3); others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | | 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) | @@ -77,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..bba972d8f 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -22,7 +22,7 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence | | …of which on a body we have verified | **~85%** | estimated, same weights | -| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | +| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) | @@ -205,7 +205,7 @@ sample files. A pointer for contributors, not legal advice. | Item | Adobe | LightCraft | |---|---|---| -| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix | +| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 X-Trans CMOS 4 Fujifilm bodies take the X-T3's); else a neutral matrix | | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From b9159b0fcee31a09fa724bf81807218771a46ded Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <marcoarnulfo1@gmail.com> Date: Sun, 18 Oct 2026 13:11:47 +0200 Subject: [PATCH 305/315] TIFF: keep the default 128 MiB intermediate buffer for small pixel budgets --- crates/codecs/src/tiff_codec.rs | 8 +++-- crates/codecs/tests/roundtrip.rs | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index 6f1e25b13..1bbfa8e6c 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -95,9 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Decoded> { fn limits(opts: &DecodeOptions) -> Limits { let mut limits = Limits::default(); limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; - // One strip or tile is read whole: the default 128 MiB refuses a single-strip uncompressed 16-bit - // export of ~22 MP (#1983). The same cap as the decoded image (32 bytes per allowed pixel) bounds it. - limits.intermediate_buffer_size = limits.decoding_buffer_size; + // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a + // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap + // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows + // (JPEG headers, padding to the tile size). + limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size); limits.ifd_value_size = 32 << 20; limits } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 489255c09..aac7db72a 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -285,6 +285,68 @@ fn tiff_single_strip_larger_than_128_mib() { assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}"); } +/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`. +fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec<u8> { + let mut b = b"II*\0".to_vec(); + b.extend(8u32.to_le_bytes()); + b.extend((entries.len() as u16).to_le_bytes()); + for &(tag, ty, value) in entries { + b.extend(tag.to_le_bytes()); + b.extend(ty.to_le_bytes()); + b.extend(1u32.to_le_bytes()); + b.extend(value.to_le_bytes()); + } + b.extend(0u32.to_le_bytes()); + b.extend_from_slice(data); + b +} + +/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so +/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB +/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at +/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989. +#[test] +fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() { + let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels }; + + // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget. + let mut jpeg = Vec::new(); + jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap(); + let at = 8 + 2 + 10 * 12 + 4; + let entries = [ + (256, 4, 1), + (257, 4, 1), + (258, 3, 8), + (259, 3, 7), + (262, 3, 1), + (273, 4, at), + (277, 3, 1), + (278, 4, 1), + (279, 4, jpeg.len() as u32), + (284, 3, 1), + ]; + let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap(); + assert_eq!((d.image.width, d.image.height), (1, 1)); + + // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels. + let at = 8 + 2 + 11 * 12 + 4; + let entries = [ + (256, 4, 16), + (257, 4, 16), + (258, 3, 8), + (259, 3, 1), + (262, 3, 1), + (277, 3, 1), + (284, 3, 1), + (322, 4, 256), + (323, 4, 256), + (324, 4, at), + (325, 4, 65536), + ]; + let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap(); + assert_eq!((d.image.width, d.image.height), (16, 16)); +} + /// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and /// already packed into `strip`, one padded row after another. fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> { From 601ddce6ef661cf965d88cd20fbb76fa90c65917 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:43:55 +0200 Subject: [PATCH 306/315] Match f:/aperture:/focal: search tokens at f32 precision Found while testing crates/catalog/src/query.rs:130 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- crates/catalog/src/query.rs | 11 +++++++++-- crates/catalog/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 311f6dcde..7625c9ab6 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool { Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; + // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8. + let cmp32 = |x: f32, s: &str| match num(s) { + Some(('>', v)) => x > v as f32, + Some(('<', v)) => x < v as f32, + Some((_, v)) => x == v as f32, + None => false, + }; return match field { "rating" | "stars" => cmp(p.rating as f64, val), "flag" => Flag::parse(val) == Some(p.flag), "label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)), "iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)), - "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)), - "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)), + "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)), + "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)), "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 255417098..a32b5acf5 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -144,6 +144,27 @@ fn free_text_with_a_colon_searches_the_text_fields() { assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing"); } +/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary +/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value. +#[test] +fn search_tokens_match_f32_aperture_and_focal() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.aperture = Some(2.8); + m.focal_mm = Some(5.6); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("f:2.8"), vec![a], "f:2.8"); + assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8"); + assert_eq!(q("focal:5.6"), vec![a], "focal:5.6"); + assert!(q("f:2.9").is_empty(), "f:2.9"); + assert!(q("f:<2.8").is_empty(), "f:<2.8"); + assert!(q("f:>2.8").is_empty(), "f:>2.8"); + assert!(q("focal:<5.6").is_empty(), "focal:<5.6"); + assert_eq!(q("f:>2.7"), vec![a], "f:>2.7"); +} + /// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an /// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be /// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores From 0c3c500ea408de17c7a792f3ae340afa96cc62ed Mon Sep 17 00:00:00 2001 From: Hamza <mail.hamzanaveed@gmail.com> Date: Sun, 18 Oct 2026 18:12:18 +0500 Subject: [PATCH 307/315] Georgian: a symbol Noto Sans Georgian cannot paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo xtask ci` fails with the craft-fonts input supplied, which CLAUDE.md says releases always build with: ka in Proportional (faces: ["Noto Sans Georgian"]) lack glyphs: ✕ Two strings in ka.json use ✕ (U+2715). Noto Sans Georgian has no glyph for it and egui's bundled fonts do not either, so a Georgian reader gets a blank where the text describes the activity stack's stop control. Without the fonts input those twenty tests skip, so a plain `cargo xtask ci` is green and this stayed invisible. The two Georgian values use × (U+00D7) instead: it reads the same at text size and every face has it. The English keys are untouched — they are the source text from docs/whats-new.md, and the test checks values, which is the right scope. An English reader's font covers ✕; the problem is a translation carrying it into a script whose face does not. `translated_catalogs_do_not_use_missing_menu_triangles` already forbids ▸ and ▾ in locale JSON for the same reason, so ✕ joins them in one list. That matters because ✕ is in the English source of those two strings: every language that translates them next would hit the same failure, and a cheap test now catches it instead of the font-dependent one. --- crates/ui-egui/locales/ka.json | 4 ++-- crates/ui-egui/src/i18n.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index bafd7827c..f6530929d 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -2575,7 +2575,7 @@ "on the CPU, are not waited for).": "CPU-ზე, არ ელოდება).", "on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the": "DLL-ების საძიებო გზაზეა მისი `dxil.dll`-ის გარეშე (ხარვეზი #471): DirectX 12 შეიდერები ახლა ყოველთვის კომპილირდება", "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "ბარათზე იმპორტის გადახედვის შემდეგ, აღირიცხება იმის ნაცვლად, რომ მოძველებული მონაცემებით იმპორტირდეს. ექსპორტის მისამართის შემოწმება", - "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; ✕ აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის", + "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; × აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის", "once you zoom out or move on.": "როცა დააპატარავებთ ან სხვაზე გადახვალთ.", "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "სათითაოდ — მათი ხელახლა ექსპორტი ყოველთვის შეიძლება, ხოლო USB დისკზე ან NAS-ზე თითოეული ფაილის სინქრონიზაცია დიდ", "only convert between the camera's multipliers and Kelvin; the colour look is what it was.": "მხოლოდ კამერის მამრავლებსა და კელვინებს შორის გარდაქმნის; ფერის იერი ისეთივეა, როგორიც იყო.", @@ -2615,7 +2615,7 @@ "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "რენდერები და ეკრანის ანაბეჭდები კვლავ დროებით ფაილში იწერება და ადგილზე გადაირქმევა, მაგრამ დისკზე იძულებით აღარ ჩაიწერება", "reports each row's `label`.": "თითოეული სტრიქონის `label`-ს აბრუნებს.", "restore the photo too and say \"Restored 1 photo from Recently Deleted\". A deleted photo whose file was moved is": "ფოტოს ასევე აღადგენს და ამბობს „ახლახან წაშლილიდან აღდგა 1 ფოტო“. წაშლილი ფოტო, რომლის ფაილიც გადაიტანეს,", - "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (✕ აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს", + "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (× აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს", "save goes through.": "შენახვა შესრულდება.", "saved in": "შენახულია:", "saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "შენახული მორგებული ტონირება ახლა საპირისპიროდ იხრება; კვლავ დააყენეთ (ან ხელახლა გაუშვით ავტომატური / თეთრის ბალანსის პიპეტი).", diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9ba6c5462..ed7ad602e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -749,6 +749,12 @@ mod tests { }); } + /// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped + /// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, + /// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads + /// the same and every face has it. + const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; + #[test] fn translated_catalogs_do_not_use_missing_menu_triangles() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); @@ -759,7 +765,9 @@ mod tests { } let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); for (source, text) in messages { - assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display()); + for bad in UNPAINTABLE { + assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); + } } } } From 6985cfa54cb7165bd5ad8b89249bbe81aca4e59d Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sun, 18 Oct 2026 09:18:48 -0400 Subject: [PATCH 308/315] Drop the README notes: toast language is not README material Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 2 +- README.zh-hans.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4dba64bec..53ec908de 100644 --- a/README.md +++ b/README.md @@ -427,7 +427,7 @@ Without it LightCraft builds and runs the same, but Chinese and Japanese text ha repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). **Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → -Language**; the choice applies immediately and persists. Info Overlay mode-change toasts use the selected interface language. See [docs/localization.md](docs/localization.md). +Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS diff --git a/README.zh-hans.md b/README.zh-hans.md index fc9373445..f2d155214 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -394,7 +394,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft 本仓库;见 [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)。 **界面语言:** **编辑 → 语言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français)或 **设置 → 一般 → -语言**;选择立即生效并会保留。信息叠加模式切换提示使用所选界面语言。见 [docs/localization.md](docs/localization.md)。 +语言**;选择立即生效并会保留。见 [docs/localization.md](docs/localization.md)。 **日志:** 桌面应用把日志写到标准错误,以及设置文件夹里的 `logs/lightcraft.log` (Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,默认 `~/.config/lightcraft/logs/`;macOS From 1e4aa93d58eda4b3ec781722908c0442078c205f Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sun, 18 Oct 2026 09:21:12 -0400 Subject: [PATCH 309/315] LRC-PRINT-PAGE: the preview draws no Page Options for a package, as the print doesn't Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/ui-egui/src/print_view.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 6e35f5b10..0c3745270 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -876,7 +876,11 @@ fn paint_cell( /// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each /// photo, the page number at the bottom right and the print settings at the bottom left. +/// Packages draw none of them, as the print doesn't ([`compose::has_page_options`]). fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) { + if !compose::has_page_options(st.layout.style) { + return; + } let p = &st.page; let ink = compose::mark_ink(p.background); let ink = Color32::from_rgb(ink[0], ink[1], ink[2]); From fe7edd7b26b211892a6fa2353a9304a2642bba96 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sun, 18 Oct 2026 09:21:34 -0400 Subject: [PATCH 310/315] Model downloads: the What's New note gets its own heading Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/whats-new.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/whats-new.md b/docs/whats-new.md index 403bbe2c3..2f02ffb91 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Model downloads time out on a name that doesn't resolve +- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout, + and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time + limit, and each of a server's addresses then got the whole connect timeout of its own. + ### Lightroom radial masks on the right side - Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in @@ -152,9 +157,6 @@ ⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step. `mask.brushStroke {connect: true}` does the same for agents. -- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout, - and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time - limit, and each of a server's addresses then got the whole connect timeout of its own. ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. From 19008bdee95b5ad1e5340e093f9b324655a51a81 Mon Sep 17 00:00:00 2001 From: Michael Anderson <andermic@gmail.com> Date: Sun, 18 Oct 2026 08:36:24 -0500 Subject: [PATCH 311/315] Test cancelling a name lookup while it waits --- crates/fetch/src/http.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index 98eee913b..37ec6924c 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -573,10 +573,19 @@ mod tests_connect { let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel)); assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}"); assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); - cancel.store(true, Ordering::Relaxed); + // Cancel while the lookup is waiting on the resolver, not before it starts let t = Instant::now(); + let r = std::thread::scope(|scope| { + scope.spawn(|| { + std::thread::sleep(Duration::from_millis(100)); + cancel.store(true, Ordering::Relaxed); + }); + lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)) + }); + assert!(matches!(r, Err(HttpError::Cancelled)), "{r:?}"); + assert!(t.elapsed() >= Duration::from_millis(100) && t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed()); + // and a lookup asked for after Cancel doesn't start assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled))); - assert!(t.elapsed() < Duration::from_secs(1)); // a lookup that answers is used let cancel = AtomicBool::new(false); let local: SocketAddr = "127.0.0.1:9".parse().unwrap(); From 70265ed17adcf7578b39bf989070bd30279d2d21 Mon Sep 17 00:00:00 2001 From: Brandon Thomas <echelon@gmail.com> Date: Sun, 18 Oct 2026 09:53:39 -0400 Subject: [PATCH 312/315] Batch 42: #1893's heap test passes the Copy DecodeOptions without clone (clippy 1.99) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- crates/codecs/tests/jxl_tail_heap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs index 068ebc952..f2200e862 100644 --- a/crates/codecs/tests/jxl_tail_heap.rs +++ b/crates/codecs/tests/jxl_tail_heap.rs @@ -32,7 +32,7 @@ fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() { let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); let mut raw = codestream(&encoded); let opts = DecodeOptions { max_pixels: 64, ..Default::default() }; - decode(&raw, opts.clone()).unwrap(); + decode(&raw, opts).unwrap(); raw.resize(raw.len() + (1 << 20), 0); let _profiler = dhat::Profiler::builder().testing().build(); let decoded = decode(&raw, opts).unwrap(); From 31a677fe3dbf4bc1042f8f6346e83b0afd1b04e8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Sun, 18 Oct 2026 10:41:49 -0400 Subject: [PATCH 313/315] docs: drop references to competing commercial software (#2213) --- README.md | 12 +++++------- README.zh-hans.md | 12 +++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 19eb7c0f2..38ff85898 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ </p> <p align="center"> - <b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br> + <b>Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.</b><br> Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP. </p> @@ -333,9 +333,9 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and [docs/target-app-parity.md](docs/target-app-parity.md)): -- **By feature count we're at ~82%** of Lightroom (core features 98%), tracked row by row in +- **By feature count we're at ~82%** (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). -- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for +- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** @@ -513,9 +513,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp` Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version: -- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…); +- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…); work from public specs and black-box observation. -- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every +- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here. @@ -645,8 +645,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub> - <p align="center"> <a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br> <sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub> diff --git a/README.zh-hans.md b/README.zh-hans.md index f2d155214..58e1d627f 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -18,7 +18,7 @@ </p> <p align="center"> - <b>照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。</b><br> + <b>照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。</b><br> 在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。 </p> @@ -299,9 +299,9 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)): -- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在 +- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在 [docs/parity.md](docs/parity.md)。 -- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ +- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; @@ -453,9 +453,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本: -- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); +- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); 只依据公开规范和黑盒观察来工作。 -- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 +- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中 于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到 [storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。 @@ -579,8 +579,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改 本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。 分支和修改版本必须移除它们。 -<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。</sub> - <p align="center"> <a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br> <sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 团队和社区制作。</sub> From fb7ac08b9ae43956f99436437ea5f549a07e3ce1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" <dylan@dylanmtaylor.com> Date: Sun, 18 Oct 2026 14:31:40 -0400 Subject: [PATCH 314/315] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com> --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From d999e382bafbb5d5c54482fdb1d11f6a63bfdf69 Mon Sep 17 00:00:00 2001 From: Francois <32835330+francoisgn@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:59:33 +0200 Subject: [PATCH 315/315] #1949: pt-BR for Include selected photos and the mask overlay modes (#2033) Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK Co-authored-by: Claude <noreply@anthropic.com> --- crates/ui-egui/locales/pt-br.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 85a0ae039..2c9e9da05 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2180,5 +2180,11 @@ "Checking the GPU…": "Verificando a GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.", - "Checking the GPU for faces": "Verificando a GPU para rostos" + "Checking the GPU for faces": "Verificando a GPU para rostos", + "Include selected photos": "Incluir fotos selecionadas", + "Color Overlay": "Sobreposição de cor", + "Color Overlay on B&W": "Sobreposição de cor em P&B", + "Image on Black": "Imagem sobre preto", + "Image on White": "Imagem sobre branco", + "White on Black": "Branco sobre preto" }